diff --git a/web/src/App.tsx b/web/src/App.tsx index b3e61394..410899a7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { RequireAuth } from '@/app/RequireAuth'; import { SectionPlaceholder } from '@/app/SectionPlaceholder'; import { LoginPage } from '@/features/auth/LoginPage'; import { RegisterPage } from '@/features/auth/RegisterPage'; +import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'; import { TenantEntryPage } from '@/features/auth/TenantEntryPage'; import { ConversationView } from '@/features/messages/ConversationView'; import { EmptyMain } from '@/features/messages/EmptyMain'; @@ -17,6 +18,7 @@ const router = createBrowserRouter([ { path: 'tenant', element: }, { path: 'login', element: }, { path: 'register', element: }, + { path: 'reset-password', element: }, { element: , children: [ diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index 134cd3f8..36fe2bb6 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -1,42 +1,39 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; -import { connect } from '@/lib/tinode/connection'; -import { useTinode } from '@/lib/tinode/hooks'; +import { Agreement } from '@/features/auth/components/Agreement'; +import { PasswordInput } from '@/features/auth/components/PasswordInput'; +import { BASE_APP_NAME } from '@/lib/config'; +import { connect, getTinode } from '@/lib/tinode/connection'; +import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; import { useSessionStore } from '@/stores/session'; -/** - * ctrl.code/text -> Chinese message. Ported from the old app's loginErrorMessage() -- the SDK's - * own login promise rejects with an Error whose `.code` is the ctrl code (401/404/500/...), not - * with an ImApiError, since this is the native WS protocol, not the /v1/im REST layer. - */ -function loginErrorMessage(err: unknown): string { - const code = (err as { code?: number })?.code; - if (code === 401) return '账号或密码错误'; - if (code === 404) return '账号不存在'; - if (code && code >= 500) return '服务器繁忙,请稍后重试'; - return (err as Error)?.message || '登录失败,请重试'; -} - export function LoginPage() { const navigate = useNavigate(); const tenant = useSessionStore((s) => s.tenant); const setMyUserId = useSessionStore((s) => s.setMyUserId); - const tinode = useTinode(); + const tinode = getTinode(); const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); + const [agreed, setAgreed] = useState(false); const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); + // Real login failures (wrong password, disabled account, ...) clear the password and keep the + // account, matching the product spec -- ported from the old app's own effect for this. + useEffect(() => { + if (error) setPassword(''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error]); + if (!tenant) { - // No tenant resolved yet this session -- can't build a valid {hi} packet without it. return ; } const submit = async (e: React.FormEvent) => { e.preventDefault(); - if (!login.trim() || !password || submitting) return; + if (!login.trim() || !password || !agreed || submitting) return; setSubmitting(true); setError(''); try { @@ -45,9 +42,7 @@ export function LoginPage() { } const ctrl = await tinode.loginBasic(login.trim(), password); if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { - // Account created but needs credential confirmation (email/SMS code) -- full flow lands - // with the register/credential-confirm screens later in Phase 1. - setError('账号需要先完成验证,此流程正在实现中'); + setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)); return; } const authToken = tinode.getAuthToken(); @@ -62,37 +57,61 @@ export function LoginPage() { }; return ( -
-
- {tenant.name} -

登录

- {error ? ( -
{error}
- ) : null} - setLogin(e.target.value)} - placeholder="用户名或手机号" - className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring" - /> - setPassword(e.target.value)} - placeholder="密码" - className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring" - /> - - - 没有账户?马上注册 - -
+
+
+
+
+
M
+ {BASE_APP_NAME} +
+

+ 企业内部沟通,安全、即时、多端同步。 +

+

+ 单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。 +

+
+ +
+
+ + arrow_back_ios_new + 切换企业 + +
+ {tenant.name} +

登录

+
+ {error ? ( +
{error}
+ ) : null} + setLogin(e.target.value)} + placeholder="请输入用户名/手机号" + autoComplete="username" + className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring" + /> + + + 忘记密码 + + +

+ 没有账户?{' '} + + 马上注册 + +

