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(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 (
respond(null)}>
e.stopPropagation()}> 安全验证

即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码

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" />
{error ?

{error}

: null}
); }