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: {