feat(web): full auth screens rework to match design + old-app business rules
Rebuilt tenant/login/register/reset-password to match the design handoff's 01-account screens precisely (previously only loosely approximated), and ported the old app's real business rules instead of guessing: - LoginPage: desktop split branding panel (>=900px), terms-agreement checkbox gating submit, forgot-password link, password visibility toggle, full ctrl-error translation table (lib/tinode/errors.ts, ported from the old app's CTRL_ERROR_TEXT_CN/translateCtrlError -- registration/login go over native WS, not REST, so client.ts's REASON_MESSAGES never applied). - RegisterPage: rebuilt as the real unified username-OR-phone form (not phone-only) -- username optional unless phone is blank, invite code required+validated only on the username-only path, matching the server's actual resolveRegistrationAccount rules. connection.ts's registerAccountByPhone generalized into registerAccount() supporting both paths, including the Tinode.credential(null) crash the old app had to work around on the username-only path. - New ResetPasswordPage wired to /auth/password-reset/* (a different endpoint family than registration's /auth/verification-codes/*). - Shared components: PasswordInput, VerificationCodeButton, Agreement, Toast. - Fixed tinode-sdk.d.ts: the real method is getCurrentUserID(), not the invented getAuthenticatedUserID() -- caught live, login crashed past a successful auth. - Fixed a responsive bug present on all four auth pages: fixed w-[380px] forms overflowed past the viewport edge below ~430px wide. Verified end-to-end against the real backend (api.118im.com, tenant "default"): fresh phone registration, password reset, re-login with the new password, and the username-only path's invite-code rejection all round-tripped correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
63c1b84b41
commit
aa1c46ff22
@@ -21,3 +21,28 @@ export function verifyRegisterCode(challengeId: string, code: string) {
|
||||
body: { code },
|
||||
});
|
||||
}
|
||||
|
||||
export interface PasswordResetVerifyResponse {
|
||||
reset_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset verifies through an entirely different endpoint family than registration
|
||||
* (`/auth/password-reset/*`, not `/auth/verification-codes/*`) even though both share the same
|
||||
* `/auth/verification-codes` code-request endpoint via `scene`. No Idempotency-Key, same reasoning
|
||||
* as verifyRegisterCode.
|
||||
*/
|
||||
export function verifyPasswordResetCode(challengeId: string, code: string) {
|
||||
return imRequest<PasswordResetVerifyResponse>(
|
||||
`/auth/password-reset/challenges/${encodeURIComponent(challengeId)}/verify`,
|
||||
{ method: 'POST', auth: false, body: { code } },
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmPasswordReset(resetToken: string, newPassword: string) {
|
||||
return imPost<void>(
|
||||
'/auth/password-reset/confirm',
|
||||
{ reset_token: resetToken, new_password: newPassword },
|
||||
{ auth: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,43 +124,62 @@ export function subscribeConnectionStatus(callback: () => void): () => void {
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
export interface RegisterPhoneParams {
|
||||
phoneE164: string;
|
||||
export interface RegisterParams {
|
||||
/** `''` when omitted -- valid to leave blank as long as `phone` is filled. */
|
||||
username: string;
|
||||
/** Bare 11-digit phone, no "+86" prefix. `''` on the username-only path (no phone at all). */
|
||||
phone: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
verificationToken: string;
|
||||
challengeId: string;
|
||||
/** `''` when omitted. Required (and validated) by the caller on the username-only path. */
|
||||
inviteCode: string;
|
||||
/** Only meaningful when `phone` is non-empty -- from verifyRegisterCode()'s response. */
|
||||
verificationToken?: string;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone-based registration (product Ch.1.4's phone path). Two DIFFERENT phone representations are
|
||||
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed
|
||||
* live against the real backend, then traced to server/user.go's resolveRegistrationAccount):
|
||||
* - `login_` (this function's first createAccountBasic arg, becomes the secret's account half)
|
||||
* must equal what resolveRegistrationAccount computes as secretAccount -- for phone
|
||||
* registration that's the E.164-normalized phone, e.g. "+8613900005232".
|
||||
* Unified username-or-phone registration (product Ch.1.4). Two DIFFERENT phone representations are
|
||||
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed live
|
||||
* against the real backend, then traced to server/user.go's resolveRegistrationAccount):
|
||||
* - `login_` (createAccountBasic's first arg, becomes the secret's account half) must equal what
|
||||
* resolveRegistrationAccount computes as secretAccount -- for phone registration (and no
|
||||
* username typed) that's the E.164-normalized phone, e.g. "+8613900005232"; if a username was
|
||||
* also typed, the username itself takes precedence (server/user.go: username!="" && phone!=""
|
||||
* => account=username), matching the old app's fixed bug where a typed username used to get
|
||||
* silently dropped on the phone path.
|
||||
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
|
||||
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit
|
||||
* number with no "+86" prefix, e.g. "13900005232". Sending the E.164 form here fails that
|
||||
* length check immediately, which is exactly the bug this comment is warning future edits away
|
||||
* from reintroducing.
|
||||
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit number
|
||||
* with no "+86" prefix.
|
||||
*
|
||||
* On the username-only path (no phone at all) `cred` must be left `undefined`, never a literal
|
||||
* `null` -- tinode-sdk's `Tinode.credential()` does `typeof meth == 'object'` to sniff its argument
|
||||
* shape, and `typeof null === 'object'` is true in JS, so passing it a real `null` crashes with
|
||||
* "Cannot destructure property 'val' of 'meth' as it is null" instead of sending the {acc} message
|
||||
* at all (confirmed by reading the old app's own history of this exact bug).
|
||||
*/
|
||||
export async function registerAccountByPhone({
|
||||
phoneE164,
|
||||
export async function registerAccount({
|
||||
username,
|
||||
phone,
|
||||
password,
|
||||
displayName,
|
||||
inviteCode,
|
||||
verificationToken,
|
||||
challengeId,
|
||||
}: RegisterPhoneParams): Promise<LoginCtrl> {
|
||||
}: RegisterParams): Promise<LoginCtrl> {
|
||||
const t = getTinode();
|
||||
if (!t.isConnected()) {
|
||||
await connect();
|
||||
}
|
||||
const phoneBare = phoneE164.replace(/^\+86/, '');
|
||||
queueAccExtra({ phone: phoneBare });
|
||||
const cred = Tinode.credential({ meth: 'tel', val: phoneE164, resp: verificationToken, params: { challenge_id: challengeId } });
|
||||
const trimmedName = displayName?.trim();
|
||||
return t.createAccountBasic(phoneE164, password, {
|
||||
const usingPhone = !!phone;
|
||||
queueAccExtra({ username: username || '', phone: phone || '', invite_code: inviteCode || '' });
|
||||
|
||||
const secretAccount = usingPhone ? username || `+86${phone}` : username;
|
||||
const cred =
|
||||
usingPhone && verificationToken && challengeId
|
||||
? Tinode.credential({ meth: 'tel', val: `+86${phone}`, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||
: undefined;
|
||||
|
||||
const trimmedName = username.trim();
|
||||
return t.createAccountBasic(secretAccount, password, {
|
||||
public: trimmedName ? { fn: trimmedName } : undefined,
|
||||
cred: cred ?? undefined,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Tinode WS {ctrl} error translation. Registration/login go over the native WS protocol, not
|
||||
// REST, so client.ts's REASON_MESSAGES table (REST-only) never sees these -- the SDK's own
|
||||
// CommError text is raw English with a status-code suffix baked in ("authentication failed (401)"),
|
||||
// confirmed live against the real backend. Ported from the old app's tinode-web.jsx (CTRL_ERROR_TEXT_CN
|
||||
// / translateCtrlError / loginErrorMessage), not guessed -- this exact table is what made login/register
|
||||
// error copy match the product spec instead of leaking SDK internals.
|
||||
const CTRL_ERROR_TEXT_CN: Record<string, string> = {
|
||||
'authentication failed': '账号或密码错误',
|
||||
'authentication required': '请先登录',
|
||||
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
|
||||
'policy violation': '当前操作不符合安全策略要求',
|
||||
'permission denied': '没有权限执行此操作,请重新登录',
|
||||
'internal error': '服务器繁忙,请稍后重试',
|
||||
};
|
||||
|
||||
function translateCtrlError(err: unknown): string | null {
|
||||
const raw = (err as Error)?.message;
|
||||
if (!raw) return null;
|
||||
const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase();
|
||||
return CTRL_ERROR_TEXT_CN[normalized] || null;
|
||||
}
|
||||
|
||||
export function loginErrorMessage(err: unknown): string {
|
||||
const known = translateCtrlError(err);
|
||||
if (known) return known;
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code === 401 || code === 403) return '账号或密码错误';
|
||||
return (err as Error)?.message || '登录失败,请重试';
|
||||
}
|
||||
|
||||
export function registerErrorMessage(err: unknown): string {
|
||||
const known = translateCtrlError(err);
|
||||
return known || (err as Error)?.message || '注册失败,请稍后重试';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only INVITE_CODE_REQUIRED/INVALID were confirmed against a live server; REVOKED/EXPIRED are
|
||||
* inferred siblings under the same naming pattern (not individually exercised).
|
||||
*/
|
||||
const INVITE_CODE_REASON_TEXT_CN: Record<string, string> = {
|
||||
INVITE_CODE_REQUIRED: '请填写邀请码',
|
||||
INVITE_CODE_INVALID: '邀请码错误,请重新输入',
|
||||
INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码',
|
||||
INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码',
|
||||
};
|
||||
|
||||
export interface ValidateCredentialsParams {
|
||||
reason?: string;
|
||||
cred?: string[];
|
||||
token?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created, now
|
||||
* confirm a real credential (email/SMS code)" -- ctrl.params.cred is a non-empty array in that case.
|
||||
* This backend reuses the exact same code/text pair for its own invite-code rejections, which carry
|
||||
* `reason` instead of `cred`. This app's registration flow always pre-verifies the phone credential
|
||||
* over REST before calling createAccountBasic, so a genuine pending-credential case here would mean
|
||||
* an account created some other way (e.g. by an admin) -- there's no credential-confirmation screen
|
||||
* built yet for that (out of scope for now), so it gets an honest, terminal message rather than a
|
||||
* silent dead end.
|
||||
*/
|
||||
export function validateCredentialsMessage(params: ValidateCredentialsParams | undefined): string {
|
||||
if (Array.isArray(params?.cred) && params.cred.length) {
|
||||
return '账号存在未确认的验证信息,请联系企业管理员处理';
|
||||
}
|
||||
const reason = params?.reason;
|
||||
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
|
||||
}
|
||||
Reference in New Issue
Block a user