Files
im_webapp/web/src/app/SecurityChallengeModal.tsx
T
Your Name 8013c1675f 修复禅道Bug清单中的前端问题:好友申请source校验、群解散二次验证缺失、
群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示

- 好友申请首次必失败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
- 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会
  立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正
  超时后才显示该链接,与其上方的提示文案保持一致的显示条件
2026-09-16 15:42:41 +08:00

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>
);
}