easyai-ai-gateway/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx
chengcheng b179162330 fix(web): 完善统一认证恢复与重配交互
为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。

验证:pnpm test;pnpm lint;pnpm build。
2026-07-17 18:31:38 +08:00

489 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState, type FormEvent } from 'react';
import { CheckCircle2, Link2, RefreshCw, ShieldCheck, Unplug } from 'lucide-react';
import type {
IdentityConfigurationRevision,
IdentityConfigurationView,
IdentityPairingInput,
} from '@easyai-ai-gateway/contracts';
import {
activateIdentityRevision,
cancelIdentityPairing,
disableIdentityConfiguration,
GatewayApiError,
getIdentityConfiguration,
retireIdentityPairingSecurityEventConflict,
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', 'cancelled']);
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 [pollError, setPollError] = useState('');
const [confirmAction, setConfirmAction] = useState<'disable' | 'cancelPairing' | 'retireSecurityEvent' | null>(null);
const operationInProgress = useRef(false);
async function refresh() {
const next = await getIdentityConfiguration(props.token);
setConfiguration(next);
setPollError('');
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;
const cleanupPending = status === 'cancelled' && configuration?.pairing?.cleanupStatus === 'pending';
if (!status || (terminalPairingStatuses.has(status) && !cleanupPending)) return undefined;
const timer = window.setInterval(() => {
void refresh().catch(() => setPollError('统一认证状态自动刷新失败,请点击“刷新”重试。'));
}, 2000);
return () => window.clearInterval(timer);
}, [configuration?.pairing?.cleanupStatus, configuration?.pairing?.status, props.token]);
async function run(action: () => Promise<unknown>, success: string, runtimeChanged = false) {
if (operationInProgress.current) return false;
operationInProgress.current = true;
setLoading(true);
setError('');
setMessage('');
try {
return await executeIdentityOperation({
action,
onRuntimeChanged: () => window.dispatchEvent(new Event('identity-runtime-changed')),
onSuccess: setMessage,
refresh,
runtimeChanged,
success,
});
} catch (caught) {
try {
await refresh();
} catch {
// Preserve the original operation error; the manual refresh remains available.
}
setError(errorMessage(caught, '统一认证操作失败'));
if (isStaleIdentityOperation(caught)) setConfirmAction(null);
return false;
} finally {
operationInProgress.current = false;
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 本地认证策略已保存。');
}
async function retireConflictingSecurityEventConnection() {
const pairing = configuration?.pairing;
if (!pairing) return false;
return run(async () => {
await retireIdentityPairingSecurityEventConflict(props.token, pairing.id, pairing.version);
}, '现有安全事件连接已进入安全退役;退役完成后本次配对会自动继续。');
}
const active = configuration?.active;
const draft = configuration?.draft;
const previous = configuration?.previous;
const pairing = configuration?.pairing;
const runtime = configuration?.runtime;
const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending';
const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed';
const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted);
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={() => {
setError('');
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置刷新失败')));
}}><RefreshCw size={14} /></Button>
</div>
</div>
{(message || error || pollError) && <div className={`formMessage${error || pollError ? ' error' : ''}`} role={error || pollError ? 'alert' : 'status'} aria-live="polite">{error || pollError || 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} />
<IdentityRuntimeActions
loading={loading}
previous={previous ?? null}
onDisable={() => setConfirmAction('disable')}
onValidate={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)}
/>
</CardContent>
</Card>
)}
{pairing && (!terminalPairingStatuses.has(pairing.status) || cleanupPending) && (
<PairingProgressCard
pairing={pairing}
loading={loading}
onCancel={() => setConfirmAction('cancelPairing')}
onResolveConnectionConflict={() => setConfirmAction('retireSecurityEvent')}
/>
)}
{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>}
<Button type="button" variant="outline" disabled={loading} onClick={() => setConfirmAction('cancelPairing')}>
稿
</Button>
</div>
</CardContent>
</Card>
)}
{pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && !cleanupPending && (
<div className="fileStorageToolbar">
<span className="formMessage" role="status">
{cleanupCompleted ? '旧配对已放弃,临时凭据与本次创建的 SSF 连接已清理。' : `配对${pairing.status === 'expired' ? '已过期' : '失败'}${pairingFailureMessage(pairing.lastErrorCategory)}`}
</span>
{cleanupCompleted
? <Button type="button" variant="outline" onClick={() => setShowPairingForm(true)}></Button>
: <Button type="button" variant="outline" disabled={loading} onClick={() => setConfirmAction('cancelPairing')}></Button>}
</div>
)}
{shouldShowPairing && (
<Card>
<CardHeader>
<div><CardTitle></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>
</div>
</form>
</CardContent>
</Card>
)}
<ConfirmDialog
confirmLabel={confirmAction === 'cancelPairing'
? '确认放弃'
: confirmAction === 'retireSecurityEvent'
? '确认安全退役'
: '确认禁用'}
description={confirmAction === 'cancelPairing'
? '系统会封存当前未激活草稿,并异步销毁本地临时凭据和仅由本次配对创建的 SSF 连接。已经完成的远端 Exchange 不会被伪装为已撤销;使用新接入码时会轮换机器凭据。'
: confirmAction === 'retireSecurityEvent'
? '系统将安全退役当前 SSF Stream。退役期间现有统一认证会自动使用 Introspection 降级保护;退役完成后,本次配对会自动创建新的标准连接。'
: '统一认证将关闭并清理现有 BFF Session本地应急管理员登录继续可用。'}
loading={loading}
open={confirmAction !== null}
title={confirmAction === 'cancelPairing'
? '放弃当前本地配对?'
: confirmAction === 'retireSecurityEvent'
? '安全退役现有 SSF 连接?'
: '禁用统一认证?'}
onCancel={() => setConfirmAction(null)}
onConfirm={async () => {
let succeeded = false;
if (confirmAction === 'disable' && active) {
succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true);
} else if (confirmAction === 'cancelPairing' && pairing) {
succeeded = await run(() => cancelIdentityPairing(props.token, pairing.id, pairing.version), '已放弃当前草稿,后台正在安全清理临时资源。');
} else if (confirmAction === 'retireSecurityEvent') {
succeeded = await retireConflictingSecurityEventConnection();
}
if (succeeded) setConfirmAction(null);
}}
/>
</section>
);
}
export function PairingProgressCard({ pairing, loading, onCancel, onResolveConnectionConflict }: {
pairing: NonNullable<IdentityConfigurationView['pairing']>;
loading: boolean;
onCancel: () => void;
onResolveConnectionConflict?: () => void;
}) {
const cleaning = pairing.status === 'cancelled' && pairing.cleanupStatus === 'pending';
const connectionConflict = pairing.lastErrorCategory === 'security_event_connection_conflict';
const unsafeCredentialHandoff = pairing.lastErrorCategory === 'security_event_credential_handoff_unsafe';
return (
<Card>
<CardContent className="pageStack" aria-busy="true">
<div className="fileStorageToolbar" role="status" aria-live="polite">
<div><strong>{cleaning ? '正在清理已放弃的配对' : '正在配对认证中心'}</strong><span>{pairingStatusLabel(pairing.status, pairing.cleanupStatus)}</span></div>
<Badge variant="secondary">{cleaning ? 'cleanup_pending' : pairing.status}</Badge>
</div>
{pairing.lastErrorCategory && (
<div className="formMessage error" role="alert">
{pairingFailureMessage(pairing.lastErrorCategory)}{safePairingCategory(pairing.lastErrorCategory)}{cleaning
? '后台会继续安全清理。'
: connectionConflict
? '此问题不会通过自动重试恢复;请安全退役现有连接,或放弃本次配对。'
: unsafeCredentialHandoff
? '此问题不会自动恢复;请先在原配置下断开旧连接,或放弃本次配对。'
: '后台正在自动重试;也可以放弃本次配对后重新配置。'}
</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>
{!cleaning && <div className="fileStorageToolbar">
{connectionConflict && <Button type="button" variant="outline" disabled={loading} onClick={onResolveConnectionConflict}>退</Button>}
<Button type="button" variant="outline" disabled={loading} onClick={onCancel}></Button>
</div>}
</CardContent>
</Card>
);
}
export function IdentityRuntimeActions({
loading,
onDisable,
onValidate,
previous,
}: {
loading: boolean;
onDisable: () => void;
onValidate: () => void;
previous: IdentityConfigurationRevision | null;
}) {
return (
<div className="fileStorageToolbar">
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={onValidate}>
<RefreshCw size={14} />
</Button>
<span className="mutedText" role="note">
Active OAuth/SSF 使
</span>
{previous && (
<span className="mutedText" role="note">
OAuth/SSF 使
</span>
)}
<Button type="button" variant="destructive" size="sm" disabled={loading} onClick={onDisable}>
<Unplug size={14} />
</Button>
</div>
);
}
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;
const configuredApiBaseUrl = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, '');
return {
authCenterUrl: 'https://auth.51easyai.com',
onboardingCode: '',
publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '',
webBaseUrl,
localTenantKey: 'default',
legacyJwtEnabled: false,
};
}
function pairingStatusLabel(status: string, cleanupStatus?: string) {
if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接';
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
}
const pairingFailureMessages: Readonly<Record<string, string>> = {
security_event_configuration_invalid: '安全事件配置不完整或无效',
security_event_discovery_failed: '无法获取或校验 SSF Discovery网络、HTTP、JSON、Issuer 或端点异常)',
security_event_connection_conflict: '已有安全事件连接与本次接入配置冲突',
security_event_credential_handoff_unsafe: '旧安全事件连接与本次认证中心或服务 Client 不匹配;系统不会发送旧凭据,请先在原配置下断开旧连接',
security_event_connection_binding_missing: '安全事件连接尚未建立',
security_event_connection_binding_unavailable: '安全事件连接状态暂时不可用',
security_event_connection_binding_invalid: '安全事件连接缺少 Revision 绑定信息',
security_event_connection_binding_mismatch: '认证中心返回的安全事件 Audience 或连接归属与本次配置不一致',
security_event_retirement_pending: '现有安全事件连接正在安全退役,完成后本次配对会自动继续',
security_event_management_token_failed: '服务客户端暂时无法取得安全事件管理 Token',
security_event_stream_create_failed: '安全事件 Stream 创建失败',
security_event_stream_response_invalid: '安全事件 Stream 返回内容不符合预期',
security_event_receiver_activation_failed: 'Gateway 安全事件 Receiver 启动失败',
security_event_preparation_failed: '安全事件能力准备失败',
metadata_submission_failed: '业务地址提交失败',
exchange_status_unavailable: '认证中心资源准备状态暂时不可用',
credential_delivery_failed: '一次性机器凭据领取或保存失败',
exchange_expired: '一次性接入 Exchange 已过期',
remote_exchange_failed: '认证中心未能完成应用资源准备,请结合 Audit ID 排查',
remote_state_invalid: '认证中心返回了不符合当前流程的 Exchange 状态',
exchange_completion_failed: 'Gateway 暂时无法确认认证中心 Exchange 已完成',
cleanup_revision_unavailable: '待清理的统一认证草稿暂时不可用',
cleanup_security_event_unavailable: '安全事件清理服务暂时不可用',
cleanup_security_event_retirement_pending: '安全事件 Stream 已断开,正在等待安全退役窗口结束',
cleanup_security_event_connection_cleanup_failed: '安全事件连接清理暂时失败',
cleanup_security_event_secret_cleanup_failed: '安全事件临时凭据清理暂时失败',
cleanup_security_event_failed: '安全事件连接清理暂时失败',
cleanup_secret_store_failed: '统一认证临时凭据清理暂时失败',
cleanup_finalize_failed: '清理结果暂时无法确认',
};
export function pairingFailureMessage(category?: string) {
return pairingFailureMessages[category ?? ''] ?? '统一认证配对步骤暂时失败';
}
function safePairingCategory(category: string) {
return Object.hasOwn(pairingFailureMessages, category) ? category : 'pairing_step_failed';
}
export function isStaleIdentityOperation(caught: unknown) {
return caught instanceof GatewayApiError && (caught.details.status === 409 || caught.details.status === 412);
}
export async function executeIdentityOperation(options: {
action: () => Promise<unknown>;
onRuntimeChanged: () => void;
onSuccess: (message: string) => void;
refresh: () => Promise<unknown>;
runtimeChanged: boolean;
success: string;
}) {
await options.action();
try {
await options.refresh();
} catch (caught) {
if (!options.runtimeChanged) throw caught;
const followUp = isIdentityRefreshAuthenticationLoss(caught)
? '当前统一认证会话已失效,请重新登录。'
: '最新状态刷新失败,请稍后点击“刷新”确认。';
options.onSuccess(`${options.success} 操作已提交;${followUp}`);
options.onRuntimeChanged();
return true;
}
options.onSuccess(options.success);
if (options.runtimeChanged) options.onRuntimeChanged();
return true;
}
function isIdentityRefreshAuthenticationLoss(caught: unknown) {
return caught instanceof GatewayApiError && caught.details.status === 401;
}
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; }