认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
min-[900px]:block hidden
- 企业码输入框补maxLength=64
图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立
顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑
通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底
消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
token),改成语义正确的unread token;录音条文字被连带染红,只保留
指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
不是text-tertiary-3)都和设计稿不符,已修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
import { useEffect, 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: '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<number | undefined>(undefined);
|
|
|
|
// 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
|
|
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
|
|
useEffect(() => () => window.clearInterval(timerRef.current), []);
|
|
|
|
// A countdown started for one phone number must not block sending a code to a DIFFERENT one --
|
|
// 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(() => {
|
|
window.clearInterval(timerRef.current);
|
|
setSeconds(0);
|
|
}, [phone]);
|
|
|
|
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;
|
|
// The phone number this request is actually FOR -- captured now, not read again after the
|
|
// `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in
|
|
// flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever
|
|
// number is now showing, with nothing on screen indicating the mismatch -- the eventual
|
|
// verification would silently operate on an account the user never intended.
|
|
const requestedPhone = phone;
|
|
setBusy(true);
|
|
try {
|
|
const data = await requestVerificationCode(scene, requestedPhone);
|
|
if (requestedPhone !== phone) return;
|
|
onChallenge(data.challenge_id);
|
|
tick(data.resend_after_seconds || 60);
|
|
} catch (err) {
|
|
if (requestedPhone !== phone) 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<string, unknown> | 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 (
|
|
<button
|
|
type="button"
|
|
onClick={click}
|
|
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
|
|
className="h-9 w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
|
>
|
|
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
|
|
</button>
|
|
);
|
|
}
|