fix(web): 避免刷新时闪现未登录页面
统一认证会话需要异步恢复,首次渲染不能把尚未确认的状态当作未登录。新增 checking、authenticated、unauthenticated 三态鉴权,在检查期间显示中性加载状态并隐藏登录相关操作。 验证:前端 156 项测试通过;TypeScript 类型检查、lint、生产构建和浏览器刷新验收通过。
This commit is contained in:
+51
-47
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('>刷新<');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user