diff --git a/web/src/App.tsx b/web/src/App.tsx index 8447cc85..a13108c3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { RequireAuth } from '@/app/RequireAuth'; import { SecurityChallengeModal } from '@/app/SecurityChallengeModal'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; 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 { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage'; @@ -62,6 +63,7 @@ function RouteErrorBoundary() { const router = createBrowserRouter([ { path: 'tenant', element: }, { path: 'login', element: }, + { path: 'register', element: }, { path: 'reset-password', element: }, { path: 'enterprise-register', element: }, { path: 'feedback', element: }, diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index 239893fd..034da3c6 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -112,6 +112,9 @@ export function LoginPage() { > {submitting ? '登录中…' : '登录'} +

+ 还没有账号?注册账号 +

diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx new file mode 100644 index 00000000..9f276785 --- /dev/null +++ b/web/src/features/auth/RegisterPage.tsx @@ -0,0 +1,157 @@ +import { useRef, useState } from 'react'; + +import { Link, Navigate, useNavigate } from 'react-router-dom'; + +import { Icon } from '@/app/Icon'; +import { PasswordInput } from '@/features/auth/components/PasswordInput'; +import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton'; +import { verifyRegistrationCode } from '@/lib/api/auth'; +import { apiBase, ImApiError } from '@/lib/api/client'; +import { getTinode, registerBasic } from '@/lib/tinode/connection'; +import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; +import { useSessionStore } from '@/stores/session'; + +const USERNAME_RE = /^[a-z0-9]{3,10}$/; +const PHONE_RE = /^\d{11}$/; +const CODE_RE = /^(?:\d{6}|[A-Za-z]{5})$/; + +function normalizePhone(raw: string): string { + const digits = raw.replace(/\D/g, ''); + return (digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits).slice(0, 11); +} + +export function RegisterPage() { + const navigate = useNavigate(); + 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 [challengeId, setChallengeId] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [agreed, setAgreed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const verifiedRef = useRef<{ key: string; response: string } | null>(null); + const submitRef = useRef(false); + + if (!tenant) return ; + const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8; + const legalOrigin = new URL(apiBase(), window.location.href).origin; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + if (submitRef.current) return; + setError(''); + if (!username && !phone) return setError('请填写用户名或手机号'); + if (username && !USERNAME_RE.test(username)) return setError('用户名须为 3~10 位小写字母或数字'); + if (phone && !PHONE_RE.test(phone)) return setError('请输入 11 位手机号'); + if (phone && (!challengeId || !CODE_RE.test(code))) return setError('请先获取验证码并填写 6 位短信验证码或 5 位邀请码'); + if (password.length < minPasswordLength || (tenant.passwordPolicy?.maxLength && password.length > tenant.passwordPolicy.maxLength)) { + return setError(`密码长度须为 ${minPasswordLength}~${tenant.passwordPolicy?.maxLength ?? 64} 位`); + } + if (password !== confirm) return setError('两次输入的密码不一致'); + if (!agreed) return setError('请先阅读并同意用户条款与隐私政策'); + + submitRef.current = true; + setSubmitting(true); + try { + let credential: { meth: string; val: string; resp: string; params: { challenge_id: string } } | undefined; + if (phone) { + const key = `${phone}:${challengeId}:${code}`; + let response = verifiedRef.current?.key === key ? verifiedRef.current.response : ''; + if (!response) { + const verified = await verifyRegistrationCode(challengeId, code); + response = verified.verification_mode === 'invite_code' ? code : verified.verification_token || ''; + if (!response) throw new Error('验证结果无效,请重新获取验证码'); + verifiedRef.current = { key, response }; + } + credential = { meth: 'tel', val: phone, resp: response, params: { challenge_id: challengeId } }; + } + const ctrl = await registerBasic({ username, phone, password, tenantCode: tenant.code, credential }); + if (ctrl.code >= 300) { + setError(ctrl.text === 'validate credentials' + ? validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined) + : '注册失败,请检查资料后重试'); + return; + } + const tinode = getTinode(); + const token = tinode.getAuthToken()?.token; + const userId = tinode.getCurrentUserID(); + if (!token || !userId) throw new Error('账号已创建,请返回登录页登录'); + sessionStorage.setItem('im-session-token', token); + setMyUserId(userId); + navigate('/messages', { replace: true }); + } catch (err) { + setError(err instanceof ImApiError ? err.message : loginErrorMessage(err)); + } finally { + submitRef.current = false; + setSubmitting(false); + } + }; + + return ( +
+
+ + 返回登录 + + {tenant.name} +
+
+
+

注册账号

+ {error &&
{error}
} + + + {phone &&
+ +
+ { setChallengeId(id); setCode(''); verifiedRef.current = null; }} onError={setError} /> +
+
} + {!phone && username &&

未绑定手机号时无法自助找回密码,请联系企业管理员重置。

} + + + + + + + +
+
+ ); +} diff --git a/web/src/features/auth/components/VerificationCodeButton.tsx b/web/src/features/auth/components/VerificationCodeButton.tsx index 02f99dcd..ece658cf 100644 --- a/web/src/features/auth/components/VerificationCodeButton.tsx +++ b/web/src/features/auth/components/VerificationCodeButton.tsx @@ -7,16 +7,18 @@ const PHONE_RE = /^\d{11}$/; interface VerificationCodeButtonProps { phone: string; - scene: 'reset_password'; + scene: 'reset_password' | 'register'; + tenantCode?: string; 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) { +export function VerificationCodeButton({ phone, scene, tenantCode, onChallenge, onError }: VerificationCodeButtonProps) { const [seconds, setSeconds] = useState(0); const [busy, setBusy] = useState(false); const timerRef = useRef(undefined); + const phoneRef = useRef(phone); // Without this, navigating away mid-countdown (e.g. switching from register back to login) // leaves the interval running against an unmounted component indefinitely until it naturally @@ -27,6 +29,7 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V // otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still // counting down for the old number) until the old timer happens to run out on its own. useEffect(() => { + phoneRef.current = phone; window.clearInterval(timerRef.current); setSeconds(0); }, [phone]); @@ -55,12 +58,12 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V const requestedPhone = phone; setBusy(true); try { - const data = await requestVerificationCode(scene, requestedPhone); - if (requestedPhone !== phone) return; + const data = await requestVerificationCode(scene, requestedPhone, tenantCode); + if (requestedPhone !== phoneRef.current) return; onChallenge(data.challenge_id); tick(data.resend_after_seconds || 60); } catch (err) { - if (requestedPhone !== phone) return; + if (requestedPhone !== phoneRef.current) return; 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; diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts index 42819778..97c24218 100644 --- a/web/src/lib/api/auth.ts +++ b/web/src/lib/api/auth.ts @@ -16,8 +16,25 @@ export interface VerificationCodeResponse { resend_after_seconds: number; } -export function requestVerificationCode(scene: 'reset_password', phone: string) { - return imPost('/auth/verification-codes', { scene, phone }, { auth: false }); +export function requestVerificationCode(scene: 'reset_password' | 'register', phone: string, tenantCode?: string) { + return imPost( + '/auth/verification-codes', + scene === 'register' ? { scene, phone, registration_type: 0, tenant_code: tenantCode } : { scene, phone }, + { auth: false }, + ); +} + +export interface RegistrationVerifyResponse { + verification_token?: string; + verification_mode?: 'invite_code'; +} + +export function verifyRegistrationCode(challengeId: string, code: string) { + return imPost( + `/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`, + { code }, + { auth: false }, + ); } export interface PasswordResetVerifyResponse { diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts index 3891c242..f4450ed4 100644 --- a/web/src/lib/tinode/connection.ts +++ b/web/src/lib/tinode/connection.ts @@ -7,7 +7,7 @@ // silently stomp on each other. This module claims all the SDK's callback slots exactly once and // fans them out to any number of subscribers via its own Set, which is what hooks.ts's // useSyncExternalStore-based hooks actually subscribe to. -import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk'; +import { Tinode, type CredentialParam, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk'; import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config'; import { isTenantMismatch } from '@/lib/tinode/errors'; @@ -166,6 +166,7 @@ export function clearKickedInfo() { let sessionTenantCode: string | null = null; /** One-shot override used by loginBasic's FORBIDDEN self-heal retry (see there). */ let forcedLoginType: 0 | 1 | null = null; +let pendingRegistration: { username: string; phone: string; tenantCode: string } | null = null; function installOutgoingPacketPatches(t: Tinode) { const conn = t._connection; @@ -188,6 +189,19 @@ function installOutgoingPacketPatches(t: Tinode) { } catch { // Not valid JSON -- send as is. } + } else if (typeof msg === 'string' && pendingRegistration && msg.indexOf('"acc":') >= 0) { + try { + const parsed = JSON.parse(msg); + if (parsed.acc?.user === 'new') { + parsed.acc.username = pendingRegistration.username; + parsed.acc.phone = pendingRegistration.phone; + parsed.acc.registration_type = 0; + parsed.acc.tenant_code = pendingRegistration.tenantCode; + msg = JSON.stringify(parsed); + } + } catch { + // Not valid JSON -- send as is. + } } else if (typeof msg === 'string' && msg.indexOf('"login":') >= 0) { try { const parsed = JSON.parse(msg); @@ -403,3 +417,32 @@ export function loginBasic(login: string, password: string): Promise }); return loginPromise; } + +let registrationPromise: Promise | null = null; + +export function registerBasic(input: { + username: string; + phone: string; + password: string; + tenantCode: string; + credential?: CredentialParam; +}): Promise { + if (registrationPromise) return registrationPromise; + registrationPromise = (async () => { + await connect(); + const t = getTinode(); + const account = input.username || `+86${input.phone}`; + pendingRegistration = { username: input.username, phone: input.phone, tenantCode: input.tenantCode }; + try { + return await t.createAccountBasic(account, input.password, { + public: { fn: input.username || account }, + cred: input.credential ? [input.credential] : [], + }); + } finally { + pendingRegistration = null; + } + })().finally(() => { + registrationPromise = null; + }); + return registrationPromise; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 1827747e..cb3eb038 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ proxy: { '/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false }, '/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false }, + '/legal': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false }, }, }, })