From 8013c1675f7b9ad3b81c36a5875498257332811c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 15:42:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A6=85=E9=81=93Bug?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E4=B8=AD=E7=9A=84=E5=89=8D=E7=AB=AF=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=9A=E5=A5=BD=E5=8F=8B=E7=94=B3=E8=AF=B7source?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81=E7=BE=A4=E8=A7=A3=E6=95=A3=E4=BA=8C?= =?UTF-8?q?=E6=AC=A1=E9=AA=8C=E8=AF=81=E7=BC=BA=E5=A4=B1=E3=80=81=20?= =?UTF-8?q?=E7=BE=A4=E8=A7=A3=E6=95=A3=E4=BA=8B=E4=BB=B6=E6=9C=AA=E5=A4=84?= =?UTF-8?q?=E7=90=86=E3=80=81=E9=94=99=E8=AF=AF=E7=A0=81=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E3=80=81=E6=81=A2=E5=A4=8D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=E8=AF=AF=E5=AF=BC=E6=80=A7=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 好友申请首次必失败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 - 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会 立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正 超时后才显示该链接,与其上方的提示文案保持一致的显示条件 --- web/src/App.tsx | 2 + web/src/app/RequireAuth.tsx | 14 +- web/src/app/SecurityChallengeModal.tsx | 144 ++++++++++++++++++ web/src/features/contacts/AddFriendModal.tsx | 21 ++- web/src/features/contacts/NewFriendsView.tsx | 7 +- web/src/features/groups/GroupAdminModal.tsx | 11 +- web/src/features/groups/GroupDetailColumn.tsx | 10 +- .../features/messages/ConversationView.tsx | 18 ++- web/src/lib/api/client.ts | 3 + web/src/lib/api/groups.ts | 30 ++-- web/src/lib/api/security.ts | 24 +++ web/src/lib/tinode/tinode-sdk.d.ts | 3 + web/src/lib/tinode/topics.ts | 17 +++ web/src/stores/securityChallenge.ts | 39 +++++ 14 files changed, 313 insertions(+), 30 deletions(-) create mode 100644 web/src/app/SecurityChallengeModal.tsx create mode 100644 web/src/stores/securityChallenge.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index bd7c99e1..1b3151e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot'; import { GlobalToastRoot } from '@/app/GlobalToastRoot'; import { KickedNoticeRoot } from '@/app/KickedNoticeRoot'; import { RequireAuth } from '@/app/RequireAuth'; +import { SecurityChallengeModal } from '@/app/SecurityChallengeModal'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { LoginPage } from '@/features/auth/LoginPage'; import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'; @@ -106,6 +107,7 @@ export default function App() { + ); } diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx index 29da0a8a..2651de4a 100644 --- a/web/src/app/RequireAuth.tsx +++ b/web/src/app/RequireAuth.tsx @@ -106,9 +106,17 @@ export function RequireAuth() { return (
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'} - - 改为重新登录 - + {/* Only past the same timeout the message text itself waits for -- a normal restore usually + resolves in well under a second (real WS round trip), and this link showing unconditionally + from the very first frame made every ordinary reload/deep-link flash a "重新登录" escape + hatch that had nothing to escape from (a real, reported false-alarm: "短暂白屏并误显改为 + 重新登录"). The escape hatch is only actually useful once restoreTimedOut has already told + the user this is taking longer than normal. */} + {restoreTimedOut ? ( + + 改为重新登录 + + ) : null}
); } diff --git a/web/src/app/SecurityChallengeModal.tsx b/web/src/app/SecurityChallengeModal.tsx new file mode 100644 index 00000000..4835a661 --- /dev/null +++ b/web/src/app/SecurityChallengeModal.tsx @@ -0,0 +1,144 @@ +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} +
+ + +
+
+
+ ); +} diff --git a/web/src/features/contacts/AddFriendModal.tsx b/web/src/features/contacts/AddFriendModal.tsx index 38325b3a..45f20d31 100644 --- a/web/src/features/contacts/AddFriendModal.tsx +++ b/web/src/features/contacts/AddFriendModal.tsx @@ -47,7 +47,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa // Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real // provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a // dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable. - const [selectedSource, setSelectedSource] = useState<'search' | 'phone' | 'qr'>('search'); + const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search'); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [sent, setSent] = useState(false); @@ -227,12 +227,19 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa type="button" disabled={!selectable} onClick={() => { - // `searchType` (already computed above from the query itself) is the real - // signal for which channel actually found this person -- NewFriendsView's - // own SOURCE_LABEL table already has a `phone` entry ("通过手机号搜索") - // that this path never reached because every search result was tagged - // 'search' regardless of how it was found. - setSelectedSource(searchType === 'phone' ? 'phone' : 'search'); + // Real, live-confirmed backend validation (im_http.go's + // serveIMFriendRequestCreate): the `source` field's switch only recognizes + // `search`/`qr`/`group` -- there is no `phone` case, so it falls to + // `default` and 400s with "invalid friend request source". A prior pass + // here tried to report phone-originated searches as their own `phone` + // source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"), + // which broke every phone-search-based friend request outright -- reproduced + // live with two brand-new accounts before this fix (first-ever request, + // never a retry/idempotency issue as it first looked). Phone and account + // search both go through the same `search` channel from the backend's own + // point of view; there is no server-side data to recover that distinction + // from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this. + setSelectedSource('search'); setSelected(item); }} className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70" diff --git a/web/src/features/contacts/NewFriendsView.tsx b/web/src/features/contacts/NewFriendsView.tsx index c04256d1..77ca2ebf 100644 --- a/web/src/features/contacts/NewFriendsView.tsx +++ b/web/src/features/contacts/NewFriendsView.tsx @@ -11,7 +11,12 @@ import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type Frien import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { formatEventTimestamp } from '@/lib/messages/time'; -const SOURCE_LABEL: Record = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' }; +// `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's +// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the +// same `search` value an account-name search does, since the server has no separate channel for it +// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it +// tried to report `phone` as its own source instead). +const SOURCE_LABEL: Record = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' }; /** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝") * inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches diff --git a/web/src/features/groups/GroupAdminModal.tsx b/web/src/features/groups/GroupAdminModal.tsx index 77d4bff0..950f368c 100644 --- a/web/src/features/groups/GroupAdminModal.tsx +++ b/web/src/features/groups/GroupAdminModal.tsx @@ -24,6 +24,7 @@ import { import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; import { requestConfirm } from '@/stores/confirmDialog'; +import { requestActionToken } from '@/stores/securityChallenge'; import { useSessionStore } from '@/stores/session'; interface GroupAdminModalProps { @@ -469,7 +470,8 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str const queryClient = useQueryClient(); const [error, setError] = useState(''); const transferMutation = useMutation({ - mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1), + mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) => + transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['group', topicId] }); queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] }); @@ -490,9 +492,10 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str cacheKey="eligible-owner" opts={{ eligibleRole: 'owner' }} onPick={async (m) => { - if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) { - transferMutation.mutate(m.user_id); - } + if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return; + const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' }); + if (!actionToken) return; + transferMutation.mutate({ userId: m.user_id, actionToken }); }} emptyLabel="没有可转让的成员" disabled={transferMutation.isPending} diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx index 8aafb690..f2744bf1 100644 --- a/web/src/features/groups/GroupDetailColumn.tsx +++ b/web/src/features/groups/GroupDetailColumn.tsx @@ -28,6 +28,7 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useTinode } from '@/lib/tinode/hooks'; import { requestConfirm } from '@/stores/confirmDialog'; import { notify } from '@/stores/globalToast'; +import { requestActionToken } from '@/stores/securityChallenge'; import { useSessionStore } from '@/stores/session'; interface GroupDetailColumnProps { @@ -157,7 +158,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: }); const disbandMutation = useMutation({ - mutationFn: () => disbandGroup(topicId), + mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken), onSuccess: () => navigate('/messages'), onError: (err) => showError(err, '解散群聊失败'), }); @@ -178,7 +179,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: const startLeaveOrDisband = async () => { if (isOwner) { if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return; - disbandMutation.mutate(); + const actionToken = await requestActionToken({ + scene: 'group_dissolve', + actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`, + }); + if (!actionToken) return; + disbandMutation.mutate(actionToken); } else { if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return; leaveMutation.mutate(); diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index b87dfcba..f9348a28 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -29,10 +29,10 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; -import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics'; +import { closeConversation, ensureTopicAttached, openConversation, subscribeTopicGone } from '@/lib/tinode/topics'; import { useCallStore } from '@/stores/calls'; import { requestConfirm } from '@/stores/confirmDialog'; -import { notImplemented } from '@/stores/globalToast'; +import { notify, notImplemented } from '@/stores/globalToast'; import { useMessageJumpStore } from '@/stores/messageJump'; import { useSessionStore } from '@/stores/session'; @@ -165,6 +165,20 @@ export function ConversationView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [topicId]); + // Someone else dissolving the group currently open here (or this account being removed from it) + // fires the SDK's `onDeleteTopic`, not `onData`/`onPres` -- those only invalidate this screen's + // cached snapshots for re-render, they don't tell it the topic itself is gone. Left unhandled, the + // member who happened to be looking at that conversation just keeps staring at a now-defunct screen + // (an actual reported bug: staying on the dissolved group's view with no way back except a manual + // nav). `notify()` (the global toast, not this screen's own `errorToast`) survives the navigate. + useEffect(() => { + return subscribeTopicGone((goneTopicName) => { + if (goneTopicName !== topicId) return; + notify('该群聊已解散'); + navigate('/messages', { replace: true }); + }); + }, [topicId, navigate]); + // useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is // `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the // PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index d6609097..e9dd8e03 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -87,6 +87,9 @@ const REASON_MESSAGES: Record string | n QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描', QR_CODE_INVALID: '二维码无效,请重新扫描', FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友', + // `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already + // friends, or a request between the two of you (either direction) is already pending. + FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中', USER_NOT_DISCOVERABLE: '未找到该用户', REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态', SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试', diff --git a/web/src/lib/api/groups.ts b/web/src/lib/api/groups.ts index c1f875df..b260dec9 100644 --- a/web/src/lib/api/groups.ts +++ b/web/src/lib/api/groups.ts @@ -130,9 +130,17 @@ export function leaveGroup(topic: string) { return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {}); } -/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */ -export function disbandGroup(topic: string) { - return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`); +/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via + * `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live, + * fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by + * actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries + * this client's own `Content-Type: application/json` header, and the server's JSON decoder treats + * a truly empty body under that header as malformed rather than "no optional fields provided". */ +export function disbandGroup(topic: string, actionToken: string) { + return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, { + body: {}, + headers: { 'X-Action-Token': actionToken }, + }); } export type GroupRole = 'owner' | 'admin' | 'member'; @@ -264,14 +272,14 @@ export function unmuteGroupMember(topic: string, userId: string) { return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`); } -/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet - * wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until - * that's added. Kept typed so the group-management UI can be built ahead of that. */ -export function transferGroupOwner(topic: string, targetUserId: string, version: number) { - return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, { - target_user_id: targetUserId, - version, - }); +/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via + * `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */ +export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) { + return imPost<{ owner: string; previous_owner: string; version: number }>( + `/groups/${encodeURIComponent(topic)}/owner-transfer`, + { target_user_id: targetUserId, version }, + { headers: { 'X-Action-Token': actionToken } }, + ); } export function getGroupGovernance(topic: string) { diff --git a/web/src/lib/api/security.ts b/web/src/lib/api/security.ts index 63f7d412..f178a625 100644 --- a/web/src/lib/api/security.ts +++ b/web/src/lib/api/security.ts @@ -15,3 +15,27 @@ export interface UrlSafetyResult { export function checkUrlSafety(url: string, source: string) { return imPost('/security/urls/check', { url, source }); } + +/** Sensitive-action re-verification (docs/IM-前后端对接文档.md:762-766, §6.17's group-dissolve line). + * Confirmed by reading im_be's own `validIMSecurityChallengeScene` (server/im_http.go) -- the doc + * text only spells out the two youth-mode scenes by name, but the server also accepts + * `group_dissolve`/`group_owner_transfer`, which is what actually lets this flow work for either. */ +export type SecurityChallengeScene = 'group_dissolve' | 'group_owner_transfer'; + +export interface SecurityChallenge { + challenge_id: string; + phone_mask: string; + expires_in_seconds: number; + resend_after_seconds: number; +} + +export function requestSecurityChallenge(scene: SecurityChallengeScene) { + return imPost('/security/challenges', { scene, channel: 'sms' }); +} + +export function verifySecurityChallenge(challengeId: string, code: string) { + return imPost<{ action_token: string; action: string; expires_at: string }>( + `/security/challenges/${encodeURIComponent(challengeId)}/verify`, + { code }, + ); +} diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index 3a37942b..172c5736 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -257,6 +257,9 @@ declare module 'tinode-sdk' { onSubsUpdated?: (subs: string[], count?: number) => void; onPres?: (pres: unknown) => void; onAuxUpdated?: (aux: unknown) => void; + /** Fired by the SDK's own `Topic#_gone()` once this topic is confirmed deleted (group dissolved, + * or this account removed from it) -- clears `public`/`private`/messages just before firing. */ + onDeleteTopic?: () => void; } export class MeTopic extends Topic { diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 4cf37d03..85bbf863 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -64,6 +64,19 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI return () => infoListeners.delete(callback); } +const goneListeners = new Set<(topicName: string) => void>(); + +/** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) -- + * the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls + * `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this, + * a member who stays on a group's conversation screen while someone else dissolves it just keeps + * looking at a now-defunct view -- `onMetaDesc`/`onPres`/etc. above only invalidate cached snapshots + * for re-render, they don't tell the currently-open screen it needs to navigate away entirely. */ +export function subscribeTopicGone(callback: (topicName: string) => void): () => void { + goneListeners.add(callback); + return () => goneListeners.delete(callback); +} + function wireTopicCallbacks(topic: Topic) { if (wiredTopics.has(topic)) return; const notify = () => notifyTopic(topic.name); @@ -86,6 +99,10 @@ function wireTopicCallbacks(topic: Topic) { topic.onPres = notify; topic.onAuxUpdated = notify; topic.onAllMessagesReceived = notify; + topic.onDeleteTopic = () => { + notify(); + goneListeners.forEach((cb) => cb(topic.name)); + }; wiredTopics.add(topic); } diff --git a/web/src/stores/securityChallenge.ts b/web/src/stores/securityChallenge.ts new file mode 100644 index 00000000..2e39f77e --- /dev/null +++ b/web/src/stores/securityChallenge.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; + +import type { SecurityChallengeScene } from '@/lib/api/security'; + +/** + * Prompts for the protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766) + * and resolves with the resulting `action_token`, or `null` if the user cancels -- same Promise + * shape/global-store-plus-single-root-component pattern as `confirmDialog.ts`'s `requestConfirm`, for + * the same reason: this needs to be callable from a mutation's `mutationFn` (GroupDetailColumn's + * dissolve flow, currently the only caller) with no natural "own a piece of JSX for this" home. + */ +export interface SecurityChallengeOptions { + scene: SecurityChallengeScene; + /** What's about to happen if the user completes verification -- e.g. "解散群聊「群名」". */ + actionDescription: string; +} + +interface PendingChallenge extends SecurityChallengeOptions { + resolve: (token: string | null) => void; +} + +interface SecurityChallengeState { + pending: PendingChallenge | null; + respond: (token: string | null) => void; +} + +export const useSecurityChallengeStore = create((set, get) => ({ + pending: null, + respond: (token) => { + get().pending?.resolve(token); + set({ pending: null }); + }, +})); + +export function requestActionToken(options: SecurityChallengeOptions): Promise { + return new Promise((resolve) => { + useSecurityChallengeStore.setState({ pending: { ...options, resolve } }); + }); +}