diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9b9f99d..dd76921 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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' && } + {activePage === 'login' && ( + + )} {activePage === 'workspace' && ( isAuthenticated ? ( { + it('shows only the authentication center entry when OIDC is enabled', () => { + const html = renderToStaticMarkup(); + + 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(); + + expect(html).toContain('账号登录'); + expect(html).toContain('注册账号'); + expect(html).toContain('外部 Token'); + expect(html).toContain('用户名或邮箱'); + }); +}); diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx index dececf9..ce01ca2 100644 --- a/apps/web/src/components/AuthPanel.tsx +++ b/apps/web/src/components/AuthPanel.tsx @@ -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 (
@@ -37,23 +35,26 @@ export function AuthPanel(props: { - {props.oidcEnabled && ( + {props.oidcEnabled ? ( + ) : ( + <> + + {props.authMode === 'login' && } + {props.authMode === 'register' && } + {props.authMode === 'external' && } + )} - - {visibleMode === 'login' && } - {visibleMode === 'register' && } - {visibleMode === 'external' && }
); } -function LoginFormView(props: { +export function LocalLoginForm(props: { loginForm: LoginForm; state: LoadState; onLoginChange: (value: LoginForm) => void; diff --git a/apps/web/src/lib/oidc.test.ts b/apps/web/src/lib/oidc.test.ts index 78810c7..d36bb9d 100644 --- a/apps/web/src/lib/oidc.test.ts +++ b/apps/web/src/lib/oidc.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/lib/oidc.ts b/apps/web/src/lib/oidc.ts index 40a0a89..81715b5 100644 --- a/apps/web/src/lib/oidc.ts +++ b/apps/web/src/lib/oidc.ts @@ -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 { if (!force && runtimeLoaded) return runtimeConfiguration; if (!force && runtimeRequest) return runtimeRequest; @@ -73,8 +77,8 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise { + it('renders only the local account form for emergency access', () => { + const html = renderToStaticMarkup( + , + ); + + 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('使用统一认证中心登录'); + }); +}); diff --git a/apps/web/src/pages/EmergencyLoginPage.tsx b/apps/web/src/pages/EmergencyLoginPage.tsx new file mode 100644 index 0000000..4b2dd33 --- /dev/null +++ b/apps/web/src/pages/EmergencyLoginPage.tsx @@ -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) => void; +}) { + return ( +
+
+

Emergency Access

+

本地应急登录

+

仅在统一认证中心不可用时使用此入口,登录后可检查并恢复统一认证配置。

+
+
+ + +
+

Gateway Identity

+

使用本地管理员账号

+ 统一认证启用时,仅本地 Manager 或 Admin 应急账号可以登录。 +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/routing.test.ts b/apps/web/src/routing.test.ts index 1d10d56..02827ad 100644 --- a/apps/web/src/routing.test.ts +++ b/apps/web/src/routing.test.ts @@ -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 = { diff --git a/apps/web/src/routing.ts b/apps/web/src/routing.ts index 1417b9e..2fb5e67 100644 --- a/apps/web/src/routing.ts +++ b/apps/web/src/routing.ts @@ -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 '/'; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 1cc4e35..657d396 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -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 =