feat(web): 增加单点登录分流与本地应急入口

开启统一认证后,正常登录入口直接跳转认证中心,并将本地账号入口收敛到 /login 应急页;未开启统一认证时继续保留工作台内原有登录方式。

影响范围仅限 Web 登录路由、认证入口展示和相关测试,不修改认证接口或 OpenAPI。风险主要在运行时身份状态判断,已通过 141 项前端测试、生产构建、无缓存类型检查及浏览器分支验证。
This commit is contained in:
2026-07-31 13:43:06 +08:00
parent cae97b6f77
commit 88c971564a
10 changed files with 169 additions and 14 deletions
+33 -3
View File
@@ -131,6 +131,7 @@ import {
} from './lib/oidc-browser-session';
import {
consumeOIDCCallbackError,
loginEntryAction,
loadOIDCRuntimeConfiguration,
startOIDCLogin,
startOIDCLogout,
@@ -138,6 +139,7 @@ import {
import { runTask, type RunTaskOptions } from './lib/run-task';
import { AdminPage } from './pages/AdminPage';
import { ApiDocsPage } from './pages/ApiDocsPage';
import { EmergencyLoginPage } from './pages/EmergencyLoginPage';
import { HomePage } from './pages/HomePage';
import { ModelsPage } from './pages/ModelsPage';
import { PlaygroundPage } from './pages/PlaygroundPage';
@@ -686,6 +688,10 @@ export function App() {
const response = await request();
persistAccessToken(response.accessToken);
setToken(response.accessToken);
if (activePage === 'login') {
navigatePath(pathForWorkspaceSection('overview'));
return;
}
await ensureRouteData(response.accessToken, true);
} catch (err) {
setState('error');
@@ -1308,9 +1314,25 @@ export function App() {
});
}
function showLogin() {
setAuthMode('login');
navigatePath(pathForWorkspaceSection('overview'));
async function showLogin() {
setState('loading');
setError('');
try {
const configuration = await loadOIDCRuntimeConfiguration(true);
const identityEnabled = configuration.enabled && configuration.oidcLogin;
setOIDCEnabled(identityEnabled);
if (loginEntryAction(identityEnabled) === 'oidc') {
setOIDCCallbackError(null);
await startOIDCLogin(configuration);
return;
}
setState('idle');
setAuthMode('login');
navigatePath(pathForWorkspaceSection('overview'));
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '登录入口加载失败');
}
}
function currentRouteState(): AppRouteState {
@@ -1411,6 +1433,14 @@ export function App() {
/>
)}
{activePage === 'models' && <ModelsPage data={data} />}
{activePage === 'login' && (
<EmergencyLoginPage
loginForm={loginForm}
state={state}
onLoginChange={setLoginForm}
onSubmitLogin={submitLogin}
/>
)}
{activePage === 'workspace' && (
isAuthenticated ? (
<WorkspacePage
@@ -0,0 +1,39 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { AuthPanel } from './AuthPanel';
const baseProps = {
authMode: 'login' as const,
externalToken: '',
loginForm: { account: '', password: '' },
registerForm: { username: '', email: '', password: '', displayName: '', invitationCode: '' },
state: 'idle' as const,
onAuthModeChange: vi.fn(),
onExternalTokenChange: vi.fn(),
onLoginChange: vi.fn(),
onRegisterChange: vi.fn(),
onSubmitExternalToken: vi.fn(),
onSubmitLogin: vi.fn(),
onSubmitRegister: vi.fn(),
onOIDCLogin: vi.fn(),
};
describe('AuthPanel', () => {
it('shows only the authentication center entry when OIDC is enabled', () => {
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled />);
expect(html).toContain('使用统一认证中心登录');
expect(html).not.toContain('用户名或邮箱');
expect(html).not.toContain('注册账号');
expect(html).not.toContain('外部 Token');
});
it('keeps the existing local login options when OIDC is disabled', () => {
const html = renderToStaticMarkup(<AuthPanel {...baseProps} oidcEnabled={false} />);
expect(html).toContain('账号登录');
expect(html).toContain('注册账号');
expect(html).toContain('外部 Token');
expect(html).toContain('用户名或邮箱');
});
});
+9 -8
View File
@@ -25,8 +25,6 @@ export function AuthPanel(props: {
oidcEnabled: boolean;
onOIDCLogin: () => void;
}) {
const visibleTabs = props.oidcEnabled ? tabs.filter((tab) => tab.value !== 'register') : tabs;
const visibleMode = props.oidcEnabled && props.authMode === 'register' ? 'login' : props.authMode;
return (
<section className="authShell" aria-label="登录">
<Card className="authCard">
@@ -37,23 +35,26 @@ export function AuthPanel(props: {
</div>
</CardHeader>
<CardContent className="authContent">
{props.oidcEnabled && (
{props.oidcEnabled ? (
<Button type="button" disabled={props.state === 'loading'} onClick={props.onOIDCLogin}>
<LogIn size={15} />
使
</Button>
) : (
<>
<Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} />
{props.authMode === 'login' && <LocalLoginForm {...props} />}
{props.authMode === 'register' && <RegisterFormView {...props} />}
{props.authMode === 'external' && <ExternalTokenForm {...props} />}
</>
)}
<Tabs value={visibleMode} tabs={visibleTabs} onValueChange={props.onAuthModeChange} />
{visibleMode === 'login' && <LoginFormView {...props} />}
{visibleMode === 'register' && <RegisterFormView {...props} />}
{visibleMode === 'external' && <ExternalTokenForm {...props} />}
</CardContent>
</Card>
</section>
);
}
function LoginFormView(props: {
export function LocalLoginForm(props: {
loginForm: LoginForm;
state: LoadState;
onLoginChange: (value: LoginForm) => void;
+14
View File
@@ -122,3 +122,17 @@ describe('OIDC BFF navigation', () => {
});
});
});
describe('login entry routing', () => {
it('uses the authentication center when OIDC login is enabled', async () => {
const { loginEntryAction } = await import('./oidc');
expect(loginEntryAction(true)).toBe('oidc');
});
it('keeps the existing workspace login when OIDC login is disabled', async () => {
const { loginEntryAction } = await import('./oidc');
expect(loginEntryAction(false)).toBe('workspace');
});
});
+6 -2
View File
@@ -45,6 +45,10 @@ export function oidcBrowserSessionEnabled() {
return oidcLoginEnabled();
}
export function loginEntryAction(oidcEnabled: boolean): 'oidc' | 'workspace' {
return oidcEnabled ? 'oidc' : 'workspace';
}
export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCRuntimeConfiguration> {
if (!force && runtimeLoaded) return runtimeConfiguration;
if (!force && runtimeRequest) return runtimeRequest;
@@ -73,8 +77,8 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
return runtimeRequest;
}
export async function startOIDCLogin() {
const configuration = await loadOIDCRuntimeConfiguration(true);
export async function startOIDCLogin(configuration?: OIDCRuntimeConfiguration) {
configuration ??= await loadOIDCRuntimeConfiguration(true);
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { EmergencyLoginPage } from './EmergencyLoginPage';
describe('EmergencyLoginPage', () => {
it('renders only the local account form for emergency access', () => {
const html = renderToStaticMarkup(
<EmergencyLoginPage
loginForm={{ account: '', password: '' }}
state="idle"
onLoginChange={vi.fn()}
onSubmitLogin={vi.fn()}
/>,
);
expect(html).toContain('本地应急登录');
expect(html).toContain('统一认证中心不可用');
expect(html).toContain('用户名或邮箱');
expect(html).toContain('current-password');
expect(html).not.toContain('注册账号');
expect(html).not.toContain('外部 Token');
expect(html).not.toContain('使用统一认证中心登录');
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { FormEvent } from 'react';
import { LocalLoginForm } from '../components/AuthPanel';
import { Card, CardContent, CardDescription, CardHeader } from '../components/ui';
import type { LoadState, LoginForm } from '../types';
export function EmergencyLoginPage(props: {
loginForm: LoginForm;
state: LoadState;
onLoginChange: (value: LoginForm) => void;
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<div className="loginRequiredPage">
<div className="loginRequiredCopy">
<p className="eyebrow">Emergency Access</p>
<h1></h1>
<p>使</p>
</div>
<section className="authShell" aria-label="本地应急登录">
<Card className="authCard">
<CardHeader>
<div>
<p className="eyebrow">Gateway Identity</p>
<h2 className="shCardTitle">使</h2>
<CardDescription> Manager Admin </CardDescription>
</div>
</CardHeader>
<CardContent className="authContent">
<LocalLoginForm {...props} />
</CardContent>
</Card>
</section>
</div>
);
}
+6
View File
@@ -36,6 +36,12 @@ describe('API documentation routes', () => {
});
});
describe('login route', () => {
it('maps /login to the dedicated emergency login page', () => {
expect(parseAppRoute('/login').activePage).toBe('login');
});
});
describe('admin task routes', () => {
it('round-trips the complete admin task query through the URL', () => {
const query = {
+2
View File
@@ -87,6 +87,7 @@ export function parseAppRoute(input = currentLocationPath()): AppRouteState {
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
const adminTaskQuery = parseAdminTaskQuery(url.searchParams);
if (path === '/') return { ...defaultRouteState };
if (path === '/login') return { ...defaultRouteState, activePage: 'login', workspaceTaskQuery, adminTaskQuery };
if (path.startsWith('/playground')) {
return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path), workspaceTaskQuery, adminTaskQuery };
}
@@ -114,6 +115,7 @@ export function pathForPage(page: PageKey, route: AppRouteState): string {
if (page === 'workspace') return pathForWorkspaceSection(route.workspaceSection);
if (page === 'admin') return pathForAdminSection(route.adminSection);
if (page === 'docs') return pathForApiDocSection(route.apiDocSection);
if (page === 'login') return '/login';
return '/';
}
+1 -1
View File
@@ -10,7 +10,7 @@ export type TaskKind =
| 'images.edits'
| 'videos.generations'
| 'tasks.retrieve';
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs' | 'login';
export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
export type ApiDocSection =