feat(web): 增加单点登录分流与本地应急入口
开启统一认证后,正常登录入口直接跳转认证中心,并将本地账号入口收敛到 /login 应急页;未开启统一认证时继续保留工作台内原有登录方式。 影响范围仅限 Web 登录路由、认证入口展示和相关测试,不修改认证接口或 OpenAPI。风险主要在运行时身份状态判断,已通过 141 项前端测试、生产构建、无缓存类型检查及浏览器分支验证。
This commit is contained in:
+33
-3
@@ -131,6 +131,7 @@ import {
|
|||||||
} from './lib/oidc-browser-session';
|
} from './lib/oidc-browser-session';
|
||||||
import {
|
import {
|
||||||
consumeOIDCCallbackError,
|
consumeOIDCCallbackError,
|
||||||
|
loginEntryAction,
|
||||||
loadOIDCRuntimeConfiguration,
|
loadOIDCRuntimeConfiguration,
|
||||||
startOIDCLogin,
|
startOIDCLogin,
|
||||||
startOIDCLogout,
|
startOIDCLogout,
|
||||||
@@ -138,6 +139,7 @@ import {
|
|||||||
import { runTask, type RunTaskOptions } from './lib/run-task';
|
import { runTask, type RunTaskOptions } from './lib/run-task';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||||
|
import { EmergencyLoginPage } from './pages/EmergencyLoginPage';
|
||||||
import { HomePage } from './pages/HomePage';
|
import { HomePage } from './pages/HomePage';
|
||||||
import { ModelsPage } from './pages/ModelsPage';
|
import { ModelsPage } from './pages/ModelsPage';
|
||||||
import { PlaygroundPage } from './pages/PlaygroundPage';
|
import { PlaygroundPage } from './pages/PlaygroundPage';
|
||||||
@@ -686,6 +688,10 @@ export function App() {
|
|||||||
const response = await request();
|
const response = await request();
|
||||||
persistAccessToken(response.accessToken);
|
persistAccessToken(response.accessToken);
|
||||||
setToken(response.accessToken);
|
setToken(response.accessToken);
|
||||||
|
if (activePage === 'login') {
|
||||||
|
navigatePath(pathForWorkspaceSection('overview'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
await ensureRouteData(response.accessToken, true);
|
await ensureRouteData(response.accessToken, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState('error');
|
setState('error');
|
||||||
@@ -1308,9 +1314,25 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLogin() {
|
async function showLogin() {
|
||||||
setAuthMode('login');
|
setState('loading');
|
||||||
navigatePath(pathForWorkspaceSection('overview'));
|
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 {
|
function currentRouteState(): AppRouteState {
|
||||||
@@ -1411,6 +1433,14 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePage === 'models' && <ModelsPage data={data} />}
|
{activePage === 'models' && <ModelsPage data={data} />}
|
||||||
|
{activePage === 'login' && (
|
||||||
|
<EmergencyLoginPage
|
||||||
|
loginForm={loginForm}
|
||||||
|
state={state}
|
||||||
|
onLoginChange={setLoginForm}
|
||||||
|
onSubmitLogin={submitLogin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activePage === 'workspace' && (
|
{activePage === 'workspace' && (
|
||||||
isAuthenticated ? (
|
isAuthenticated ? (
|
||||||
<WorkspacePage
|
<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;
|
oidcEnabled: boolean;
|
||||||
onOIDCLogin: () => void;
|
onOIDCLogin: () => void;
|
||||||
}) {
|
}) {
|
||||||
const visibleTabs = props.oidcEnabled ? tabs.filter((tab) => tab.value !== 'register') : tabs;
|
|
||||||
const visibleMode = props.oidcEnabled && props.authMode === 'register' ? 'login' : props.authMode;
|
|
||||||
return (
|
return (
|
||||||
<section className="authShell" aria-label="登录">
|
<section className="authShell" aria-label="登录">
|
||||||
<Card className="authCard">
|
<Card className="authCard">
|
||||||
@@ -37,23 +35,26 @@ export function AuthPanel(props: {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="authContent">
|
<CardContent className="authContent">
|
||||||
{props.oidcEnabled && (
|
{props.oidcEnabled ? (
|
||||||
<Button type="button" disabled={props.state === 'loading'} onClick={props.onOIDCLogin}>
|
<Button type="button" disabled={props.state === 'loading'} onClick={props.onOIDCLogin}>
|
||||||
<LogIn size={15} />
|
<LogIn size={15} />
|
||||||
使用统一认证中心登录
|
使用统一认证中心登录
|
||||||
</Button>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginFormView(props: {
|
export function LocalLoginForm(props: {
|
||||||
loginForm: LoginForm;
|
loginForm: LoginForm;
|
||||||
state: LoadState;
|
state: LoadState;
|
||||||
onLoginChange: (value: LoginForm) => void;
|
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();
|
return oidcLoginEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loginEntryAction(oidcEnabled: boolean): 'oidc' | 'workspace' {
|
||||||
|
return oidcEnabled ? 'oidc' : 'workspace';
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCRuntimeConfiguration> {
|
export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCRuntimeConfiguration> {
|
||||||
if (!force && runtimeLoaded) return runtimeConfiguration;
|
if (!force && runtimeLoaded) return runtimeConfiguration;
|
||||||
if (!force && runtimeRequest) return runtimeRequest;
|
if (!force && runtimeRequest) return runtimeRequest;
|
||||||
@@ -73,8 +77,8 @@ export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCR
|
|||||||
return runtimeRequest;
|
return runtimeRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOIDCLogin() {
|
export async function startOIDCLogin(configuration?: OIDCRuntimeConfiguration) {
|
||||||
const configuration = await loadOIDCRuntimeConfiguration(true);
|
configuration ??= await loadOIDCRuntimeConfiguration(true);
|
||||||
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
|
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
|
||||||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||||
const loginURL = new URL(identityEndpointURL(configuration.loginUrl), window.location.origin);
|
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', () => {
|
describe('admin task routes', () => {
|
||||||
it('round-trips the complete admin task query through the URL', () => {
|
it('round-trips the complete admin task query through the URL', () => {
|
||||||
const query = {
|
const query = {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function parseAppRoute(input = currentLocationPath()): AppRouteState {
|
|||||||
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
|
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
|
||||||
const adminTaskQuery = parseAdminTaskQuery(url.searchParams);
|
const adminTaskQuery = parseAdminTaskQuery(url.searchParams);
|
||||||
if (path === '/') return { ...defaultRouteState };
|
if (path === '/') return { ...defaultRouteState };
|
||||||
|
if (path === '/login') return { ...defaultRouteState, activePage: 'login', workspaceTaskQuery, adminTaskQuery };
|
||||||
if (path.startsWith('/playground')) {
|
if (path.startsWith('/playground')) {
|
||||||
return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path), workspaceTaskQuery, adminTaskQuery };
|
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 === 'workspace') return pathForWorkspaceSection(route.workspaceSection);
|
||||||
if (page === 'admin') return pathForAdminSection(route.adminSection);
|
if (page === 'admin') return pathForAdminSection(route.adminSection);
|
||||||
if (page === 'docs') return pathForApiDocSection(route.apiDocSection);
|
if (page === 'docs') return pathForApiDocSection(route.apiDocSection);
|
||||||
|
if (page === 'login') return '/login';
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type TaskKind =
|
|||||||
| 'images.edits'
|
| 'images.edits'
|
||||||
| 'videos.generations'
|
| 'videos.generations'
|
||||||
| 'tasks.retrieve';
|
| '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 PlaygroundMode = 'chat' | 'image' | 'video';
|
||||||
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
|
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
|
||||||
export type ApiDocSection =
|
export type ApiDocSection =
|
||||||
|
|||||||
Reference in New Issue
Block a user