diff --git a/web/src/App.tsx b/web/src/App.tsx index dc4f2aba..bd7c99e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom' import { AppShell } from '@/app/AppShell'; import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot'; import { GlobalToastRoot } from '@/app/GlobalToastRoot'; +import { KickedNoticeRoot } from '@/app/KickedNoticeRoot'; import { RequireAuth } from '@/app/RequireAuth'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { LoginPage } from '@/features/auth/LoginPage'; @@ -104,6 +105,7 @@ export default function App() { tenant" confirmation), not just the logged-in app tree. */} + ); } diff --git a/web/src/app/KickedNoticeRoot.tsx b/web/src/app/KickedNoticeRoot.tsx new file mode 100644 index 00000000..efffee3c --- /dev/null +++ b/web/src/app/KickedNoticeRoot.tsx @@ -0,0 +1,42 @@ +import { clearKickedInfo } from '@/lib/tinode/connection'; +import { kickedNoticeText } from '@/lib/tinode/errors'; +import { useKickedInfo } from '@/lib/tinode/hooks'; + +/** + * Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot -- + * an eviction can land on any authenticated page, not just ones that happen to render their own + * connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already + * torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's + * only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端 + * 接口文档.md §2.4's "弹出中文提示后跳转登录页". + * + * A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for + * an eviction that already happened, offering one would be misleading. Hard `window.location.href` + * navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical + * reason: every piece of in-memory state (TanStack Query cache, any component's local state) should + * reset from scratch, not rely on each one separately noticing "you're logged out now". + */ +export function KickedNoticeRoot() { + const kicked = useKickedInfo(); + if (!kicked) return null; + const { title, message } = kickedNoticeText(kicked.reason); + + const acknowledge = () => { + clearKickedInfo(); + window.location.href = '/login'; + }; + + return ( +
+
+ {title} +

{message}

+
+ +
+
+
+ ); +} diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts index 095e038b..04dbb057 100644 --- a/web/src/lib/tinode/connection.ts +++ b/web/src/lib/tinode/connection.ts @@ -7,7 +7,7 @@ // silently stomp on each other. This module claims all the SDK's callback slots exactly once and // fans them out to any number of subscribers via its own Set, which is what hooks.ts's // useSyncExternalStore-based hooks actually subscribe to. -import { Tinode, type LoginCtrl } from 'tinode-sdk'; +import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk'; import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config'; import { isTenantMismatch } from '@/lib/tinode/errors'; @@ -109,6 +109,32 @@ if (typeof window !== 'undefined') { window.addEventListener('offline', () => setReconnectState({ networkOnline: false })); } +/** + * 强制下线通知与顶号互踢-后端接口文档.md §2: an account-level eviction is a `{ctrl}` with + * `code:205, text:"evicted"` and NO `topic` (a topic-scoped one -- e.g. plain removal from a group -- + * carries a topic name instead and is left to whatever already handles that; not this module's + * concern). `reason` drives which notice text KickedNoticeRoot shows. + */ +let kickedInfo: { reason: string | undefined } | null = null; +const kickedListeners = new Set<() => void>(); + +export function getKickedInfo(): { reason: string | undefined } | null { + return kickedInfo; +} + +export function subscribeKicked(callback: () => void): () => void { + kickedListeners.add(callback); + return () => kickedListeners.delete(callback); +} + +/** KickedNoticeRoot calls this once the user has acknowledged the notice, right before navigating + * to /login -- clears the flag so a later fresh login doesn't leave a stale notice around to + * reappear (e.g. StrictMode remount, or the user landing back on some cached page via history). */ +export function clearKickedInfo() { + kickedInfo = null; + kickedListeners.forEach((cb) => cb()); +} + /** * The server extends the stock {hi} packet with a `tenant` field the vendored JS SDK has no API for * (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private, @@ -205,6 +231,21 @@ function createTinode(): Tinode { if (next <= 0) clearCountdown(); }, 1000); }; + t.onCtrlMessage = (ctrl: TinodeCtrl) => { + if (ctrl.code !== 205 || ctrl.text !== 'evicted' || ctrl.topic) return; + // Tear down the session THE MOMENT eviction is detected, not when the user dismisses the + // notice -- the doc requires reconnect to stop immediately, and resetTinode()'s disconnect() + // sets the SDK's own #boffClosed flag before this handler returns, which is what actually + // prevents the backoff timer from ever scheduling another attempt (confirmed by reading the + // real bundle's Connection#init_ws -- disconnect() is the only thing that does this; there is + // no separate "stop autoreconnect" method to call instead). Only the user-facing navigation to + // /login is deferred, so they get a chance to actually read why. + sessionStorage.removeItem('im-session-token'); + useSessionStore.getState().setMyUserId(null); + resetTinode(); + kickedInfo = { reason: typeof ctrl.params?.reason === 'string' ? ctrl.params.reason : undefined }; + kickedListeners.forEach((cb) => cb()); + }; return t; } diff --git a/web/src/lib/tinode/errors.ts b/web/src/lib/tinode/errors.ts index d29792f9..fb80a207 100644 --- a/web/src/lib/tinode/errors.ts +++ b/web/src/lib/tinode/errors.ts @@ -87,3 +87,36 @@ export function validateCredentialsMessage(params: ValidateCredentialsParams | u const reason = params?.reason; return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试'; } + +/** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording, + * so this stays a straight lookup rather than something to rephrase later without checking the doc + * again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table + * merges them into a single row). */ +const KICKED_REASON_TEXT_CN: Record = { + replaced_by_new_login: { + title: '账号已在其他设备登录', + message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。', + }, + admin_user_evict: { + title: '账号已被管理员下线', + message: '您的账号已被管理员强制下线,如有疑问请联系管理员。', + }, + admin_device_kick: { + title: '账号已被管理员下线', + message: '您的账号已被管理员强制下线,如有疑问请联系管理员。', + }, + user_deleted: { + title: '账号已注销', + message: '您的账号已注销,当前会话已退出。', + }, + user_state_changed: { + title: '账号已被限制登录', + message: '您的账号已被管理员限制登录,如有疑问请联系管理员。', + }, +}; + +const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' }; + +export function kickedNoticeText(reason: string | undefined): { title: string; message: string } { + return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK; +} diff --git a/web/src/lib/tinode/hooks.ts b/web/src/lib/tinode/hooks.ts index c67b4b05..fb40af60 100644 --- a/web/src/lib/tinode/hooks.ts +++ b/web/src/lib/tinode/hooks.ts @@ -2,7 +2,16 @@ import { useCallback, useSyncExternalStore } from 'react'; import type { Contact, StoredMessage } from 'tinode-sdk'; -import { getConnectionStatus, getReconnectState, getTinode, subscribeConnectionStatus, subscribeReconnectState, type ReconnectState } from './connection'; +import { + getConnectionStatus, + getKickedInfo, + getReconnectState, + getTinode, + subscribeConnectionStatus, + subscribeKicked, + subscribeReconnectState, + type ReconnectState, +} from './connection'; import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics'; /** The current WS connection status, reactive via useSyncExternalStore. */ @@ -15,6 +24,12 @@ export function useReconnectState(): ReconnectState { return useSyncExternalStore(subscribeReconnectState, getReconnectState); } +/** Non-null once the server has forcibly evicted this session (顶号/管理端踢出/封禁/删号/重置密码 -- + * see 强制下线通知与顶号互踢-后端接口文档.md §2); KickedNoticeRoot is the sole consumer. */ +export function useKickedInfo() { + return useSyncExternalStore(subscribeKicked, getKickedInfo); +} + /** The singleton Tinode client instance (stable reference, not itself reactive). */ export function useTinode() { return getTinode(); diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index c00e2b53..89544a37 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -348,6 +348,27 @@ declare module 'tinode-sdk' { * has stopped entirely (e.g. an explicit disconnect). */ onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise) => void; + /** + * Fires for every incoming `{ctrl}` packet, account-level ones included (verified + * tinode.dev.js:6564-6566: `if (this.onCtrlMessage) this.onCtrlMessage(pkt.ctrl)`, unconditional, + * runs before the SDK's own internal `code===205` handling). This is the ONLY hook the actual + * installed SDK (0.25.3) offers for a server-forced eviction (`code:205, text:"evicted"`) -- + * there is no `onKick` callback in this version despite 强制下线通知与顶号互踢-后端接口文档.md's + * §2.4 claiming the SDK auto-stops reconnect and calls one; read the real bundle to confirm + * before relying on that doc's description of client-side behavior again. + */ + onCtrlMessage?: (ctrl: TinodeCtrl) => void; + } + + export interface TinodeCtrl { + code: number; + text: string; + /** Empty/absent for an account-level `{ctrl}` (e.g. forced eviction); a topic-scoped event + * (e.g. a plain "removed from this group") carries the topic name here instead. */ + topic?: string; + params?: Record; + ts?: string; + id?: string; } export const Drafty: {