feat(web): 增加单点登录分流与本地应急入口
开启统一认证后,正常登录入口直接跳转认证中心,并将本地账号入口收敛到 /login 应急页;未开启统一认证时继续保留工作台内原有登录方式。 影响范围仅限 Web 登录路由、认证入口展示和相关测试,不修改认证接口或 OpenAPI。风险主要在运行时身份状态判断,已通过 141 项前端测试、生产构建、无缓存类型检查及浏览器分支验证。
This commit is contained in:
+33
-3
@@ -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('用户名或邮箱');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('使用统一认证中心登录');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user