feat(web): 增加统一认证接入与运维页面

系统设置新增统一认证配对、验证、激活、重验证、回滚与禁用流程,安全事件改为统一能力状态。前端登录入口运行时读取公开配置,不再依赖 VITE_OIDC 构建变量。\n\n验证:web 31 项测试;web typecheck;pnpm lint;web production build
This commit is contained in:
2026-07-17 12:18:26 +08:00
parent b175d545ff
commit e0a356ee0c
11 changed files with 675 additions and 191 deletions
@@ -1,10 +1,11 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2 } from 'lucide-react';
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity';
type ClientCustomizationForm = {
clientEnglishName: string;
@@ -54,21 +55,16 @@ const resultUploadPolicyOptions = [
];
export function SystemSettingsPanel(props: {
token: string;
channels: FileStorageChannel[];
clientCustomizationSettings: ClientCustomizationSettings | null;
message: string;
settings: FileStorageSettings | null;
securityEventConnection: SecurityEventConnectionResponse | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
onRotateSecurityEventsCredential: () => Promise<void>;
onVerifySecurityEvents: () => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
@@ -78,8 +74,6 @@ export function SystemSettingsPanel(props: {
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
const [disconnectOpen, setDisconnectOpen] = useState(false);
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
@@ -89,13 +83,6 @@ export function SystemSettingsPanel(props: {
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
}, [props.clientCustomizationSettings]);
useEffect(() => {
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
return () => window.clearInterval(timer);
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
function openCreateDialog() {
setEditingChannel(null);
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
@@ -177,7 +164,7 @@ export function SystemSettingsPanel(props: {
tabs={[
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
{ value: 'identity', label: '统一认证', icon: <ShieldCheck size={15} /> },
]}
onValueChange={setActiveTab}
/>
@@ -291,19 +278,7 @@ export function SystemSettingsPanel(props: {
</section>
)}
{activeTab === 'securityEvents' && (
<SecurityEventConnectionPanel
connection={props.securityEventConnection}
issuer={transmitterIssuer}
loading={props.state === 'loading'}
onIssuerChange={setTransmitterIssuer}
onConnect={props.onConnectSecurityEvents}
onDisconnect={() => setDisconnectOpen(true)}
onRefresh={props.onRefreshSecurityEvents}
onRotate={props.onRotateSecurityEventsCredential}
onVerify={props.onVerifySecurityEvents}
/>
)}
{activeTab === 'identity' && <UnifiedIdentityPanel token={props.token} />}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
@@ -393,137 +368,10 @@ export function SystemSettingsPanel(props: {
onCancel={() => setPendingDeleteChannel(null)}
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
/>
<ConfirmDialog
confirmLabel="断开连接"
description="系统会先删除远端 Stream,并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
loading={props.state === 'loading'}
open={disconnectOpen}
title="确认断开认证中心安全事件连接?"
onCancel={() => setDisconnectOpen(false)}
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
/>
</div>
);
}
function SecurityEventConnectionPanel(props: {
connection: SecurityEventConnectionResponse | null;
issuer: string;
loading: boolean;
onIssuerChange(value: string): void;
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
onDisconnect(): void;
onRefresh(): Promise<void>;
onRotate(): Promise<void>;
onVerify(): Promise<void>;
}) {
const connection = props.connection?.connection;
const prerequisites = props.connection?.prerequisites;
const [managementClientId, setManagementClientId] = useState('');
const [managementClientSecret, setManagementClientSecret] = useState('');
useEffect(() => {
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
}, [managementClientId, prerequisites?.managementClientId]);
const missing = [
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
].filter(Boolean);
return (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong>SSF / CAEP </strong>
<span>Gateway Push Bearer RFC 7662 Client</span>
</div>
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
</Badge>
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
<RefreshCw size={14} />
</Button>
</div>
{!connection && (
<Card>
<CardContent className="pageStack">
<div>
<strong></strong>
<p className="mutedText"> Application Gateway </p>
</div>
{missing.length > 0 && <div className="formMessage">{missing.join('')}</div>}
<Label>
SSF Transmitter Issuer
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
<small>EndpointScopePush Bearer Stream </small>
</Label>
<Label>
Machine Client ID
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
</Label>
<Label>
Machine Client Secret
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
<small>Gateway SecretStore</small>
</Label>
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
<Link2 size={15} />
</Button>
</CardContent>
</Card>
)}
{connection && (
<Card>
<CardContent className="pageStack">
<div className="fileStorageMeta">
<span> Client: {connection.managementClientId}</span>
<span>Receiver Endpoint: {connection.receiverEndpoint}</span>
<span>Issuer: {connection.transmitterIssuer}</span>
<span>Audience: {connection.audience ?? '正在获取'}</span>
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
<span>: {securityEventHealthLabel(connection.healthMode)}</span>
<span> Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
{props.connection?.traceId && <span> Trace ID: {props.connection.traceId}</span>}
{props.connection?.auditId && <span> Audit ID: {props.connection.auditId}</span>}
{connection.lastErrorCategory && <span>: {connection.lastErrorCategory}</span>}
</div>
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
<div className="formMessage"> Stream</div>
<Label>
Machine Client ID
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
</Label>
<Label>
Machine Client Secret
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
</Label>
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
<Link2 size={15} />
</Button>
</div>}
<div className="fileStorageToolbar">
{connection.lifecycleStatus === 'error' && (
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} /></Button>
)}
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={() => void props.onVerify()}><RefreshCw size={14} /></Button>
<Button type="button" variant="outline" size="sm" disabled={props.loading || connection.lifecycleStatus === 'rotating'} onClick={() => void props.onRotate()}><RotateCcw size={14} /></Button>
<Button type="button" variant="destructive" size="sm" disabled={props.loading || connection.lifecycleStatus === 'retiring'} onClick={props.onDisconnect}><Unplug size={14} /></Button>
</div>
</CardContent>
</Card>
)}
</section>
);
}
function securityEventLifecycleLabel(status?: string) {
return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record<string, string>)[status ?? ''] ?? '未连接';
}
function securityEventHealthLabel(mode: string) {
return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record<string, string>)[mode] ?? mode;
}
function defaultClientCustomizationForm(): ClientCustomizationForm {
return {
clientEnglishName: 'EasyAI AI Gateway',
@@ -0,0 +1,17 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
describe('UnifiedIdentityPanel', () => {
it('asks only for the standard pairing inputs and does not expose a machine secret field', () => {
const html = renderToStaticMarkup(<UnifiedIdentityPanel token="manager-token" />);
expect(html).toContain('Auth Center 地址');
expect(html).toContain('一次性接入码');
expect(html).toContain('Gateway API 公网地址');
expect(html).toContain('Gateway Web 地址');
expect(html).toContain('Gateway 本地租户映射');
expect(html).not.toContain('Machine Client Secret');
expect(html).not.toContain('SSF Transmitter Issuer');
});
});
@@ -0,0 +1,310 @@
import { useEffect, useState, type FormEvent } from 'react';
import { CheckCircle2, Link2, RefreshCw, RotateCcw, ShieldCheck, Unplug } from 'lucide-react';
import type {
IdentityConfigurationRevision,
IdentityConfigurationView,
IdentityPairingInput,
} from '@easyai-ai-gateway/contracts';
import {
activateIdentityRevision,
disableIdentityConfiguration,
getIdentityConfiguration,
rollbackIdentityRevision,
startIdentityPairing,
updateIdentityRevisionPolicy,
validateIdentityRevision,
} from '../../api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui';
const terminalPairingStatuses = new Set(['completed', 'failed', 'expired']);
export function UnifiedIdentityPanel(props: { token: string }) {
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
const [showPairingForm, setShowPairingForm] = useState(false);
const [localTenantKey, setLocalTenantKey] = useState('default');
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [confirmAction, setConfirmAction] = useState<'disable' | 'rollback' | null>(null);
async function refresh() {
const next = await getIdentityConfiguration(props.token);
setConfiguration(next);
if (next.draft) {
setLocalTenantKey(next.draft.localTenantKey);
setLegacyJwtEnabled(next.draft.legacyJwtEnabled);
}
return next;
}
useEffect(() => {
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置加载失败')));
}, [props.token]);
useEffect(() => {
const status = configuration?.pairing?.status;
if (!status || terminalPairingStatuses.has(status)) return undefined;
const timer = window.setInterval(() => {
void refresh().catch(() => undefined);
}, 2000);
return () => window.clearInterval(timer);
}, [configuration?.pairing?.status, props.token]);
async function run(action: () => Promise<unknown>, success: string, runtimeChanged = false) {
setLoading(true);
setError('');
setMessage('');
try {
await action();
await refresh();
setMessage(success);
if (runtimeChanged) window.dispatchEvent(new Event('identity-runtime-changed'));
return true;
} catch (caught) {
setError(errorMessage(caught, '统一认证操作失败'));
return false;
} finally {
setLoading(false);
}
}
async function submitPairing(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const input = { ...form };
setForm((current) => ({ ...current, onboardingCode: '' }));
await run(async () => {
await startIdentityPairing(props.token, input);
setShowPairingForm(false);
}, '接入码已领取,正在由 Gateway 后端准备并保存配置。');
}
async function saveDraftPolicy() {
const draft = configuration?.draft;
if (!draft) return;
await run(() => updateIdentityRevisionPolicy(props.token, draft.id, draft.version, {
localTenantKey: localTenantKey.trim(),
legacyJwtEnabled,
}), 'Gateway 本地认证策略已保存。');
}
const active = configuration?.active;
const draft = configuration?.draft;
const previous = configuration?.previous;
const pairing = configuration?.pairing;
const runtime = configuration?.runtime;
const shouldShowPairing = showPairingForm || (!active && !pairing);
return (
<section className="pageStack">
<div className="fileStorageToolbar">
<div>
<strong></strong>
<span>使Gateway OIDCIntrospection SSF</span>
</div>
<div className="fileStorageToolbar">
<Badge variant={active ? 'success' : 'outline'}>{active ? '已启用' : '未启用'}</Badge>
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => void refresh()}><RefreshCw size={14} /></Button>
</div>
</div>
{(message || error) && <div className="formMessage">{error || message}</div>}
{active && runtime && (
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">Revision {shortID(active.id)} · {active.issuer}</p>
</div>
<Badge variant="success"><CheckCircle2 size={13} />Active</Badge>
</CardHeader>
<CardContent className="pageStack">
<div className="identityHealthGrid">
<IdentityHealth label="OIDC 登录" value={runtime.login} />
<IdentityHealth label="JIT 用户" value={runtime.jit} />
<IdentityHealth label="Token Introspection" value={runtime.tokenIntrospection} />
<IdentityHealth label="SSF 会话撤销" value={runtime.sessionRevocation} />
</div>
<RevisionDetails revision={active} />
<div className="fileStorageToolbar">
<Button type="button" variant="outline" size="sm" disabled={loading}
onClick={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)}>
<RefreshCw size={14} />
</Button>
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => setShowPairingForm(true)}>
<RotateCcw size={14} /> /
</Button>
{previous && (
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => setConfirmAction('rollback')}>
<RotateCcw size={14} />
</Button>
)}
<Button type="button" variant="destructive" size="sm" disabled={loading} onClick={() => setConfirmAction('disable')}>
<Unplug size={14} />
</Button>
</div>
</CardContent>
</Card>
)}
{pairing && !terminalPairingStatuses.has(pairing.status) && (
<Card>
<CardContent className="pageStack">
<div className="fileStorageToolbar">
<div><strong></strong><span>{pairingStatusLabel(pairing.status)}</span></div>
<Badge variant="secondary">{pairing.status}</Badge>
</div>
<div className="fileStorageMeta">
<span>Exchange: {shortID(pairing.remoteExchangeId)}</span>
<span>: {new Date(pairing.expiresAt).toLocaleString()}</span>
{pairing.lastTraceId && <span>Trace ID: {pairing.lastTraceId}</span>}
{pairing.authCenterAuditId && <span>Auth Center Audit ID: {pairing.authCenterAuditId}</span>}
</div>
</CardContent>
</Card>
)}
{draft && pairing?.status === 'completed' && (
<Card>
<CardHeader>
<div><CardTitle></CardTitle><p className="mutedText"> SecretStore</p></div>
<Badge variant={draft.state === 'failed' ? 'destructive' : 'secondary'}>{draft.state}</Badge>
</CardHeader>
<CardContent className="pageStack">
<RevisionDetails revision={draft} />
{draft.state === 'draft' && (
<div className="formGrid two">
<Label>Gateway <Input value={localTenantKey} onChange={(event) => setLocalTenantKey(event.target.value)} /></Label>
<label className="identityCheckbox">
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
<span><strong> Legacy JWT</strong><small></small></span>
</label>
</div>
)}
<div className="fileStorageToolbar">
{draft.state === 'draft' && <>
<Button type="button" variant="outline" disabled={loading || !localTenantKey.trim()} onClick={() => void saveDraftPolicy()}></Button>
<Button type="button" disabled={loading || !localTenantKey.trim()}
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
<ShieldCheck size={14} />
</Button>
</>}
{draft.state === 'validated' && (
<Button type="button" disabled={loading}
onClick={() => void run(() => activateIdentityRevision(props.token, draft.id, draft.version), '统一认证已热切换启用,无需重启。', true)}>
<CheckCircle2 size={14} />
</Button>
)}
{draft.state === 'failed' && <span className="formMessage">{draft.lastErrorCategory || 'validation_failed'}使</span>}
</div>
</CardContent>
</Card>
)}
{pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && (
<div className="fileStorageToolbar">
<span className="formMessage">{pairing.status === 'expired' ? '已过期' : '失败'}{pairing.lastErrorCategory || pairing.status}</span>
<Button type="button" variant="outline" onClick={() => setShowPairingForm(true)}>使</Button>
</div>
)}
{shouldShowPairing && (
<Card>
<CardHeader>
<div><CardTitle>{active ? '重新配对认证中心' : '接入认证中心'}</CardTitle><p className="mutedText"> Application Manifest </p></div>
<Badge variant="outline"> 10 </Badge>
</CardHeader>
<CardContent>
<form className="formGrid two" onSubmit={(event) => void submitPairing(event)}>
<Label>Auth Center <Input required value={form.authCenterUrl} onChange={(event) => setForm({ ...form, authCenterUrl: event.target.value })} placeholder="https://auth.example.com" /></Label>
<Label><Input required type="password" autoComplete="one-time-code" value={form.onboardingCode} onChange={(event) => setForm({ ...form, onboardingCode: event.target.value })} placeholder="仅本次提交,不写入日志或数据库" /></Label>
<Label>Gateway API <Input required value={form.publicBaseUrl} onChange={(event) => setForm({ ...form, publicBaseUrl: event.target.value })} placeholder="https://api.gateway.example.com" /></Label>
<Label>Gateway Web <Input required value={form.webBaseUrl} onChange={(event) => setForm({ ...form, webBaseUrl: event.target.value })} placeholder="https://gateway.example.com" /></Label>
<Label>Gateway <Input required value={form.localTenantKey} onChange={(event) => setForm({ ...form, localTenantKey: event.target.value })} placeholder="default" /></Label>
<label className="identityCheckbox">
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
<span><strong> Legacy JWT</strong><small></small></span>
</label>
<div className="fileStorageToolbar spanTwo">
<Button type="submit" disabled={loading || !form.onboardingCode.trim()}><Link2 size={15} /></Button>
{active && <Button type="button" variant="outline" onClick={() => setShowPairingForm(false)}></Button>}
</div>
</form>
</CardContent>
</Card>
)}
<ConfirmDialog
confirmLabel={confirmAction === 'rollback' ? '确认回滚' : '确认禁用'}
description={confirmAction === 'rollback'
? '系统会重新验证上一版本并热切换;现有统一认证会话将被清理,用户需要重新登录。'
: '统一认证将关闭并清理现有 BFF Session;本地应急管理员登录继续可用。'}
loading={loading}
open={confirmAction !== null}
title={confirmAction === 'rollback' ? '回滚统一认证配置?' : '禁用统一认证?'}
onCancel={() => setConfirmAction(null)}
onConfirm={async () => {
let succeeded = false;
if (confirmAction === 'rollback' && previous) {
succeeded = await run(() => rollbackIdentityRevision(props.token, previous.id, previous.version), '统一认证已回滚,用户需要重新登录。', true);
} else if (confirmAction === 'disable' && active) {
succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true);
}
if (succeeded) setConfirmAction(null);
}}
/>
</section>
);
}
function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) {
return (
<div className="fileStorageMeta">
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
<span>Audience: {revision.audience || '等待 Manifest'}</span>
<span>Tenant: {revision.tenantId || '等待 Manifest'}</span>
<span>: {revision.localTenantKey}</span>
<span> Client: {revision.browserClientId || '未启用'}</span>
<span> Client: {revision.machineClientId || '未启用'}</span>
<span>: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
<span>Scope: {revision.scopes.join(', ') || '无'}</span>
{revision.lastTraceId && <span>Trace ID: {revision.lastTraceId}</span>}
{revision.lastAuditId && <span>Audit ID: {revision.lastAuditId}</span>}
</div>
);
}
function IdentityHealth({ label, value }: { label: string; value: string }) {
const healthy = value === 'healthy' || value === 'push_healthy';
return <div className="identityHealthItem"><span>{label}</span><Badge variant={healthy ? 'success' : value === 'disabled' ? 'outline' : 'secondary'}>{healthLabel(value)}</Badge></div>;
}
function defaultPairingInput(): IdentityPairingInput {
const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin;
return {
authCenterUrl: 'https://auth.51easyai.com',
onboardingCode: '',
publicBaseUrl: (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''),
webBaseUrl,
localTenantKey: 'default',
legacyJwtEnabled: false,
};
}
function pairingStatusLabel(status: string) {
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
}
function capabilityLabel(value: string) {
return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record<string, string>)[value] ?? value;
}
function healthLabel(value: string) {
return ({ healthy: '健康', disabled: '未启用', push_healthy: 'Push 健康', introspection_fallback: 'Introspection 降级', bootstrap: '启动保护' } as Record<string, string>)[value] ?? value;
}
function shortID(value: string) { return value ? `${value.slice(0, 8)}` : '-'; }
function errorMessage(caught: unknown, fallback: string) { return caught instanceof Error ? caught.message : fallback; }