Files
im_webapp/web/src/features/auth/RegisterPage.tsx
T
Your NameandClaude Sonnet 5 72b7b79caf fix(web): code review round 3 -- UI bugs and remaining design fidelity
- FileAttachment never surfaced a failed authenticated download -- the
  card looked identically clickable whether it worked or not, and
  clicking while broken silently no-op'd. Added an error state/retry
  (useAuthedBlobUrl gained a retry() escape hatch for this).
  ImageAttachment/AudioAttachment already handled this; only the file
  card didn't.
- --shadow-btn was one token serving two different specs: round 2's fix
  (0 6px 16px, from the 50px auth CTAs) actually regressed the 36px
  messaging send button (ConversationView/VoiceRecorder), which needs
  0 4px 12px per 02-messages.dc.html. Split into --shadow-btn /
  --shadow-btn-sm.
- loadOlder's scroll-restore guard compared topicId by value, which
  false-positives on an A->B->A round trip within one request's
  lifetime (same string, but the DOM was torn down and rebuilt by the
  intervening open/close). Switched to a generation counter, same
  pattern as topics.ts's leave-race fix.
- errorToast wasn't included in ConversationView/DetailColumn's
  topicId-reset effects -- a failed action's toast could keep showing
  over a conversation/contact switched to afterward. Also switched
  those effects (and MessagesLayout's) from useEffect to
  useLayoutEffect -- since none of these components remount on
  topicId change, a post-paint effect could let one frame render with
  the previous topicId's stale UI still showing.
- VoiceRecorder never listened for MediaRecorder's own onerror -- a
  mid-recording failure left the "录音中" timer ticking forever with no
  indication the recording had already died.
- VerificationCodeButton's phone-change countdown reset (round 2) only
  cleared the button's own state; RegisterPage/ResetPasswordPage's
  challengeId was still tied to whatever phone the code was actually
  issued for, with nothing cross-checking it against a since-edited
  phone number.
- Agreement's checkbox used bare accent-color on a native input, which
  only tints the checked fill and leaves size/radius/border to the
  browser/OS; replaced with the spec's exact 16x16 border-radius:4px
  box + check glyph.
- Added a catch-all route (App.tsx), TenantFeedbackPage image-picker
  validation feedback + corrected remove-button style, and a handful of
  smaller token/spacing corrections against screens/*.dc.html (NavRail
  font-weight, LoginPage/TenantEntryPage logo size+shadow+color,
  ChatListColumn row padding/icon size, --shadow-modal alpha).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:27:41 +08:00

223 lines
9.5 KiB
TypeScript

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<FormErrors>({});
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 <Navigate to="/tenant" replace />;
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 (
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</Link>
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">注册</strong>
</div>
<div className="flex flex-1 items-center justify-center px-6 py-8">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<h1 className="m-0 text-[22px] font-bold text-text-title">创建账号</h1>
{formError ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{formError}
</div>
) : null}
<div className="flex flex-col gap-1.5">
<input
value={username}
onChange={(e) => 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 ? <div className="text-xs text-danger-border">{errors.username}</div> : null}
</div>
<div className="flex items-center gap-3 text-xs text-text-tertiary-3">
<div className="h-px flex-1 bg-border-row" />
或
<div className="h-px flex-1 bg-border-row" />
</div>
<div className="flex flex-col gap-1.5">
<label
className={`flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
errors.phone ? 'border border-danger-text' : 'has-focus:border-focus-border'
}`}
>
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
placeholder="请输入手机号"
inputMode="numeric"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
{errors.phone ? <div className="text-xs text-danger-border">{errors.phone}</div> : null}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<input
value={code}
onChange={(e) => 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' : ''
}`}
/>
<VerificationCodeButton
phone={phone}
scene="register"
onChallenge={setChallengeId}
onError={(msg) => setErrors((e) => ({ ...e, code: msg }))}
/>
</div>
{errors.code ? <div className="text-xs text-danger-border">{errors.code}</div> : null}
</div>
<PasswordInput value={password} onChange={setPassword} placeholder="设置密码" autoComplete="new-password" error={errors.password} />
<div className="flex flex-col gap-1.5">
{/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */}
<input
value={invite}
onChange={(e) => 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 ? <div className="text-xs text-danger-border">{errors.invite}</div> : null}
</div>
{!usingPhone ? (
<p className="m-0 text-xs text-text-tertiary-3">未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置</p>
) : null}
<button
type="submit"
disabled={(!username && !phone) || !password || !agreed || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '注册中…' : '注册'}
</button>
<Link to="/login" className="text-center text-sm text-accent no-underline">
已有账号?返回登录
</Link>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同MeetingIM" />
</form>
</div>
</div>
);
}