feat(web): 实现强制下线通知与顶号互踢的客户端感知(Ctrl 205 evicted)
按docs/强制下线通知与顶号互踢-后端接口文档.md §2实现。该文档§2.4声称
"SDK侧已内置:收到后关闭autoreconnect并回调onKick(reason, ctrl)"——读
实际安装的tinode-sdk(0.25.3)真实源码确认这个说法不成立:SDK对
code:205/text:"evicted"只有topic级的内部处理(topic._resetSub()),账
号级(topic为空)完全没有特殊逻辑,也没有onKick这个回调。
改为用SDK真实存在、此前项目里完全没用过的onCtrlMessage钩子(每条
{ctrl}包都会触发,含账号级的)在connection.ts里自行判定
"code===205 && text==='evicted' && !topic"。检测到后立即:清空
sessionStorage的token、清空myUserId、调用resetTinode()——其内部的
disconnect()会把SDK自己的#boffClosed标记置真,这是唯一能真正阻止
backoff计时器安排下一次重连的方式(读真实bundle的Connection#init_ws
确认,不存在另外一个"关闭autoreconnect"的方法)。只有面向用户的弹窗
和跳转延后到用户点确定,逻辑上是"会话立刻终止,通知是补充说明",不
是"通知完才终止"。
新增KickedNoticeRoot(挂载在App.tsx顶层,和ConfirmDialogRoot/
GlobalToastRoot同一层级,保证任意页面被踢都能弹出),文案原样照抄文
档§2.4的表格(reason→标题/正文),单一"确定"按钮(不是取消/确定二
选一——已经被踢了,没有"取消"这个选项,给出会误导)。确认后用
window.location.href硬跳转/login,和client.ts的forceReauth()同一个
理由:保证内存里的每一处状态(TanStack Query缓存等)都从头重置,不用
指望每个组件各自正确响应"你已经被登出了"。
topic非空的205(群内移出等topic级事件)不受影响,走原有的SDK内部
处理,不弹这个全局提示。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c3e8f91364
commit
9adadaa57d
@@ -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. */}
|
||||
<ConfirmDialogRoot />
|
||||
<GlobalToastRoot />
|
||||
<KickedNoticeRoot />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25">
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal">
|
||||
<strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong>
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button type="button" onClick={acknowledge} className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, { title: string; message: string }> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+21
@@ -348,6 +348,27 @@ declare module 'tinode-sdk' {
|
||||
* has stopped entirely (e.g. an explicit disconnect).
|
||||
*/
|
||||
onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise<unknown>) => 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<string, unknown>;
|
||||
ts?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Drafty: {
|
||||
|
||||
Reference in New Issue
Block a user