+ + +
); } diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx index 490bcd57..f74fe835 100644 --- a/web/src/features/auth/RegisterPage.tsx +++ b/web/src/features/auth/RegisterPage.tsx @@ -1,14 +1,26 @@ -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; -import { requestVerificationCode, verifyRegisterCode } from '@/lib/api/auth'; +import { Agreement } from '@/features/auth/components/Agreement'; +import { PasswordInput } from '@/features/auth/components/PasswordInput'; +import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton'; +import { verifyRegisterCode } from '@/lib/api/auth'; import { ImApiError } from '@/lib/api/client'; -import { registerAccountByPhone } from '@/lib/tinode/connection'; +import { getTinode, registerAccount } from '@/lib/tinode/connection'; +import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; import { useSessionStore } from '@/stores/session'; -function toE164(phone: string): string { - return phone.startsWith('+') ? phone : `+86${phone}`; +const USERNAME_RE = /^[a-z0-9]{1,10}$/; +const PHONE_RE = /^\d{11}$/; +const INVITE_RE = /^[A-Za-z]{5}$/; + +interface FormErrors { + username?: string; + phone?: string; + code?: string; + password?: string; + invite?: string; } export function RegisterPage() { @@ -16,114 +28,170 @@ export function RegisterPage() { const tenant = useSessionStore((s) => s.tenant); const setMyUserId = useSessionStore((s) => s.setMyUserId); + const [username, setUsername] = useState(''); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [password, setPassword] = useState(''); + const [invite, setInvite] = useState(''); const [challengeId, setChallengeId] = useState(''); - const [resendIn, setResendIn] = useState(0); - const [sendingCode, setSendingCode] = useState(false); + const [agreed, setAgreed] = useState(false); + const [errors, setErrors] = useState({}); + const [formError, setFormError] = useState(''); const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(''); - const timerRef = useRef(undefined); if (!tenant) return ; - const phoneValid = /^1\d{10}$/.test(phone); - - const sendCode = async () => { - if (!phoneValid || sendingCode || resendIn > 0) return; - setSendingCode(true); - setError(''); - try { - const res = await requestVerificationCode('register', phone); - setChallengeId(res.challenge_id); - setResendIn(res.resend_after_seconds); - window.clearInterval(timerRef.current); - timerRef.current = window.setInterval(() => { - setResendIn((s) => { - if (s <= 1) { - window.clearInterval(timerRef.current); - return 0; - } - return s - 1; - }); - }, 1000); - } catch (err) { - setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'); - } finally { - setSendingCode(false); - } - }; + const usingPhone = !!phone; const submit = async (e: React.FormEvent) => { e.preventDefault(); - if (!phoneValid || !code || password.length < 8 || !challengeId || submitting) return; + const next: FormErrors = {}; + if (!username && !phone) next.username = '用户名和手机号至少填写一项'; + if (username && !USERNAME_RE.test(username)) next.username = '用户名仅支持 1-10 位小写字母或数字'; + if (usingPhone) { + if (!PHONE_RE.test(phone)) next.phone = '请输入 11 位手机号'; + if (!challengeId) next.code = '请先获取验证码'; + else if (!/^\d{6}$/.test(code)) next.code = '请输入 6 位验证码'; + } else if (!INVITE_RE.test(invite)) { + next.invite = '用户名注册需填写 5 位字母邀请码'; + } + if (password.length < 8) next.password = '密码至少 8 位'; + setErrors(next); + setFormError(''); + if (Object.keys(next).length || !agreed || submitting) return; + setSubmitting(true); - setError(''); + let verificationToken: string | undefined; try { - const { verification_token } = await verifyRegisterCode(challengeId, code); - const phoneE164 = toE164(phone); - const ctrl = await registerAccountByPhone({ - phoneE164, + if (usingPhone) { + const verified = await verifyRegisterCode(challengeId, code); + verificationToken = verified.verification_token; + } + } catch (err) { + setErrors((e) => ({ ...e, code: err instanceof ImApiError ? err.message : '验证码错误或已过期' })); + setSubmitting(false); + return; + } + try { + const ctrl = await registerAccount({ + username, + phone, password, - verificationToken: verification_token, - challengeId, + inviteCode: invite, + verificationToken, + challengeId: usingPhone ? challengeId : undefined, }); if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { - setError('账号已创建,但还需要完成验证,此流程正在实现中'); + setFormError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)); return; } - setMyUserId(ctrl.params?.user as string | null); + const authToken = getTinode().getAuthToken(); + sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); + setMyUserId((ctrl.params?.user as string | undefined) ?? null); navigate('/messages'); } catch (err) { - setError(err instanceof ImApiError ? err.message : (err as Error)?.message || '注册失败,请稍后重试'); + setFormError(registerErrorMessage(err)); } finally { setSubmitting(false); } }; return ( -
-
-

创建账号

- {error ? ( -
{error}
- ) : null} - -
- setCode(e.target.value)} - placeholder="验证码" - className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" - /> - +
+ +
+
+ M +
+ {tenant.name}
- setPassword(e.target.value)} - placeholder="密码(至少 8 位)" - className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" - /> +

