群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示 - 好友申请首次必失败30000(P0):AddFriendModal把手机号搜索单独标记为 source:"phone"上报,但后端serveIMFriendRequestCreate的source校验只认 search/qr/group,命中default分支返回"invalid friend request source"。 改为手机号搜索也统一上报source:"search"(后端本就不区分具体搜索渠道), 用两个全新、此前无任何关系的测试账号验证首次申请200成功 - 解散群聊需要短信二次验证但完整流程从未实现(P0):新增 SecurityChallengeModal通用二次验证弹层(POST /security/challenges→ 验证码→verify→action_token)+ requestActionToken()(stores/ securityChallenge.ts,与requestConfirm同一模式),接入解散群聊和转让 群主(后者此前也因缺X-Action-Token必然404/400)。同时发现并修复 imDelete对无body请求不显式传空对象体,导致携带Content-Type: application/json但请求体真正为空时后端JSON解码失败返回400——实测确认 两个问题都解决后DELETE返回200 - 群聊被解散但当前正查看该群的用户界面不会跳转(P1):新增 topics.ts的subscribeTopicGone()监听SDK自身的onDeleteTopic回调, ConversationView据此在当前会话被解散时弹出提示并跳转回消息列表 - P2P会话发起方标题栏显示原始topic id、对方明明在线却显示离线:确认 为上一轮已修复的isCommType()根因问题的同一症状,本轮实测(新建从未 聊过天的账号对)已不再复现;群内消息发送者显示原始uid的关联描述同样 实测未复现 - 好友关系冲突(已是好友/申请正在处理中)报错英文原文透出:翻译表补充 FRIEND_REQUEST_CONFLICT - 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会 立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正 超时后才显示该链接,与其上方的提示文案保持一致的显示条件
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { useEscapeKey } from '@/lib/hooks';
|
|
import { ImApiError } from '@/lib/api/client';
|
|
import { requestSecurityChallenge, verifySecurityChallenge } from '@/lib/api/security';
|
|
import { useSecurityChallengeStore } from '@/stores/securityChallenge';
|
|
|
|
/** Mounted once at the app root (App.tsx), same placement/pattern as ConfirmDialogRoot -- renders
|
|
* whatever `requestActionToken()` (stores/securityChallenge.ts) most recently asked for: the
|
|
* protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766), needed before
|
|
* a group can actually be dissolved (or its ownership transferred) -- both require `X-Action-Token`,
|
|
* and there was previously no UI anywhere in this app that could produce one. */
|
|
export function SecurityChallengeModal() {
|
|
const pending = useSecurityChallengeStore((s) => s.pending);
|
|
const respond = useSecurityChallengeStore((s) => s.respond);
|
|
const [challengeId, setChallengeId] = useState('');
|
|
const [phoneMask, setPhoneMask] = useState('');
|
|
const [code, setCode] = useState('');
|
|
const [seconds, setSeconds] = useState(0);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const timerRef = useRef<number | undefined>(undefined);
|
|
useEscapeKey(() => respond(null));
|
|
|
|
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);
|
|
};
|
|
|
|
// Fires the first SMS the moment the modal opens -- the doc's own flow has no separate "get code"
|
|
// click for this endpoint (unlike VerificationCodeButton's register/reset-password scenes), since
|
|
// the phone number isn't something the user enters here at all (read server-side from the login
|
|
// identity), so there's nothing to wait on the user to fill in first.
|
|
useEffect(() => {
|
|
window.clearInterval(timerRef.current);
|
|
setChallengeId('');
|
|
setCode('');
|
|
setError('');
|
|
setSeconds(0);
|
|
if (!pending) return undefined;
|
|
let cancelled = false;
|
|
requestSecurityChallenge(pending.scene)
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
setChallengeId(data.challenge_id);
|
|
setPhoneMask(data.phone_mask);
|
|
tick(data.resend_after_seconds || 60);
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(timerRef.current);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pending?.scene]);
|
|
|
|
if (!pending) return null;
|
|
|
|
const resend = () => {
|
|
if (busy || seconds > 0) return;
|
|
setError('');
|
|
setBusy(true);
|
|
requestSecurityChallenge(pending.scene)
|
|
.then((data) => {
|
|
setChallengeId(data.challenge_id);
|
|
setPhoneMask(data.phone_mask);
|
|
tick(data.resend_after_seconds || 60);
|
|
})
|
|
.catch((err) => setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'))
|
|
.finally(() => setBusy(false));
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请输入 6 位验证码');
|
|
setBusy(true);
|
|
setError('');
|
|
try {
|
|
const verified = await verifySecurityChallenge(challengeId, code);
|
|
respond(verified.action_token);
|
|
} catch (err) {
|
|
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(null)}>
|
|
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
|
|
<strong className="mb-2 block text-base font-semibold text-text-heading">安全验证</strong>
|
|
<p className="m-0 text-sm leading-relaxed text-text-body">
|
|
即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
|
|
</p>
|
|
<div className="mt-4 flex items-center gap-2">
|
|
<input
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="请输入 6 位验证码"
|
|
inputMode="numeric"
|
|
autoFocus
|
|
className="h-9 min-w-0 flex-1 rounded-lg border border-border-panel px-3 text-sm outline-none focus:border-accent"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={resend}
|
|
disabled={busy || seconds > 0 || !challengeId}
|
|
className="h-9 w-28 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
|
>
|
|
{seconds > 0 ? `重新获取(${seconds}s)` : '重新获取'}
|
|
</button>
|
|
</div>
|
|
{error ? <p className="m-0 mt-2 text-xs text-danger-text">{error}</p> : null}
|
|
<div className="mt-5 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => respond(null)}
|
|
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={submit}
|
|
disabled={busy || !challengeId || code.length !== 6}
|
|
className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:opacity-50"
|
|
>
|
|
{busy ? '验证中…' : '确定'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|