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