- 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>
223 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|