import { useEffect, useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; import { Icon } from '@/app/Icon'; 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 { getTinode, registerAccount } from '@/lib/tinode/connection'; import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; import { subscribeMeTopic } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; 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() { 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 [password, setPassword] = useState(''); const [invite, setInvite] = useState(''); const [challengeId, setChallengeId] = useState(''); const [agreed, setAgreed] = useState(false); const [errors, setErrors] = useState({}); const [formError, setFormError] = useState(''); const [submitting, setSubmitting] = useState(false); // A challengeId/code pair is only valid for the phone number it was issued for -- without this, // editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that // verifyRegisterCode() has no way to cross-check against the NEW phone in the field. useEffect(() => { setChallengeId(''); setCode(''); }, [phone]); if (!tenant) return ; const usingPhone = !!phone; const submit = async (e: React.FormEvent) => { e.preventDefault(); 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); let verificationToken: string | undefined; try { 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, inviteCode: invite, verificationToken, challengeId: usingPhone ? challengeId : undefined, }); if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { setFormError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)); return; } const authToken = getTinode().getAuthToken(); sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); setMyUserId((ctrl.params?.user as string | undefined) ?? null); subscribeMeTopic().catch(() => {}); navigate('/messages'); } catch (err) { setFormError(registerErrorMessage(err)); } finally { setSubmitting(false); } }; return (
注册

创建账号

{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-text' : '' }`} /> {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-text' : '' }`} /> 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-text' : '' }`} /> {errors.invite ?
{errors.invite}
: null}
{!usingPhone ? (

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

) : null} 已有账号?返回登录
); }