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
+51 -47
View File
@@ -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<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery());
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
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 [authMode, setAuthMode] = useState<AuthMode>('login');
const [loginForm, setLoginForm] = useState<LoginForm>({ 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 = (
<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 (
<AppShell
activePage={activePage}
authenticationStatus={authenticationStatus}
canAccessAdmin={canAccessAdminWorkspace(currentUser)}
health={health}
isAuthenticated={isAuthenticated}
state={state}
onNavigate={navigatePage}
onLogin={showLogin}
@@ -1510,8 +1544,9 @@ export function App() {
/>
)}
{activePage === 'workspace' && (
isAuthenticated ? (
<WorkspacePage
<AuthGate
status={authenticationStatus}
authenticated={<WorkspacePage
apiKeyForm={apiKeyForm}
apiKeySecret={apiKeySecret}
apiKeySecretsById={apiKeySecretsById}
@@ -1534,29 +1569,14 @@ export function App() {
onTaskQueryChange={navigateWorkspaceTaskQuery}
onTransactionQueryChange={setWorkspaceTransactionQuery}
onUseApiKeyForPlayground={useApiKeyForPlayground}
/>
) : (
<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}
/>
)
/>}
unauthenticated={loginRequiredPanel}
/>
)}
{activePage === 'admin' && (
isAuthenticated ? (
<AdminPage
<AuthGate
status={authenticationStatus}
authenticated={<AdminPage
token={token}
adminTaskQuery={adminTaskQuery}
adminTaskTotal={adminTaskTotal}
@@ -1608,25 +1628,9 @@ export function App() {
onAdminTaskQueryChange={navigateAdminTaskQuery}
onRefreshAdminTasks={refreshAdminTasks}
onSectionChange={navigateAdminSection}
/>
) : (
<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}
/>
)
/>}
unauthenticated={loginRequiredPanel}
/>
)}
{activePage === 'docs' && (
<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(
<AppShell
activePage="home"
authenticationStatus="authenticated"
canAccessAdmin={canAccessAdmin}
health={null}
isAuthenticated
state="ready"
onLogin={handler}
onNavigate={handler}
@@ -29,4 +29,27 @@ describe('AppShell', () => {
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(
<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 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 (
<div className="appShell" data-page={props.activePage}>
<header className="appTopbar">
@@ -55,7 +58,7 @@ export function AppShell(props: {
<span />
{props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'}
</div>
{props.isAuthenticated ? (
{props.authenticationStatus === 'checking' ? null : isAuthenticated ? (
<>
<Button type="button" variant="outline" size="sm" onClick={props.onRefresh} disabled={props.state === 'loading'}>
<RefreshCw size={15} />
+25
View File
@@ -197,6 +197,31 @@
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) {
.landingHero,
.loginRequiredPage {