From 3c8d9839a4b81fdbb32143e8f02404d9fc6c7151 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Mon, 3 Aug 2026 15:14:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E9=81=BF=E5=85=8D=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=97=B6=E9=97=AA=E7=8E=B0=E6=9C=AA=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一认证会话需要异步恢复,首次渲染不能把尚未确认的状态当作未登录。新增 checking、authenticated、unauthenticated 三态鉴权,在检查期间显示中性加载状态并隐藏登录相关操作。 验证:前端 156 项测试通过;TypeScript 类型检查、lint、生产构建和浏览器刷新验收通过。 --- apps/web/src/App.tsx | 98 ++++++++++--------- apps/web/src/components/AuthGate.test.tsx | 47 +++++++++ apps/web/src/components/AuthGate.tsx | 24 +++++ .../src/components/layout/AppShell.test.tsx | 25 ++++- apps/web/src/components/layout/AppShell.tsx | 7 +- apps/web/src/styles/landing.css | 25 +++++ 6 files changed, 176 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/AuthGate.test.tsx create mode 100644 apps/web/src/components/AuthGate.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1810e11..595de68 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -119,6 +119,7 @@ import { verifySecurityEventConnection, } from './api'; import type { ConsoleData, StatItem } from './app-state'; +import { AuthGate, initialAuthenticationStatus } from './components/AuthGate'; import { AppShell } from './components/layout/AppShell'; import { canAccessAdminWorkspace } from './auth-permissions'; import { LoginRequiredPanel } from './components/LoginRequiredPanel'; @@ -228,7 +229,9 @@ export function App() { const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState(() => defaultWorkspaceTransactionQuery()); const [apiDocSection, setApiDocSection] = useState(initialRoute.apiDocSection); const [playgroundMode, setPlaygroundMode] = useState(initialRoute.playgroundMode); - const [token, setToken] = useState(readStoredAccessToken); + const [initialAccessToken] = useState(readStoredAccessToken); + const [token, setToken] = useState(initialAccessToken); + const [authenticationStatus, setAuthenticationStatus] = useState(() => initialAuthenticationStatus(initialAccessToken)); const [externalToken, setExternalToken] = useState(''); const [authMode, setAuthMode] = useState('login'); const [loginForm, setLoginForm] = useState({ account: '', password: '' }); @@ -337,6 +340,7 @@ export function App() { setCurrentUser(restored.user); loadedDataKeysRef.current.add('currentUser'); setToken(restored.credential); + setAuthenticationStatus('authenticated'); setState('idle'); setError(''); }; @@ -356,13 +360,22 @@ export function App() { }); return; } - if (oidcCallbackError || readStoredAccessToken() || !identityEnabled) return; + const storedAccessToken = readStoredAccessToken(); + if (oidcCallbackError || storedAccessToken || !identityEnabled) { + if (!storedAccessToken) setAuthenticationStatus('unauthenticated'); + return; + } const restored = await restoreOIDCBrowserSession(); - if (!restored || cancelled) return; + if (cancelled) return; + if (!restored) { + setAuthenticationStatus('unauthenticated'); + return; + } applyRestoredSession(restored); }; const handleLoadError = (err: unknown) => { if (cancelled) return; + setAuthenticationStatus('unauthenticated'); setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); }; @@ -691,6 +704,7 @@ export function App() { } persistAccessToken(nextToken); setToken(nextToken); + setAuthenticationStatus('authenticated'); await ensureRouteData(nextToken, true); } @@ -701,6 +715,7 @@ export function App() { const response = await request(); persistAccessToken(response.accessToken); setToken(response.accessToken); + setAuthenticationStatus('authenticated'); if (activePage === 'login') { navigatePath(pathForWorkspaceSection('overview')); return; @@ -1298,6 +1313,7 @@ export function App() { function resetAuthenticatedSession() { persistAccessToken(''); setToken(''); + setAuthenticationStatus('unauthenticated'); loadedDataKeysRef.current = new Set(health ? ['health'] : []); loadingDataKeysRef.current.clear(); setState('idle'); @@ -1466,14 +1482,32 @@ export function App() { navigatePath(pathForPlaygroundMode('chat')); } - const isAuthenticated = Boolean(token); + const isAuthenticated = authenticationStatus === 'authenticated' && Boolean(token); + const loginRequiredPanel = ( + + ); return ( )} {activePage === 'workspace' && ( - isAuthenticated ? ( - - ) : ( - - ) + />} + unauthenticated={loginRequiredPanel} + /> )} {activePage === 'admin' && ( - isAuthenticated ? ( - - ) : ( - - ) + />} + unauthenticated={loginRequiredPanel} + /> )} {activePage === 'docs' && ( { + it('shows a neutral session check without rendering the signed-out view', () => { + const html = renderToStaticMarkup( + 工作台内容} + unauthenticated={
登录后进入工作台
} + />, + ); + + expect(html).toContain('正在确认登录状态'); + expect(html).not.toContain('登录后进入工作台'); + expect(html).not.toContain('工作台内容'); + expect(html).toContain('aria-busy="true"'); + }); + + it('renders the matching view after authentication is resolved', () => { + const authenticated = renderToStaticMarkup( + 工作台内容} + unauthenticated={
登录后进入工作台
} + />, + ); + const unauthenticated = renderToStaticMarkup( + 工作台内容} + unauthenticated={
登录后进入工作台
} + />, + ); + + expect(authenticated).toContain('工作台内容'); + expect(authenticated).not.toContain('登录后进入工作台'); + expect(unauthenticated).toContain('登录后进入工作台'); + expect(unauthenticated).not.toContain('工作台内容'); + }); + + it('checks for a cookie session only when no stored access token exists', () => { + expect(initialAuthenticationStatus('')).toBe('checking'); + expect(initialAuthenticationStatus('local-access-token')).toBe('authenticated'); + }); +}); diff --git a/apps/web/src/components/AuthGate.tsx b/apps/web/src/components/AuthGate.tsx new file mode 100644 index 0000000..c8323b9 --- /dev/null +++ b/apps/web/src/components/AuthGate.tsx @@ -0,0 +1,24 @@ +import { LoaderCircle } from 'lucide-react'; +import type { ReactNode } from 'react'; + +export type AuthenticationStatus = 'checking' | 'authenticated' | 'unauthenticated'; + +export function initialAuthenticationStatus(storedAccessToken: string): AuthenticationStatus { + return storedAccessToken ? 'authenticated' : 'checking'; +} + +export function AuthGate(props: { + authenticated: ReactNode; + status: AuthenticationStatus; + unauthenticated: ReactNode; +}) { + if (props.status === 'authenticated') return props.authenticated; + if (props.status === 'unauthenticated') return props.unauthenticated; + + return ( +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.test.tsx b/apps/web/src/components/layout/AppShell.test.tsx index 7e287db..1a64893 100644 --- a/apps/web/src/components/layout/AppShell.test.tsx +++ b/apps/web/src/components/layout/AppShell.test.tsx @@ -7,9 +7,9 @@ function renderShell(canAccessAdmin: boolean) { return renderToStaticMarkup( { it('shows the admin workspace navigation with admin access', () => { expect(renderShell(true)).toContain('管理工作台'); }); + + it('does not show signed-in or signed-out actions while checking the session', () => { + const handler = vi.fn(); + const html = renderToStaticMarkup( + +
正在确认登录状态
+
, + ); + + expect(html).not.toContain('>登录<'); + expect(html).not.toContain('>退出<'); + expect(html).not.toContain('>刷新<'); + }); }); diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index f70835b..f5faaa6 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { BookOpen, Boxes, Home, RefreshCw, ShieldCheck, Sparkles, UserCircle } from 'lucide-react'; import type { HealthResponse } from '../../api'; import type { LoadState, PageKey } from '../../types'; +import type { AuthenticationStatus } from '../AuthGate'; import { Button, Badge } from '../ui'; const navItems: Array<{ key: PageKey; label: string; icon: ReactNode }> = [ @@ -18,13 +19,15 @@ export function AppShell(props: { canAccessAdmin: boolean; children: ReactNode; health: HealthResponse | null; - isAuthenticated: boolean; + authenticationStatus: AuthenticationStatus; state: LoadState; onNavigate: (page: PageKey) => void; onLogin: () => void; onRefresh: () => void; onSignOut: () => void; }) { + const isAuthenticated = props.authenticationStatus === 'authenticated'; + return (
@@ -55,7 +58,7 @@ export function AppShell(props: { {props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'}
- {props.isAuthenticated ? ( + {props.authenticationStatus === 'checking' ? null : isAuthenticated ? ( <>