创建账号

+ {formError ? ( +
{formError}
+ ) : null} + +
+ setUsername(e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10))} + placeholder="用户名(1-10位小写字母或数字)" + autoComplete="username" + className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring ${ + errors.username ? 'border border-danger-border' : '' + }`} + /> + {errors.username ?
{errors.username}
: null} +
+ +
+
+ 或 +
+
+ +
+ + {errors.phone ?
{errors.phone}
: null} +
+ +
+
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="短信验证码" + inputMode="numeric" + className={`h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${ + errors.code ? 'border border-danger-border' : '' + }`} + /> + setErrors((e) => ({ ...e, code: msg }))} + /> +
+ {errors.code ?
{errors.code}
: null} +
+ + + +
+ {/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */} + setInvite(e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5))} + placeholder="邀请码(5位英文字母,区分大小写)" + autoCapitalize="off" + autoCorrect="off" + className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${ + errors.invite ? 'border border-danger-border' : '' + }`} + /> + {errors.invite ?
{errors.invite}
: null} +
+ + {!usingPhone ? ( +

未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置

+ ) : null} +
); diff --git a/web/src/features/auth/ResetPasswordPage.tsx b/web/src/features/auth/ResetPasswordPage.tsx new file mode 100644 index 00000000..d232a04c --- /dev/null +++ b/web/src/features/auth/ResetPasswordPage.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; + +import { Link, Navigate, useNavigate } from 'react-router-dom'; + +import { PasswordInput } from '@/features/auth/components/PasswordInput'; +import { Toast } from '@/features/auth/components/Toast'; +import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton'; +import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth'; +import { ImApiError } from '@/lib/api/client'; +import { useSessionStore } from '@/stores/session'; + +const PHONE_RE = /^\d{11}$/; + +export function ResetPasswordPage() { + const navigate = useNavigate(); + const tenant = useSessionStore((s) => s.tenant); + + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [challengeId, setChallengeId] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [toast, setToast] = useState(''); + + if (!tenant) return ; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号'); + if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码'); + // Matches Register's rule (>= 8, no upper bound -- the backend's checkPasswordPolicy only + // enforces a minimum length). + if (password.length < 8) return setError('密码至少 8 位,需包含字母和数字'); + if (password !== confirm) return setError('两次输入的密码不一致'); + + setSubmitting(true); + setError(''); + try { + const verified = await verifyPasswordResetCode(challengeId, code); + await confirmPasswordReset(verified.reset_token, password); + setToast('密码修改成功,请重新登录'); + window.setTimeout(() => navigate('/login'), 1200); + } catch (err) { + setError(err instanceof ImApiError ? err.message : '验证码错误或已过期'); + setSubmitting(false); + } + }; + + return ( +
+
+ + arrow_back_ios_new + 返回登录 + +
+

重置密码

+

+ 通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。 +

