fix(web): 避免刷新时闪现未登录页面

统一认证会话需要异步恢复,首次渲染不能把尚未确认的状态当作未登录。新增 checking、authenticated、unauthenticated 三态鉴权,在检查期间显示中性加载状态并隐藏登录相关操作。

验证:前端 156 项测试通过;TypeScript 类型检查、lint、生产构建和浏览器刷新验收通过。
This commit is contained in:
2026-08-03 15:55:49 +08:00
parent b56769512f
commit 3c8d9839a4
6 changed files with 176 additions and 50 deletions
+49 -45
View File
@@ -119,6 +119,7 @@ import {
verifySecurityEventConnection, verifySecurityEventConnection,
} from './api'; } from './api';
import type { ConsoleData, StatItem } from './app-state'; import type { ConsoleData, StatItem } from './app-state';
import { AuthGate, initialAuthenticationStatus } from './components/AuthGate';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { canAccessAdminWorkspace } from './auth-permissions'; import { canAccessAdminWorkspace } from './auth-permissions';
import { LoginRequiredPanel } from './components/LoginRequiredPanel'; import { LoginRequiredPanel } from './components/LoginRequiredPanel';
@@ -228,7 +229,9 @@ export function App() {
const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery()); const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery());
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection); const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode); const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(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 [externalToken, setExternalToken] = useState('');
const [authMode, setAuthMode] = useState<AuthMode>('login'); const [authMode, setAuthMode] = useState<AuthMode>('login');
const [loginForm, setLoginForm] = useState<LoginForm>({ account: '', password: '' }); const [loginForm, setLoginForm] = useState<LoginForm>({ account: '', password: '' });
@@ -337,6 +340,7 @@ export function App() {
setCurrentUser(restored.user); setCurrentUser(restored.user);
loadedDataKeysRef.current.add('currentUser'); loadedDataKeysRef.current.add('currentUser');
setToken(restored.credential); setToken(restored.credential);
setAuthenticationStatus('authenticated');
setState('idle'); setState('idle');
setError(''); setError('');
}; };
@@ -356,13 +360,22 @@ export function App() {
}); });
return; return;
} }
if (oidcCallbackError || readStoredAccessToken() || !identityEnabled) return; const storedAccessToken = readStoredAccessToken();
if (oidcCallbackError || storedAccessToken || !identityEnabled) {
if (!storedAccessToken) setAuthenticationStatus('unauthenticated');
return;
}
const restored = await restoreOIDCBrowserSession(); const restored = await restoreOIDCBrowserSession();
if (!restored || cancelled) return; if (cancelled) return;
if (!restored) {
setAuthenticationStatus('unauthenticated');
return;
}
applyRestoredSession(restored); applyRestoredSession(restored);
}; };
const handleLoadError = (err: unknown) => { const handleLoadError = (err: unknown) => {
if (cancelled) return; if (cancelled) return;
setAuthenticationStatus('unauthenticated');
setState('error'); setState('error');
setError(err instanceof Error ? err.message : '统一认证登录失败'); setError(err instanceof Error ? err.message : '统一认证登录失败');
}; };
@@ -691,6 +704,7 @@ export function App() {
} }
persistAccessToken(nextToken); persistAccessToken(nextToken);
setToken(nextToken); setToken(nextToken);
setAuthenticationStatus('authenticated');
await ensureRouteData(nextToken, true); await ensureRouteData(nextToken, true);
} }
@@ -701,6 +715,7 @@ export function App() {
const response = await request(); const response = await request();
persistAccessToken(response.accessToken); persistAccessToken(response.accessToken);
setToken(response.accessToken); setToken(response.accessToken);
setAuthenticationStatus('authenticated');
if (activePage === 'login') { if (activePage === 'login') {
navigatePath(pathForWorkspaceSection('overview')); navigatePath(pathForWorkspaceSection('overview'));
return; return;
@@ -1298,6 +1313,7 @@ export function App() {
function resetAuthenticatedSession() { function resetAuthenticatedSession() {
persistAccessToken(''); persistAccessToken('');
setToken(''); setToken('');
setAuthenticationStatus('unauthenticated');
loadedDataKeysRef.current = new Set(health ? ['health'] : []); loadedDataKeysRef.current = new Set(health ? ['health'] : []);
loadingDataKeysRef.current.clear(); loadingDataKeysRef.current.clear();
setState('idle'); setState('idle');
@@ -1466,14 +1482,32 @@ export function App() {
navigatePath(pathForPlaygroundMode('chat')); navigatePath(pathForPlaygroundMode('chat'));
} }
const isAuthenticated = Boolean(token); const isAuthenticated = authenticationStatus === 'authenticated' && Boolean(token);
const loginRequiredPanel = (
<LoginRequiredPanel
authMode={authMode}
externalToken={externalToken}
loginForm={loginForm}
registerForm={registerForm}
state={state}
onAuthModeChange={setAuthMode}
onExternalTokenChange={setExternalToken}
onLoginChange={setLoginForm}
onRegisterChange={setRegisterForm}
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
onOIDCLogin={loginWithOIDC}
/>
);
return ( return (
<AppShell <AppShell
activePage={activePage} activePage={activePage}
authenticationStatus={authenticationStatus}
canAccessAdmin={canAccessAdminWorkspace(currentUser)} canAccessAdmin={canAccessAdminWorkspace(currentUser)}
health={health} health={health}
isAuthenticated={isAuthenticated}
state={state} state={state}
onNavigate={navigatePage} onNavigate={navigatePage}
onLogin={showLogin} onLogin={showLogin}
@@ -1510,8 +1544,9 @@ export function App() {
/> />
)} )}
{activePage === 'workspace' && ( {activePage === 'workspace' && (
isAuthenticated ? ( <AuthGate
<WorkspacePage status={authenticationStatus}
authenticated={<WorkspacePage
apiKeyForm={apiKeyForm} apiKeyForm={apiKeyForm}
apiKeySecret={apiKeySecret} apiKeySecret={apiKeySecret}
apiKeySecretsById={apiKeySecretsById} apiKeySecretsById={apiKeySecretsById}
@@ -1534,29 +1569,14 @@ export function App() {
onTaskQueryChange={navigateWorkspaceTaskQuery} onTaskQueryChange={navigateWorkspaceTaskQuery}
onTransactionQueryChange={setWorkspaceTransactionQuery} onTransactionQueryChange={setWorkspaceTransactionQuery}
onUseApiKeyForPlayground={useApiKeyForPlayground} onUseApiKeyForPlayground={useApiKeyForPlayground}
/>}
unauthenticated={loginRequiredPanel}
/> />
) : (
<LoginRequiredPanel
authMode={authMode}
externalToken={externalToken}
loginForm={loginForm}
registerForm={registerForm}
state={state}
onAuthModeChange={setAuthMode}
onExternalTokenChange={setExternalToken}
onLoginChange={setLoginForm}
onRegisterChange={setRegisterForm}
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
onOIDCLogin={loginWithOIDC}
/>
)
)} )}
{activePage === 'admin' && ( {activePage === 'admin' && (
isAuthenticated ? ( <AuthGate
<AdminPage status={authenticationStatus}
authenticated={<AdminPage
token={token} token={token}
adminTaskQuery={adminTaskQuery} adminTaskQuery={adminTaskQuery}
adminTaskTotal={adminTaskTotal} adminTaskTotal={adminTaskTotal}
@@ -1608,25 +1628,9 @@ export function App() {
onAdminTaskQueryChange={navigateAdminTaskQuery} onAdminTaskQueryChange={navigateAdminTaskQuery}
onRefreshAdminTasks={refreshAdminTasks} onRefreshAdminTasks={refreshAdminTasks}
onSectionChange={navigateAdminSection} onSectionChange={navigateAdminSection}
/>}
unauthenticated={loginRequiredPanel}
/> />
) : (
<LoginRequiredPanel
authMode={authMode}
externalToken={externalToken}
loginForm={loginForm}
registerForm={registerForm}
state={state}
onAuthModeChange={setAuthMode}
onExternalTokenChange={setExternalToken}
onLoginChange={setLoginForm}
onRegisterChange={setRegisterForm}
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
oidcEnabled={oidcEnabled}
onOIDCLogin={loginWithOIDC}
/>
)
)} )}
{activePage === 'docs' && ( {activePage === 'docs' && (
<ApiDocsPage <ApiDocsPage
+47
View File
@@ -0,0 +1,47 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { AuthGate, initialAuthenticationStatus } from './AuthGate';
describe('AuthGate', () => {
it('shows a neutral session check without rendering the signed-out view', () => {
const html = renderToStaticMarkup(
<AuthGate
status="checking"
authenticated={<div></div>}
unauthenticated={<div></div>}
/>,
);
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(
<AuthGate
status="authenticated"
authenticated={<div></div>}
unauthenticated={<div></div>}
/>,
);
const unauthenticated = renderToStaticMarkup(
<AuthGate
status="unauthenticated"
authenticated={<div></div>}
unauthenticated={<div></div>}
/>,
);
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');
});
});
+24
View File
@@ -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 (
<div className="authSessionChecking" role="status" aria-busy="true" aria-live="polite">
<LoaderCircle aria-hidden="true" size={20} />
<span></span>
</div>
);
}
@@ -7,9 +7,9 @@ function renderShell(canAccessAdmin: boolean) {
return renderToStaticMarkup( return renderToStaticMarkup(
<AppShell <AppShell
activePage="home" activePage="home"
authenticationStatus="authenticated"
canAccessAdmin={canAccessAdmin} canAccessAdmin={canAccessAdmin}
health={null} health={null}
isAuthenticated
state="ready" state="ready"
onLogin={handler} onLogin={handler}
onNavigate={handler} onNavigate={handler}
@@ -29,4 +29,27 @@ describe('AppShell', () => {
it('shows the admin workspace navigation with admin access', () => { it('shows the admin workspace navigation with admin access', () => {
expect(renderShell(true)).toContain('管理工作台'); 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(
<AppShell
activePage="workspace"
authenticationStatus="checking"
canAccessAdmin={false}
health={null}
state="idle"
onLogin={handler}
onNavigate={handler}
onRefresh={handler}
onSignOut={handler}
>
<div></div>
</AppShell>,
);
expect(html).not.toContain('>登录<');
expect(html).not.toContain('>退出<');
expect(html).not.toContain('>刷新<');
});
}); });
+5 -2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { BookOpen, Boxes, Home, RefreshCw, ShieldCheck, Sparkles, UserCircle } from 'lucide-react'; import { BookOpen, Boxes, Home, RefreshCw, ShieldCheck, Sparkles, UserCircle } from 'lucide-react';
import type { HealthResponse } from '../../api'; import type { HealthResponse } from '../../api';
import type { LoadState, PageKey } from '../../types'; import type { LoadState, PageKey } from '../../types';
import type { AuthenticationStatus } from '../AuthGate';
import { Button, Badge } from '../ui'; import { Button, Badge } from '../ui';
const navItems: Array<{ key: PageKey; label: string; icon: ReactNode }> = [ const navItems: Array<{ key: PageKey; label: string; icon: ReactNode }> = [
@@ -18,13 +19,15 @@ export function AppShell(props: {
canAccessAdmin: boolean; canAccessAdmin: boolean;
children: ReactNode; children: ReactNode;
health: HealthResponse | null; health: HealthResponse | null;
isAuthenticated: boolean; authenticationStatus: AuthenticationStatus;
state: LoadState; state: LoadState;
onNavigate: (page: PageKey) => void; onNavigate: (page: PageKey) => void;
onLogin: () => void; onLogin: () => void;
onRefresh: () => void; onRefresh: () => void;
onSignOut: () => void; onSignOut: () => void;
}) { }) {
const isAuthenticated = props.authenticationStatus === 'authenticated';
return ( return (
<div className="appShell" data-page={props.activePage}> <div className="appShell" data-page={props.activePage}>
<header className="appTopbar"> <header className="appTopbar">
@@ -55,7 +58,7 @@ export function AppShell(props: {
<span /> <span />
{props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'} {props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'}
</div> </div>
{props.isAuthenticated ? ( {props.authenticationStatus === 'checking' ? null : isAuthenticated ? (
<> <>
<Button type="button" variant="outline" size="sm" onClick={props.onRefresh} disabled={props.state === 'loading'}> <Button type="button" variant="outline" size="sm" onClick={props.onRefresh} disabled={props.state === 'loading'}>
<RefreshCw size={15} /> <RefreshCw size={15} />
+25
View File
@@ -197,6 +197,31 @@
min-height: auto; min-height: auto;
} }
.authSessionChecking {
align-items: center;
color: var(--muted-foreground);
display: flex;
gap: 10px;
justify-content: center;
min-height: calc(100vh - 160px);
}
.authSessionChecking svg {
animation: auth-session-spin 0.9s linear infinite;
}
@keyframes auth-session-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.authSessionChecking svg {
animation: none;
}
}
@media (max-width: 980px) { @media (max-width: 980px) {
.landingHero, .landingHero,
.loginRequiredPage { .loginRequiredPage {