fix(web): 完善统一认证恢复与重配交互

为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。

验证:pnpm test;pnpm lint;pnpm build。
This commit is contained in:
2026-07-17 18:31:38 +08:00
parent a312ad880d
commit b179162330
9 changed files with 696 additions and 98 deletions
@@ -1,6 +1,15 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { IdentityConfigurationRevision } from '@easyai-ai-gateway/contracts';
import { GatewayApiError } from '../../api';
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
import {
IdentityRuntimeActions,
PairingProgressCard,
executeIdentityOperation,
isStaleIdentityOperation,
pairingFailureMessage,
} from './UnifiedIdentityPanel';
describe('UnifiedIdentityPanel', () => {
it('asks only for the standard pairing inputs and does not expose a machine secret field', () => {
@@ -16,3 +25,190 @@ describe('UnifiedIdentityPanel', () => {
expect(html).not.toContain('SSF Transmitter Issuer');
});
});
describe('PairingProgressCard', () => {
it('shows a safe actionable discovery failure and a recovery action', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_discovery_failed', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('无法获取或校验 SSF Discovery');
expect(html).toContain('后台正在自动重试');
expect(html).toContain('放弃并清理本次配对');
expect(html).toContain('role="status"');
expect(html).toContain('aria-busy="true"');
});
it('does not reflect an untrusted category into the page', () => {
expect(pairingFailureMessage('token-value <script>alert(1)</script>')).toBe('统一认证配对步骤暂时失败');
expect(pairingFailureMessage('secret_token_abcdef')).toBe('统一认证配对步骤暂时失败');
});
it('renders cancellation cleanup as resumable progress without another cancel button', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'cancelled',
cleanupStatus: 'pending', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 8,
lastErrorCategory: 'cleanup_security_event_retirement_pending', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('正在清理已放弃的配对');
expect(html).toContain('安全退役窗口');
expect(html).not.toContain('放弃并清理本次配对');
});
it('marks an existing SSF owner conflict as action-required', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} onResolveConnectionConflict={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_connection_conflict', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('不会通过自动重试恢复');
expect(html).toContain('安全退役现有连接');
});
it('does not claim an unsafe credential handoff will recover automatically', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_credential_handoff_unsafe', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('不会自动恢复');
expect(html).toContain('原配置下断开旧连接');
expect(html).not.toContain('后台正在自动重试');
expect(html).toContain('放弃并清理本次配对');
});
it('shows safe retirement as automatic progress without another retirement action', () => {
const html = renderToStaticMarkup(<PairingProgressCard loading={false} onCancel={() => undefined} onResolveConnectionConflict={() => undefined} pairing={{
id: 'pairing', revisionId: 'revision', remoteExchangeId: 'exchange', status: 'credentials_saved',
cleanupStatus: 'none', remoteVersion: 4, expiresAt: '2026-07-17T15:00:00+08:00', version: 7,
lastErrorCategory: 'security_event_retirement_pending', createdAt: '2026-07-17T14:00:00+08:00', updatedAt: '2026-07-17T14:01:00+08:00',
}} />);
expect(html).toContain('正在安全退役,完成后本次配对会自动继续');
expect(html).not.toContain('安全退役现有连接</button>');
});
});
describe('identity confirmation freshness', () => {
it('requires a fresh confirmation after conflict responses', () => {
expect(isStaleIdentityOperation(new GatewayApiError({ message: 'conflict', status: 409 }))).toBe(true);
expect(isStaleIdentityOperation(new GatewayApiError({ message: 'changed', status: 412 }))).toBe(true);
expect(isStaleIdentityOperation(new GatewayApiError({ message: 'retry', status: 503 }))).toBe(false);
});
});
describe('identity runtime operation completion', () => {
it('treats a successful disable followed by refresh 401 as submitted and requests re-login', async () => {
const onSuccess = vi.fn();
const onRuntimeChanged = vi.fn();
await expect(executeIdentityOperation({
action: vi.fn().mockResolvedValue(undefined),
onRuntimeChanged,
onSuccess,
refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unauthorized', status: 401 })),
runtimeChanged: true,
success: '统一认证已禁用,本地管理登录继续可用。',
})).resolves.toBe(true);
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('请重新登录'));
expect(onRuntimeChanged).toHaveBeenCalledOnce();
});
it('treats a successful activation followed by refresh 401 as submitted and requests re-login', async () => {
const onSuccess = vi.fn();
const onRuntimeChanged = vi.fn();
await expect(executeIdentityOperation({
action: vi.fn().mockResolvedValue(undefined),
onRuntimeChanged,
onSuccess,
refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unauthorized', status: 401 })),
runtimeChanged: true,
success: '统一认证已激活,用户需要重新登录。',
})).resolves.toBe(true);
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('请重新登录'));
expect(onRuntimeChanged).toHaveBeenCalledOnce();
});
it('does not reverse a committed runtime change when the follow-up refresh is unavailable', async () => {
const onSuccess = vi.fn();
const onRuntimeChanged = vi.fn();
await expect(executeIdentityOperation({
action: vi.fn().mockResolvedValue(undefined),
onRuntimeChanged,
onSuccess,
refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unavailable', status: 503 })),
runtimeChanged: true,
success: '统一认证已热切换启用,无需重启。',
})).resolves.toBe(true);
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('操作已提交'));
expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('状态刷新失败'));
expect(onSuccess).not.toHaveBeenCalledWith(expect.stringContaining('请重新登录'));
expect(onRuntimeChanged).toHaveBeenCalledOnce();
});
});
describe('IdentityRuntimeActions remote-resource safety', () => {
it('hides active re-pairing for every active configuration', () => {
const html = renderToStaticMarkup(<IdentityRuntimeActions
loading={false}
previous={null}
onDisable={() => undefined}
onValidate={() => undefined}
/>);
expect(html).not.toContain('重新配对');
expect(html).toContain('请先禁用统一认证');
expect(html).toContain('OAuth/SSF 资源');
expect(html).toContain('重新验证');
expect(html).toContain('禁用统一认证');
});
it('replaces unsafe previous-version rollback with a new onboarding-code instruction', () => {
const html = renderToStaticMarkup(<IdentityRuntimeActions
loading={false}
previous={identityRevision('superseded', ['session_revocation'])}
onDisable={() => undefined}
onValidate={() => undefined}
/>);
expect(html).not.toContain('回滚上一版本');
expect(html).toContain('不能直接回滚');
expect(html).toContain('新的接入码');
});
});
function identityRevision(
state: IdentityConfigurationRevision['state'],
capabilities: string[],
): IdentityConfigurationRevision {
return {
id: `${state}-revision`,
state,
schemaVersion: 1,
authCenterUrl: 'https://auth.example.com',
issuer: 'https://issuer.example.com',
scopes: ['openid'],
capabilities,
rolePrefix: 'gateway.',
localTenantKey: 'default',
publicBaseUrl: 'https://api.gateway.example.com',
webBaseUrl: 'https://gateway.example.com',
jitEnabled: true,
legacyJwtEnabled: false,
tokenIntrospection: capabilities.includes('token_introspection'),
sessionRevocation: capabilities.includes('session_revocation'),
sessionIdleSeconds: 1800,
sessionAbsoluteSeconds: 28800,
sessionRefreshSeconds: 60,
version: 1,
createdAt: '2026-07-17T14:00:00+08:00',
updatedAt: '2026-07-17T14:00:00+08:00',
};
}
+245 -68
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react';
import { CheckCircle2, Link2, RefreshCw, RotateCcw, ShieldCheck, Unplug } from 'lucide-react';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { CheckCircle2, Link2, RefreshCw, ShieldCheck, Unplug } from 'lucide-react';
import type {
IdentityConfigurationRevision,
IdentityConfigurationView,
@@ -7,16 +7,18 @@ import type {
} from '@easyai-ai-gateway/contracts';
import {
activateIdentityRevision,
cancelIdentityPairing,
disableIdentityConfiguration,
GatewayApiError,
getIdentityConfiguration,
rollbackIdentityRevision,
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']);
const terminalPairingStatuses = new Set(['completed', 'failed', 'expired', 'cancelled']);
export function UnifiedIdentityPanel(props: { token: string }) {
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
@@ -27,11 +29,14 @@ export function UnifiedIdentityPanel(props: { token: string }) {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [confirmAction, setConfirmAction] = useState<'disable' | 'rollback' | null>(null);
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);
@@ -45,27 +50,40 @@ export function UnifiedIdentityPanel(props: { token: string }) {
useEffect(() => {
const status = configuration?.pairing?.status;
if (!status || terminalPairingStatuses.has(status)) return undefined;
const cleanupPending = status === 'cancelled' && configuration?.pairing?.cleanupStatus === 'pending';
if (!status || (terminalPairingStatuses.has(status) && !cleanupPending)) return undefined;
const timer = window.setInterval(() => {
void refresh().catch(() => undefined);
void refresh().catch(() => setPollError('统一认证状态自动刷新失败,请点击“刷新”重试。'));
}, 2000);
return () => window.clearInterval(timer);
}, [configuration?.pairing?.status, props.token]);
}, [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 {
await action();
await refresh();
setMessage(success);
if (runtimeChanged) window.dispatchEvent(new Event('identity-runtime-changed'));
return true;
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, '统一认证操作失败'));
return false;
if (isStaleIdentityOperation(caught)) setConfirmAction(null);
return false;
} finally {
operationInProgress.current = false;
setLoading(false);
}
}
@@ -89,12 +107,22 @@ export function UnifiedIdentityPanel(props: { token: string }) {
}), '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 shouldShowPairing = showPairingForm || (!active && !pairing);
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">
@@ -105,11 +133,14 @@ export function UnifiedIdentityPanel(props: { token: string }) {
</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>
<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) && <div className="formMessage">{error || message}</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>
@@ -128,42 +159,23 @@ export function UnifiedIdentityPanel(props: { token: string }) {
<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>
<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) && (
<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>
{pairing && (!terminalPairingStatuses.has(pairing.status) || cleanupPending) && (
<PairingProgressCard
pairing={pairing}
loading={loading}
onCancel={() => setConfirmAction('cancelPairing')}
onResolveConnectionConflict={() => setConfirmAction('retireSecurityEvent')}
/>
)}
{draft && pairing?.status === 'completed' && (
@@ -198,22 +210,29 @@ export function UnifiedIdentityPanel(props: { token: string }) {
</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' && (
<div className="fileStorageToolbar">
<span className="formMessage">{pairing.status === 'expired' ? '已过期' : '失败'}{pairing.lastErrorCategory || pairing.status}</span>
<Button type="button" variant="outline" onClick={() => setShowPairingForm(true)}>使</Button>
</div>
{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>{active ? '重新配对认证中心' : '接入认证中心'}</CardTitle><p className="mutedText"> Application Manifest </p></div>
<div><CardTitle></CardTitle><p className="mutedText"> Application Manifest </p></div>
<Badge variant="outline"> 10 </Badge>
</CardHeader>
<CardContent>
@@ -229,7 +248,6 @@ export function UnifiedIdentityPanel(props: { token: string }) {
</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>
@@ -237,28 +255,113 @@ export function UnifiedIdentityPanel(props: { token: string }) {
)}
<ConfirmDialog
confirmLabel={confirmAction === 'rollback' ? '确认回滚' : '确认禁用'}
description={confirmAction === 'rollback'
? '系统会重新验证上一版本并热切换;现有统一认证会话将被清理,用户需要重新登录。'
: '统一认证将关闭并清理现有 BFF Session;本地应急管理员登录继续可用。'}
confirmLabel={confirmAction === 'cancelPairing'
? '确认放弃'
: confirmAction === 'retireSecurityEvent'
? '确认安全退役'
: '确认禁用'}
description={confirmAction === 'cancelPairing'
? '系统会封存当前未激活草稿,并异步销毁本地临时凭据和仅由本次配对创建的 SSF 连接。已经完成的远端 Exchange 不会被伪装为已撤销;使用新接入码时会轮换机器凭据。'
: confirmAction === 'retireSecurityEvent'
? '系统将安全退役当前 SSF Stream。退役期间现有统一认证会自动使用 Introspection 降级保护;退役完成后,本次配对会自动创建新的标准连接。'
: '统一认证将关闭并清理现有 BFF Session;本地应急管理员登录继续可用。'}
loading={loading}
open={confirmAction !== null}
title={confirmAction === 'rollback' ? '回滚统一认证配置?' : '禁用统一认证?'}
title={confirmAction === 'cancelPairing'
? '放弃当前本地配对?'
: confirmAction === 'retireSecurityEvent'
? '安全退役现有 SSF 连接?'
: '禁用统一认证?'}
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);
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);
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">
@@ -294,10 +397,84 @@ function defaultPairingInput(): IdentityPairingInput {
};
}
function pairingStatusLabel(status: string) {
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;
}