+
+ {error ? ( +
{error}
+ ) : null} + + + +
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="短信验证码" + inputMode="numeric" + className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" + /> + +
+ + + + + + + setToast('')} /> +
+ ); +} diff --git a/web/src/features/auth/TenantEntryPage.tsx b/web/src/features/auth/TenantEntryPage.tsx index 22e85dae..dbcad91c 100644 --- a/web/src/features/auth/TenantEntryPage.tsx +++ b/web/src/features/auth/TenantEntryPage.tsx @@ -31,8 +31,8 @@ export function TenantEntryPage() { }; return ( -
-
+
+
M diff --git a/web/src/features/auth/components/Agreement.tsx b/web/src/features/auth/components/Agreement.tsx new file mode 100644 index 00000000..680de3e9 --- /dev/null +++ b/web/src/features/auth/components/Agreement.tsx @@ -0,0 +1,30 @@ +import { BASE_APP_NAME } from '@/lib/config'; + +interface AgreementProps { + checked: boolean; + onChange: (checked: boolean) => void; + prefix?: string; +} + +export function Agreement({ checked, onChange, prefix = `同意${BASE_APP_NAME}` }: AgreementProps) { + return ( + + ); +} diff --git a/web/src/features/auth/components/PasswordInput.tsx b/web/src/features/auth/components/PasswordInput.tsx new file mode 100644 index 00000000..3818e684 --- /dev/null +++ b/web/src/features/auth/components/PasswordInput.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; + +import { Icon } from '@/app/Icon'; + +interface PasswordInputProps { + value: string; + onChange: (value: string) => void; + placeholder: string; + autoComplete?: string; + error?: string; +} + +export function PasswordInput({ value, onChange, placeholder, autoComplete, error }: PasswordInputProps) { + const [visible, setVisible] = useState(false); + + return ( +
+
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete={autoComplete} + className="w-full bg-transparent text-[15px] outline-none" + /> + +
+ {error ?
{error}
: null} +
+ ); +} diff --git a/web/src/features/auth/components/Toast.tsx b/web/src/features/auth/components/Toast.tsx new file mode 100644 index 00000000..c98663c9 --- /dev/null +++ b/web/src/features/auth/components/Toast.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; + +import { Icon } from '@/app/Icon'; +import { TOAST_DURATION } from '@/lib/config'; + +interface ToastProps { + message: string; + onDone: () => void; +} + +export function Toast({ message, onDone }: ToastProps) { + useEffect(() => { + if (!message) return undefined; + const timer = window.setTimeout(onDone, TOAST_DURATION); + return () => window.clearTimeout(timer); + }, [message, onDone]); + + if (!message) return null; + + return ( +
+ + {message} +
+ ); +} diff --git a/web/src/features/auth/components/VerificationCodeButton.tsx b/web/src/features/auth/components/VerificationCodeButton.tsx new file mode 100644 index 00000000..248c3585 --- /dev/null +++ b/web/src/features/auth/components/VerificationCodeButton.tsx @@ -0,0 +1,63 @@ +import { useRef, useState } from 'react'; + +import { requestVerificationCode } from '@/lib/api/auth'; +import { ImApiError } from '@/lib/api/client'; + +const PHONE_RE = /^\d{11}$/; + +interface VerificationCodeButtonProps { + phone: string; + scene: 'register' | 'reset_password'; + onChallenge: (challengeId: string) => void; + onError: (message: string) => void; +} + +/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */ +export function VerificationCodeButton({ phone, scene, onChallenge, onError }: VerificationCodeButtonProps) { + const [seconds, setSeconds] = useState(0); + const [busy, setBusy] = useState(false); + const timerRef = useRef(undefined); + + const tick = (initial: number) => { + window.clearInterval(timerRef.current); + setSeconds(initial); + timerRef.current = window.setInterval(() => { + setSeconds((s) => { + if (s <= 1) { + window.clearInterval(timerRef.current); + return 0; + } + return s - 1; + }); + }, 1000); + }; + + const click = async () => { + if (busy || seconds > 0 || !PHONE_RE.test(phone)) return; + setBusy(true); + try { + const data = await requestVerificationCode(scene, phone); + onChallenge(data.challenge_id); + tick(data.resend_after_seconds || 60); + } catch (err) { + onError(err instanceof ImApiError ? err.message : '获取验证码失败'); + // Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it. + const details = err instanceof ImApiError ? (err.details as Record | undefined) : undefined; + if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds); + if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts index 212e31e7..6486b239 100644 --- a/web/src/lib/api/auth.ts +++ b/web/src/lib/api/auth.ts @@ -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( + `/auth/password-reset/challenges/${encodeURIComponent(challengeId)}/verify`, + { method: 'POST', auth: false, body: { code } }, + ); +} + +export function confirmPasswordReset(resetToken: string, newPassword: string) { + return imPost( + '/auth/password-reset/confirm', + { reset_token: resetToken, new_password: newPassword }, + { auth: false }, + ); +} diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts index 27b3d3a2..c8be7f44 100644 --- a/web/src/lib/tinode/connection.ts +++ b/web/src/lib/tinode/connection.ts @@ -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 { +}: RegisterParams): Promise { 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, }); diff --git a/web/src/lib/tinode/errors.ts b/web/src/lib/tinode/errors.ts new file mode 100644 index 00000000..a04377ba --- /dev/null +++ b/web/src/lib/tinode/errors.ts @@ -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 = { + '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 = { + 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]) || '注册信息校验未通过,请检查后重试'; +}