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:
Your Name
2026-09-08 01:11:21 +08:00
co-authored by Claude Sonnet 5
parent 63c1b84b41
commit aa1c46ff22
12 changed files with 636 additions and 163 deletions
+25
View File
@@ -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 },
);
}
+43 -24
View File
@@ -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,
});
+70
View File
@@ -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]) || '注册信息校验未通过,请检查后重试';
}