Commit a5fd5abb by luoqi

fix(web): isolate embedded login sessions and stale responses

parent 15d08d44
Pipeline #3863 failed in 0 seconds
......@@ -46,6 +46,7 @@
"@types/react-dom": "^19.2.3",
"eslint": "^9.17.0",
"eslint-config-next": "^16.2.4",
"jsdom": "^26.1.0",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3",
"vitest": "^3.2.7"
......
......@@ -1174,6 +1174,16 @@ export function AssistantChat({
finalized: string;
} | null>(null);
useEffect(() => () => {
const d = dictRef.current;
dictRef.current = null;
if (d) {
d.socket.disconnect();
void d.ctx.close().catch(() => undefined);
d.stream.getTracks().forEach((t) => t.stop());
}
}, []);
const stopVoice = () => {
const d = dictRef.current;
dictRef.current = null;
......@@ -1191,7 +1201,7 @@ export function AssistantChat({
stopVoice();
return;
}
const token = useAuthStore.getState().accessToken;
const { accessToken: token, sessionVersion } = useAuthStore.getState();
if (!token) {
setVoiceErr('未鉴权,请重新登录');
return;
......@@ -1200,6 +1210,10 @@ export function AssistantChat({
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
if (useAuthStore.getState().sessionVersion !== sessionVersion) {
stream.getTracks().forEach((t) => t.stop());
return;
}
const socket = io(`${env.apiBaseUrl}/pac/v1/assistant/dictation`, {
query: { token },
transports: ['websocket'],
......
'use client';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
AssignmentProposal,
RearrangeDraft,
......@@ -135,6 +135,8 @@ export function useAssistantChat() {
*/
const [model, setModel] = useState('deepseek');
const abortRef = useRef<AbortController | null>(null);
// 页面/会话卸载后停止旧流,避免旧响应继续处理工具结果。
useEffect(() => () => abortRef.current?.abort(), []);
/**
* 消息流的**同步镜像** —— 给那些"要先读当前状态、再发请求"的回调用。
* ⛔ 别把这类读取塞进 `setMessages` 的 updater:StrictMode 会把 updater 跑两次,
......
// @vitest-environment jsdom
import { act, useEffect, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { AuthGate } from './auth-gate';
import { useAuthStore } from '@/stores/auth-store';
import { api } from '@/lib/api-client';
vi.mock('@/components/mock-login-dialog', () => ({ MockLoginDialog: () => <div>LOGIN</div> }));
vi.mock('@/components/session-expired', () => ({ SessionExpired: () => <div>REENTER HOST</div> }));
vi.mock('@/lib/embed', () => ({ isEmbedded: () => true }));
const deferred = () => {
let resolve!: (v: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
};
const tokens = (clinic: string) => ({
accessToken: `x.${btoa(JSON.stringify({ sub: 'admin', hostId: 'friday', tenantId: 'market', role: 'leader', permissions: [], orgScope: [clinic] }))}.x`,
refreshToken: clinic,
expiresIn: 3600,
});
const result = (data: unknown, code = 0) =>
new Response(JSON.stringify({ code, data }), { headers: { 'Content-Type': 'application/json' } });
function TestPage() {
const clinic = useAuthStore((s) => s.user?.clinicIds?.[0]);
const [data, setData] = useState('loading');
useEffect(() => {
void api
.get<string>('/business')
.then(setData)
.catch(() => {});
}, []);
return (
<div>
clinic:{clinic};data:{data}
</div>
);
}
let el: HTMLDivElement, root: Root;
let seen: string[], businessCalls: string[], oldSession: ReturnType<typeof deferred>;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useAuthStore.getState().clear();
window.localStorage.clear();
window.history.replaceState({}, '', '/plans?code=youen');
el = document.createElement('div');
document.body.appendChild(el);
root = createRoot(el);
seen = [];
businessCalls = [];
oldSession = deferred();
vi.stubGlobal(
'fetch',
vi.fn(async (url: URL, opts?: RequestInit) => {
const path = new URL(url).pathname;
const auth = new Headers(opts?.headers).get('Authorization');
const clinic = auth ? JSON.parse(atob(auth.split('.')[1]!)).orgScope[0] : '';
if (path.endsWith('/client-diag')) return result(null);
if (path.endsWith('/exchange-code')) return result(tokens('youen'));
if (path.endsWith('/session')) {
if (clinic === 'puer') return oldSession.promise;
return result({
clinicIds: [clinic],
sourceUnits: [clinic],
permissions: [],
dictionary: { clinics: { [clinic]: clinic } },
});
}
if (path === '/business') {
businessCalls.push(clinic);
return result(clinic);
}
throw new Error(path);
}),
);
});
afterEach(async () => {
await act(async () => root.unmount());
el.remove();
vi.unstubAllGlobals();
});
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
seen.push(el.textContent ?? '');
}
it('never mounts old clinic business while exchanging host code, even if old session arrives last', async () => {
useAuthStore.getState().setTokens(tokens('puer'));
await new Promise((r) => setTimeout(r, 0));
await act(async () =>
root.render(
<AuthGate>
<TestPage />
</AuthGate>,
),
);
await settle();
await settle();
expect(el.textContent).toContain('clinic:youen;data:youen');
await act(async () =>
oldSession.resolve(result({ clinicIds: ['puer'], sourceUnits: ['puer'], permissions: [] })),
);
await settle();
expect(el.textContent).toContain('clinic:youen;data:youen');
expect(businessCalls).toEqual(['youen']);
expect(seen.join('')).not.toContain('puer');
});
it('does not fall back to a stored clinic when the new host code fails', async () => {
useAuthStore.getState().setTokens(tokens('puer'));
const normal = vi.mocked(fetch).getMockImplementation()!;
vi.mocked(fetch).mockImplementation((url, opts) =>
new URL(String(url)).pathname.endsWith('/exchange-code')
? Promise.resolve(result(null, 10102))
: normal(url, opts),
);
await act(async () =>
root.render(
<AuthGate>
<TestPage />
</AuthGate>,
),
);
await settle();
expect(el.textContent).toContain('REENTER HOST');
expect(businessCalls).toEqual([]);
expect(useAuthStore.getState().accessToken).toBeNull();
});
it('remounts all protected page state on a scope change for the same user', async () => {
await act(async () =>
root.render(
<AuthGate>
<TestPage />
</AuthGate>,
),
);
await settle();
expect(el.textContent).toContain('youen');
// 新范围独立返回,模拟同账号切到普尔。
const normal = vi.mocked(fetch).getMockImplementation()!;
vi.mocked(fetch).mockImplementation((url, opts) =>
new URL(String(url)).pathname.endsWith('/session')
? Promise.resolve(result({ clinicIds: ['puer'], sourceUnits: ['puer'], permissions: [] }))
: normal(url, opts),
);
await act(async () => useAuthStore.getState().setTokens(tokens('puer')));
await settle();
expect(el.textContent).toContain('clinic:puer;data:puer');
expect(businessCalls).toEqual(['youen', 'puer']);
});
it('reloads without a code using only the current saved session', async () => {
window.history.replaceState({}, '', '/supervisor');
useAuthStore.getState().setTokens(tokens('youen'));
await act(async () => root.render(<AuthGate><TestPage /></AuthGate>));
await settle();
expect(el.textContent).toContain('clinic:youen;data:youen');
expect(businessCalls).toEqual(['youen']);
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).includes('exchange-code'))).toBe(false);
});
'use client';
import { useEffect, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import { useAuthBootstrap } from '@/hooks/use-auth-bootstrap';
import { useAuthStore } from '@/stores/auth-store';
import { clinicScopeReady, useAuthStore } from '@/stores/auth-store';
import { MockLoginDialog } from '@/components/mock-login-dialog';
import { SessionExpired } from '@/components/session-expired';
import { isEmbedded } from '@/lib/embed';
......@@ -16,6 +16,9 @@ const Placeholder = () => (
export function AuthGate({ children }: { children: React.ReactNode }) {
const { status } = useAuthBootstrap();
const sessionVersion = useAuthStore((s) => s.sessionVersion);
const sessionError = useAuthStore((s) => s.sessionError);
const scopeReady = useAuthStore((s) => clinicScopeReady(s.user));
const isAuthenticated = useAuthStore((s) => s.isAuthenticated());
// SSR + first client render 必须同形态(否则 hydration mismatch)。
......@@ -65,12 +68,11 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
if (!mounted) return <Placeholder />;
// mount 后真正分流:
// - store 已 authenticated → 放行(覆盖 bootstrap 边界 case,如 back 重挂)
// - 还在 bootstrap → 占位
// - 都不是 → 弹"快速登录"对话框(开发 / 试部署用,后端 env 门控)
if (isAuthenticated) return <>{children}</>;
// 新票和诊所范围就绪前不展示任何业务页面;换身份时卸载全部旧页面状态。
if (status === 'authenticating' || status === 'idle') return <Placeholder />;
if (sessionError) return <SessionExpired />;
if (isAuthenticated && !scopeReady) return <Placeholder />;
if (isAuthenticated) return <Fragment key={sessionVersion}>{children}</Fragment>;
// 嵌入态(宿主 iframe):不弹 dev 快速登录 —— 会话失效只能回宿主重进,给友好引导 + 重试。
if (isEmbedded()) return <SessionExpired />;
......
......@@ -2,7 +2,7 @@
import { useEffect, useId, useRef, useState } from 'react';
import { api } from '@/lib/api-client';
import Link from 'next/link';
import { useAuthStore } from '@/stores/auth-store';
import { sessionIdentity, useAuthStore } from '@/stores/auth-store';
import { CircleAlert, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
......@@ -59,7 +59,7 @@ export function HostAppointmentDialog({
const returnFocusRef = useRef<HTMLElement | null>(
typeof document === 'undefined' ? null : (document.activeElement as HTMLElement),
);
const actor = useAuthStore((s) => s.user?.sub ?? 'guest');
const actor = useAuthStore((s) => sessionIdentity(s.user));
const storageKey = `pac:host-appointment:${actor}:${planId ?? patientId}`;
const [context, setContext] = useState<AppointmentContext | null>(null);
const [clinic, setClinic] = useState(targetOrgRef ?? '');
......
'use client';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiCode } from '@pac/types';
import { env } from '@/lib/env';
import { tryRefresh } from '@/lib/api-client';
......@@ -90,6 +90,8 @@ const DURATION_FALLBACK: Record<FixedSectionId, string> = {
export function useScriptStream(): UseScriptStream {
const [state, setState] = useState<ScriptStreamState>({ status: 'idle' });
const abortRef = useRef<AbortController | null>(null);
// 页面/会话卸载后停止旧流,避免旧响应继续处理工具结果。
useEffect(() => () => abortRef.current?.abort(), []);
const reset = useCallback(() => setState({ status: 'idle' }), []);
......
'use client';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiCode } from '@pac/types';
import { env } from '@/lib/env';
import { tryRefresh } from '@/lib/api-client';
......@@ -51,6 +51,8 @@ export interface UseSummaryStream {
export function useSummaryStream(): UseSummaryStream {
const [state, setState] = useState<SummaryStreamState>({ status: 'idle' });
const abortRef = useRef<AbortController | null>(null);
// 页面/会话卸载后停止旧流,避免旧响应继续处理工具结果。
useEffect(() => () => abortRef.current?.abort(), []);
const reset = useCallback(() => setState({ status: 'idle' }), []);
const abort = useCallback(() => {
......
'use client';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { io, type Socket } from 'socket.io-client';
import { env } from '@/lib/env';
import { useAuthStore } from '@/stores/auth-store';
......@@ -39,6 +39,8 @@ export function useRealtimeCoach(planId: string) {
analyserRef.current = null;
}, []);
useEffect(() => cleanup, [cleanup]);
const start = useCallback(async (provider: 'qwen' | 'gemini' = 'qwen') => {
const token = useAuthStore.getState().accessToken;
if (!token) {
......@@ -98,6 +100,10 @@ export function useRealtimeCoach(planId: string) {
// 开 WebRTC 降噪/回声消除/自动增益,过滤稳态背景噪音
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
if (socketRef.current !== socket) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const ctx = new AudioContext();
ctxRef.current = ctx;
......
......@@ -3,7 +3,7 @@
import { useState } from 'react';
import { Clock, RefreshCw } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { refreshToken as apiRefresh } from '@/lib/auth-api';
import { tryRefresh } from '@/lib/api-client';
/**
* 嵌入态(宿主 iframe)会话失效兜底 —— 替代 dev 的 MockLoginDialog。
......@@ -24,8 +24,7 @@ export function SessionExpired() {
// 有 refresh token → 试着静默续期(挡暂时性失败);没有就只能回宿主,直接标记失败。
if (rt) {
try {
const r = await apiRefresh(rt);
useAuthStore.getState().setTokens(r);
if (!(await tryRefresh())) throw new Error('Refresh failed');
return; // 成功:store 更新 → AuthGate 自动放行,本组件卸载
} catch {
/* fall through */
......
......@@ -165,16 +165,20 @@ export function useAuthBootstrap() {
// ─── 1. URL 有 code → 优先(新人接管,覆盖旧 token)───
if (code) {
setStatus('authenticating');
// 宿主新票代表一次新的登录;不能借用浏览器里另一诊所的身份兜底。
clear();
const loginVersion = useAuthStore.getState().sessionVersion;
try {
const r = await exchangeCode(code);
if (useAuthStore.getState().sessionVersion !== loginVersion) return;
setTokens(r);
// ⚠️ 地址栏上面已经剥干净了 —— ⛔ 别在这里再 replaceState 一次(那正是卡死的成因)
reportDiag({ ev: 'code-ok', codeTag });
setStatus('ready');
return;
} catch (e) {
// code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效(地址栏已剥,同上)
// ⭐ 关键证据:换 code 失败瞬间,store 里到底有没有 token(有→能兜住;无→必弹登录已过期)
if (useAuthStore.getState().sessionVersion !== loginVersion) return;
// 新票失败必须回宿主重进,⛔ 不能回落到旧会话。
reportDiag({
ev: 'code-fail',
codeTag,
......@@ -182,7 +186,8 @@ export function useAuthBootstrap() {
...storeSnap(),
...envInfo,
});
// fall through 试 store
setStatus('error');
return;
}
}
......@@ -244,13 +249,13 @@ export function useAuthBootstrap() {
// → 10103 revoked → 误清会话 =「用着用着登录已过期」的竞态根因。
// tryRefresh 内部成功即 setTokens、失败即 clear + 发运行期掉线信标,故此处不再手动处理。
useEffect(() => {
if (!expiresAt || !refresh) return;
if (status !== 'ready' || !expiresAt || !refresh) return;
const delay = Math.max(0, expiresAt - Date.now() - REFRESH_LEAD_MS);
const timer = setTimeout(() => {
void tryRefresh();
}, delay);
return () => clearTimeout(timer);
}, [expiresAt, refresh]);
}, [status, expiresAt, refresh]);
return { status };
}
// @vitest-environment jsdom
import { beforeEach, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { api, tryRefresh } from './api-client';
vi.mock('@/lib/auth-api', () => ({ getSession: () => new Promise(() => {}) }));
vi.mock('@/lib/auth-diag', () => ({
reportAuthDiag: vi.fn(),
authStoreSnap: () => ({}),
authEnvSnap: () => ({}),
}));
const deferred = () => {
let resolve!: (value: any) => void;
const promise = new Promise<any>((r) => {
resolve = r;
});
return { promise, resolve };
};
const tokens = (clinic: string) => ({
accessToken: `x.${Buffer.from(JSON.stringify({ sub: 'admin', hostId: 'friday', tenantId: 'market', orgScope: [clinic] })).toString('base64url')}.x`,
refreshToken: clinic,
expiresIn: 3600,
});
const result = (code: number, data?: any) =>
new Response(JSON.stringify({ code, data }), { headers: { 'Content-Type': 'application/json' } });
beforeEach(() => {
useAuthStore.getState().clear();
vi.stubGlobal('fetch', vi.fn());
});
it.each([0, 10106, 10107])(
'rejects stale response code %s without clearing or retrying under a new login',
async (code) => {
const pending = deferred();
vi.mocked(fetch).mockReturnValueOnce(pending.promise);
useAuthStore.getState().setTokens(tokens('puer'));
const response = api.get('/test');
const checked = expect(response).rejects.toMatchObject({ name: 'AbortError' });
useAuthStore.getState().setTokens(tokens('youen'));
pending.resolve(result(code, { clinic: 'puer' }));
await checked;
expect(useAuthStore.getState().refreshToken).toBe('youen');
expect(fetch).toHaveBeenCalledTimes(1);
},
);
it.each([0, 10103])('ignores late refresh result %s from previous login', async (code) => {
const pending = deferred();
vi.mocked(fetch).mockReturnValueOnce(pending.promise);
useAuthStore.getState().setTokens(tokens('puer'));
const p = tryRefresh();
useAuthStore.getState().setTokens(tokens('youen'));
pending.resolve(result(code, tokens('puer')));
expect(await p).toBe(false);
expect(useAuthStore.getState().refreshToken).toBe('youen');
});
it('single-flights renewal within a session but not across logins', async () => {
const old = deferred(),
fresh = deferred();
vi.mocked(fetch).mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
useAuthStore.getState().setTokens(tokens('puer'));
const a = tryRefresh(),
b = tryRefresh();
expect(fetch).toHaveBeenCalledTimes(1);
useAuthStore.getState().setTokens(tokens('youen'));
const c = tryRefresh();
expect(fetch).toHaveBeenCalledTimes(2);
old.resolve(result(10103));
await Promise.all([a, b]);
fresh.resolve(result(0, tokens('youen')));
expect(await c).toBe(true);
expect(useAuthStore.getState().refreshToken).toBe('youen');
});
......@@ -52,6 +52,12 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
}
}
const sessionVersion = useAuthStore.getState().sessionVersion;
const assertCurrentSession = () => {
if (auth && useAuthStore.getState().sessionVersion !== sessionVersion) {
throw new DOMException('Session changed', 'AbortError');
}
};
const finalHeaders = new Headers(headers);
if (body !== undefined && !(body instanceof FormData)) {
finalHeaders.set('Content-Type', 'application/json');
......@@ -67,6 +73,8 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
});
assertCurrentSession();
// True server crash — preserve HTTP semantics here so monitoring still works.
if (res.status >= 500) {
throw new ApiError(ApiCode.INTERNAL_ERROR, `Server error ${res.status}`);
......@@ -80,6 +88,8 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
throw new ApiError(ApiCode.INTERNAL_ERROR, `Malformed response (HTTP ${res.status})`);
}
assertCurrentSession();
if (payload.code === ApiCode.OK) {
return payload.data as T;
}
......@@ -94,6 +104,7 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
useAuthStore.getState().canRefresh()
) {
const refreshed = await tryRefresh();
assertCurrentSession();
if (refreshed) {
return request<T>(path, { ...opts, _retried: true });
}
......@@ -130,24 +141,27 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
// Silent refresh — 单飞模式(并发请求只调一次 /auth/refresh)
// ─────────────────────────────────────────────────────
let refreshInflight: Promise<boolean> | null = null;
// 按会话与 refresh token 单飞,旧请求完成不能覆盖或清除新登录。
let refreshInflight: { token: string; version: number; promise: Promise<boolean> } | null = null;
/**
* 调用 /auth/refresh。成功 → 写 store 返回 true;失败 → 清 store 返回 false。
* 多请求同时撞 401 时,只调一次 refresh,其它请求 await 同一个 promise。
*/
export async function tryRefresh(): Promise<boolean> {
if (refreshInflight) return refreshInflight;
refreshInflight = doRefresh().finally(() => {
refreshInflight = null;
const { refreshToken: token, sessionVersion: version } = useAuthStore.getState();
if (!token) return false;
if (refreshInflight?.token === token && refreshInflight.version === version) return refreshInflight.promise;
const flight = { token, version, promise: doRefresh() };
refreshInflight = flight;
void flight.promise.finally(() => {
if (refreshInflight === flight) refreshInflight = null;
});
return refreshInflight;
return flight.promise;
}
async function doRefresh(): Promise<boolean> {
const store = useAuthStore.getState();
const rt = store.refreshToken;
if (!rt) return false;
const current = () => useAuthStore.getState().sessionVersion === store.sessionVersion
&& useAuthStore.getState().refreshToken === rt;
// 不复用 request(),避免循环:用裸 fetch 调 public 端点
try {
......@@ -166,6 +180,7 @@ async function doRefresh(): Promise<boolean> {
refreshToken: string;
expiresIn: number;
}>;
if (!current()) return false;
if (payload.code === ApiCode.OK && payload.data) {
store.setTokens({
accessToken: payload.data.accessToken,
......
......@@ -24,7 +24,10 @@ import { STREAM_TOTAL_MS } from '@/components/assistant/cohort-stream';
* 听写状态全靠这个保留)。store 化之后**必须保持这个特性** ——
* 若顺手改成条件渲染,主管每收一次助手就丢一次对话,而这在代码上看不出任何异常。
*/
let handoffTimer: ReturnType<typeof setTimeout> | undefined;
interface AssistantState {
resetSession: () => void;
open: boolean;
setOpen: (open: boolean) => void;
/**
......@@ -87,6 +90,11 @@ interface AssistantState {
}
export const useAssistantStore = create<AssistantState>((set, get) => ({
resetSession: () => {
clearTimeout(handoffTimer);
handoffTimer = undefined;
set({ open: false, maximized: false, pending: null, activeClinicId: null, sheetSnapshot: null });
},
open: false,
setOpen: (open) => set({ open }),
maximized: false,
......@@ -108,7 +116,8 @@ export const useAssistantStore = create<AssistantState>((set, get) => ({
* 等于把他刚看完的工作台整个盖掉 —— 而他多半还要回去点下一格。
* ⛔ 别改回 true:要看大图,助手窗口自己有最大化按钮,那是**他**的决定。
*/
window.setTimeout(() => get().ask(text), STREAM_TOTAL_MS);
clearTimeout(handoffTimer);
handoffTimer = setTimeout(() => get().ask(text), STREAM_TOTAL_MS);
},
activeClinicId: null,
setActiveClinicId: (activeClinicId) => set({ activeClinicId }),
......
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore, sessionIdentity } from './auth-store';
import { useAssistantStore } from './assistant-store';
import { usePlanSyncStore } from './plan-sync-store';
const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() }));
vi.mock('@/lib/auth-api', () => ({ getSession }));
const tick = () => new Promise((r) => setTimeout(r, 0));
const deferred = () => {
let resolve!: (value: any) => void;
const promise = new Promise<any>((r) => {
resolve = r;
});
return { promise, resolve };
};
const user = (clinic = 'puer', sub = 'admin') => ({
sub,
hostId: 'friday',
tenantId: 'market',
role: 'leader' as const,
orgScope: [clinic],
permissions: [],
});
const tokens = (u: object, suffix = '') => ({
accessToken: `x.${Buffer.from(JSON.stringify(u)).toString('base64url')}.${suffix}`,
refreshToken: `refresh-${suffix}`,
expiresIn: 3600,
});
const session = (clinic: string) => ({
clinicIds: [clinic],
sourceUnits: [clinic],
permissions: [],
dictionary: { clinics: { [clinic]: clinic } },
});
beforeEach(() => {
useAuthStore.getState().clear();
getSession.mockReset();
getSession.mockImplementation(() => new Promise(() => {}));
});
describe('session isolation', () => {
it('discards a previous login session response even when it arrives last', async () => {
const old = deferred(),
fresh = deferred();
getSession.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
useAuthStore.getState().setTokens(tokens(user('puer', 'old')));
await tick();
useAuthStore.getState().setTokens(tokens(user('youen', 'new')));
await tick();
fresh.resolve(session('youen'));
await tick();
old.resolve(session('puer'));
await tick();
expect(useAuthStore.getState().user?.clinicIds).toEqual(['youen']);
expect(useAuthStore.getState().user?.dictionary?.clinics).toEqual({ youen: 'youen' });
});
it.each(['clinic', 'tenant', 'host', 'role', 'user'])(
'clears expanded scope when %s changes',
(field) => {
const previous = user();
useAuthStore.setState({ user: { ...previous, ...session('puer') } as any });
const next = { ...previous };
if (field === 'clinic') next.orgScope = ['youen'];
if (field === 'tenant') next.tenantId = 'another';
if (field === 'host') next.hostId = 'another';
if (field === 'role') (next as any).role = 'staff';
if (field === 'user') next.sub = 'another';
useAuthStore.getState().setTokens(tokens(next));
expect(useAuthStore.getState().user?.clinicIds).toBeUndefined();
expect(useAuthStore.getState().user?.sourceUnits).toBeUndefined();
},
);
it('preserves scope during ordinary token renewal', () => {
const u = { ...user(), ...session('puer') };
useAuthStore.setState({ user: u as any });
const version = useAuthStore.getState().sessionVersion;
useAuthStore.getState().setTokens(tokens(user(), 'renewal'));
expect(useAuthStore.getState().sessionVersion).toBe(version);
expect(useAuthStore.getState().user?.clinicIds).toEqual(['puer']);
});
it('does not resurrect data after logout and clears patient/assistant context', async () => {
const old = deferred();
getSession.mockReturnValue(old.promise);
useAuthStore.getState().setTokens(tokens(user()));
await tick();
useAssistantStore.getState().ask('old patient');
useAssistantStore.getState().setActiveClinicId('puer');
usePlanSyncStore.getState().setCurrent({ planId: 'p1', patientId: 'old', patientName: 'old' });
useAuthStore.getState().clear();
old.resolve(session('puer'));
await tick();
expect(useAuthStore.getState().user).toBeNull();
expect(useAssistantStore.getState().pending).toBeNull();
expect(useAssistantStore.getState().activeClinicId).toBeNull();
expect(usePlanSyncStore.getState().current).toBeNull();
});
it('compares scope sets independently of ordering', () => {
expect(sessionIdentity({ ...user(), orgScope: ['b', 'a'] } as any)).toBe(
sessionIdentity({ ...user(), orgScope: ['a', 'b'] } as any),
);
});
});
......@@ -3,6 +3,8 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { AccessTokenPayload } from '@pac/types';
import { useAssistantStore } from './assistant-store';
import { usePlanSyncStore } from './plan-sync-store';
/**
* 前端会话用户 = JWT 解码(role/permissions/sub/tenantId/dictionary/orgScope,即时)
......@@ -49,7 +51,29 @@ export function clinicScopeReady(user: SessionUser | null): boolean {
return Array.isArray(user?.clinicIds);
}
/** 同一账号也可能切品牌、诊所或角色;令牌续期不改变身份边界。 */
export function sessionIdentity(user: SessionUser | null): string {
return user
? JSON.stringify([
user.hostId,
user.tenantId,
user.sub,
user.role,
[...(user.orgScope ?? [])].sort(),
[...(user.permissions ?? [])].sort(),
])
: '';
}
function resetSessionData() {
useAssistantStore.getState().resetSession();
usePlanSyncStore.setState(usePlanSyncStore.getInitialState());
}
interface AuthState {
/** 只在重新登录/退出/身份范围改变时递增;供所有页面和请求隔离会话。 */
sessionVersion: number;
sessionError: boolean;
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
......@@ -97,6 +121,8 @@ function decodeJwt(token: string): AccessTokenPayload | null {
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
sessionVersion: 0,
sessionError: false,
accessToken: null,
refreshToken: null,
expiresAt: null,
......@@ -104,20 +130,22 @@ export const useAuthStore = create<AuthState>()(
setTokens: ({ accessToken, refreshToken, expiresIn }) => {
const decoded = decodeJwt(accessToken);
const prev = get().user;
// 防闪空:silent refresh 重签 token 时,同一 sub 沿用上次已展开的 clinicIds/sourceUnits
// 作占位,避免 user 被 decodeJwt 重置后诊所筛选短暂清空(loadSession 随后会刷新)。
// permissions 同理:decodeJwt 拿到的是**旧 token 的快照**,不留住的话每次 silent refresh
// 都会让新权限的入口(如分配按钮)闪一下消失 —— 正是 loadSession 刚修好的那个问题。
const user =
decoded && prev && prev.sub === decoded.sub
? {
...decoded,
clinicIds: prev.clinicIds,
sourceUnits: prev.sourceUnits,
permissions: prev.permissions ?? decoded.permissions,
}
: decoded;
const sameSession = Boolean(
decoded && prev && sessionIdentity(prev) === sessionIdentity(decoded),
);
const user = sameSession
? {
...decoded!,
clinicIds: prev!.clinicIds,
sourceUnits: prev!.sourceUnits,
actionUrls: prev!.actionUrls,
dictionary: prev!.dictionary ?? decoded!.dictionary,
}
: decoded;
if (!sameSession) resetSessionData();
set({
sessionVersion: get().sessionVersion + (sameSession ? 0 : 1),
sessionError: false,
accessToken,
refreshToken,
expiresAt: Date.now() + expiresIn * 1000,
......@@ -127,13 +155,19 @@ export const useAuthStore = create<AuthState>()(
void get().loadSession();
},
loadSession: async () => {
if (!get().accessToken) return;
const { accessToken, sessionVersion } = get();
if (!accessToken) return;
const current = () =>
get().accessToken === accessToken && get().sessionVersion === sessionVersion;
try {
const { getSession } = await import('@/lib/auth-api');
if (!current()) return;
const s = await getSession();
if (!current()) return;
set((st) =>
st.user
? {
sessionError: false,
user: {
...st.user,
clinicIds: s.clinicIds,
......@@ -152,10 +186,20 @@ export const useAuthStore = create<AuthState>()(
: {},
);
} catch {
// 静默:拿不到就保持 token decode 的基础 user(诊所筛选回退为空)
if (current()) set({ sessionError: true });
}
},
clear: () => set({ accessToken: null, refreshToken: null, expiresAt: null, user: null }),
clear: () => {
resetSessionData();
set((s) => ({
sessionVersion: s.sessionVersion + 1,
sessionError: false,
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
}));
},
isAuthenticated: () => {
const { accessToken, expiresAt } = get();
return Boolean(accessToken && expiresAt && Date.now() < expiresAt);
......@@ -164,7 +208,7 @@ export const useAuthStore = create<AuthState>()(
}),
{
name: 'pac-auth',
storage: createJSONStorage(() => localStorage),
storage: createJSONStorage(() => window.localStorage),
// 只持久化原始 token,派生字段(user)反水合时从 token 重新 decode
partialize: (s) => ({
accessToken: s.accessToken,
......
......@@ -4,5 +4,5 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
esbuild: { jsx: 'automatic' },
test: { maxWorkers: 1 },
test: { maxWorkers: 1, setupFiles: ['./vitest.setup.ts'] },
});
// Node 26 的原生 localStorage getter 会遮住 jsdom 的 Storage。
// 只给缺少 Storage 的测试环境补浏览器等价内存实现,不影响应用代码。
if (typeof window !== 'undefined' && !window.localStorage) {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => { values.delete(key); },
setItem: (key, value) => { values.set(key, String(value)); },
};
Object.defineProperty(window, 'localStorage', { configurable: true, value: storage });
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment