diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0749ae7..6870008 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -122,7 +122,10 @@ import { persistAccessToken, readStoredAccessToken, } from './lib/auth-storage'; -import { restoreOIDCBrowserSession } from './lib/oidc-browser-session'; +import { + reconcileOIDCBrowserSessionAfterRuntimeChange, + restoreOIDCBrowserSession, +} from './lib/oidc-browser-session'; import { consumeOIDCCallbackError, loadOIDCRuntimeConfiguration, @@ -260,7 +263,15 @@ export function App() { const [state, setState] = useState('idle'); const [error, setError] = useState(''); const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); - const [oidcEnabled, setOIDCEnabled] = useState(false); + const [oidcEnabled, setOIDCEnabled] = useState(false); + const currentCredentialRef = useRef(token); + const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined); + currentCredentialRef.current = token; + resetAuthenticatedSessionRef.current = () => { + resetAuthenticatedSession(); + setAuthMode('login'); + setError('统一认证配置已变更,当前登录会话已失效,请重新登录。'); + }; const loadedDataKeysRef = useRef(new Set()); const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); @@ -294,29 +305,48 @@ export function App() { useEffect(() => { let cancelled = false; - const loadIdentityRuntime = async (force = false) => { - const configuration = await loadOIDCRuntimeConfiguration(force); - if (cancelled) return; - setOIDCEnabled(configuration.enabled && configuration.oidcLogin); - if (oidcCallbackError || readStoredAccessToken() || !configuration.enabled || !configuration.oidcLogin) return; - const restored = await restoreOIDCBrowserSession(); - if (!restored || cancelled) return; + const applyRestoredSession = (restored: NonNullable>>) => { + if (cancelled) return; setCurrentUser(restored.user); loadedDataKeysRef.current.add('currentUser'); setToken(restored.credential); setState('idle'); setError(''); - }; - const runtimeChanged = () => { void loadIdentityRuntime(true); }; - window.addEventListener('identity-runtime-changed', runtimeChanged); - void loadIdentityRuntime().catch((err) => { + }; + const loadIdentityRuntime = async (force = false) => { + const configuration = await loadOIDCRuntimeConfiguration(force); + if (cancelled) return; + const identityEnabled = configuration.enabled && configuration.oidcLogin; + setOIDCEnabled(identityEnabled); + if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) { + await reconcileOIDCBrowserSessionAfterRuntimeChange({ + credential: currentCredentialRef.current, + oidcEnabled: identityEnabled, + onReset: () => { + if (!cancelled) resetAuthenticatedSessionRef.current(); + }, + onRestored: applyRestoredSession, + }); + return; + } + if (oidcCallbackError || readStoredAccessToken() || !identityEnabled) return; + const restored = await restoreOIDCBrowserSession(); + if (!restored || cancelled) return; + applyRestoredSession(restored); + }; + const handleLoadError = (err: unknown) => { if (cancelled) return; setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); - }); + }; + const runtimeChanged = () => { + void loadIdentityRuntime(true).catch(handleLoadError); + }; + window.addEventListener('identity-runtime-changed', runtimeChanged); + void loadIdentityRuntime().catch(handleLoadError); return () => { cancelled = true; - window.removeEventListener('identity-runtime-changed', runtimeChanged); + window.removeEventListener('identity-runtime-changed', runtimeChanged); }; }, [oidcCallbackError]); useEffect(() => { diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index a49aa75..6b6e3d4 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + cancelIdentityPairing, connectSecurityEventTransmitter, deleteOIDCBrowserSession, GatewayApiError, @@ -7,6 +8,7 @@ import { getCurrentUser, OIDC_BROWSER_SESSION_CREDENTIAL, startIdentityPairing, + retireIdentityPairingSecurityEventConflict, validateIdentityRevision, } from './api'; @@ -22,6 +24,10 @@ describe('Gateway provisioning errors', () => { ['OIDC_SESSION_REFRESH_UNAVAILABLE', '认证中心暂时不可用,请稍后重试'], ['OIDC_SESSION_STORE_UNAVAILABLE', '登录会话存储暂时不可用,请稍后重试'], ['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'], + ['IDENTITY_PAIRING_IN_PROGRESS', '请先完成或放弃当前统一认证配对'], + ['IDENTITY_PAIRING_NOT_CANCELLABLE', '当前统一认证配置不能放弃'], + ['IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE', '当前配对状态已变化,请刷新后重试'], + ['IDENTITY_VERSION_CONFLICT', '统一认证状态已变化,请刷新后重试'], ] as const; for (const [code, expected] of cases) { @@ -80,6 +86,46 @@ describe('identity configuration transport', () => { expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); expect(init.credentials).toBe('include'); }); + + it('cancels a pairing with its own ETag and an idempotency key', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id: 'pairing', status: 'cancelled', cleanupStatus: 'pending', version: 8, + }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await cancelIdentityPairing('manager-token', 'pairing', 7); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/admin/system/identity/pairings/pairing/cancel'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-cancel-'); + expect(init.credentials).toBe('include'); + }); + + it('retires an SSF conflict through the pairing-scoped recovery endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id: 'pairing', status: 'credentials_saved', version: 7, + }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await retireIdentityPairingSecurityEventConflict('manager-token', 'pairing', 7); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/admin/system/identity/pairings/pairing/retire-conflicting-security-event'); + expect(init.method).toBe('POST'); + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-retire-ssf-conflict-'); + }); }); describe('security event connection transport', () => { diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8fb2644..ea96648 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1019,6 +1019,14 @@ export async function getIdentityPairing(token: string, pairingId: string): Prom return request(`${identityConfigurationPath}/pairings/${pairingId}`, { token }); } +export async function cancelIdentityPairing(token: string, pairingId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/pairings/${pairingId}/cancel`, 'POST', version, undefined, 'cancel'); +} + +export async function retireIdentityPairingSecurityEventConflict(token: string, pairingId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/pairings/${pairingId}/retire-conflicting-security-event`, 'POST', version, undefined, 'retire-ssf-conflict'); +} + export async function updateIdentityRevisionPolicy( token: string, revisionId: string, @@ -1036,10 +1044,6 @@ export async function activateIdentityRevision(token: string, revisionId: string return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate'); } -export async function rollbackIdentityRevision(token: string, revisionId: string, version: number): Promise { - return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/rollback`, 'POST', version, undefined, 'rollback'); -} - export async function disableIdentityConfiguration(token: string, version: number): Promise { return identityConfigurationWrite(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable'); } @@ -1267,6 +1271,10 @@ const gatewayProvisioningErrorMessages: Record = { OIDC_SESSION_REFRESH_UNAVAILABLE: '认证中心暂时不可用,请稍后重试', OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试', OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试', + IDENTITY_PAIRING_IN_PROGRESS: '请先完成或放弃当前统一认证配对', + IDENTITY_PAIRING_NOT_CANCELLABLE: '当前统一认证配置不能放弃', + IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE: '当前配对状态已变化,请刷新后重试', + IDENTITY_VERSION_CONFLICT: '统一认证状态已变化,请刷新后重试', }; export function gatewayErrorMessage(details: GatewayErrorDetails) { diff --git a/apps/web/src/components/ui/confirm-dialog.test.tsx b/apps/web/src/components/ui/confirm-dialog.test.tsx new file mode 100644 index 0000000..38f4448 --- /dev/null +++ b/apps/web/src/components/ui/confirm-dialog.test.tsx @@ -0,0 +1,29 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ConfirmDialog } from './confirm-dialog'; + +describe('ConfirmDialog', () => { + it('associates its title and destructive-action description', () => { + const html = renderToStaticMarkup( + undefined} onConfirm={() => undefined} />, + ); + + const labelledBy = html.match(/aria-labelledby="([^"]+)"/)?.[1]; + const describedBy = html.match(/aria-describedby="([^"]+)"/)?.[1]; + expect(labelledBy).toBeTruthy(); + expect(describedBy).toBeTruthy(); + expect(html).toContain(`id="${labelledBy}"`); + expect(html).toContain(`id="${describedBy}"`); + expect(html).toContain('tabindex="-1"'); + }); + + it('keeps the modal container focusable while its actions are loading', () => { + const html = renderToStaticMarkup( + undefined} onConfirm={() => undefined} />, + ); + + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('tabindex="-1"'); + expect(html.match(/disabled=""/g)?.length).toBe(2); + }); +}); diff --git a/apps/web/src/components/ui/confirm-dialog.tsx b/apps/web/src/components/ui/confirm-dialog.tsx index b295e11..42c9faa 100644 --- a/apps/web/src/components/ui/confirm-dialog.tsx +++ b/apps/web/src/components/ui/confirm-dialog.tsx @@ -18,32 +18,79 @@ export interface ConfirmDialogProps { } export function ConfirmDialog(props: ConfirmDialogProps) { - const { onCancel, open } = props; + const { open } = props; + const titleId = React.useId(); + const descriptionId = React.useId(); + const dialogRef = React.useRef(null); + const cancelRef = React.useRef(null); + const loadingRef = React.useRef(props.loading); + const onCancelRef = React.useRef(props.onCancel); + loadingRef.current = props.loading; + onCancelRef.current = props.onCancel; React.useEffect(() => { if (!open) return undefined; + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusFrame = window.requestAnimationFrame(() => { + if (cancelRef.current && !cancelRef.current.disabled) cancelRef.current.focus(); + else dialogRef.current?.focus(); + }); function onKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') onCancel(); + if (event.key === 'Escape' && !loadingRef.current) { + event.preventDefault(); + onCancelRef.current(); + return; + } + if (event.key !== 'Tab' || !dialogRef.current) return; + const focusable = Array.from(dialogRef.current.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); + if (!focusable.length) { + event.preventDefault(); + dialogRef.current.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === dialogRef.current) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (!dialogRef.current.contains(document.activeElement)) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } } window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [onCancel, open]); + return () => { + window.cancelAnimationFrame(focusFrame); + window.removeEventListener('keydown', onKeyDown); + previouslyFocused?.focus(); + }; + }, [open]); + + React.useEffect(() => { + if (open && props.loading) dialogRef.current?.focus(); + }, [open, props.loading]); if (!open) return null; return (
-
-
+
+
- {props.title} - {props.description &&

{props.description}

} + {props.title} + {props.description &&

{props.description}

} {props.children}
- '); + }); +}); + +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( 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( 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', + }; +} diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx index 3ae4700..215b316 100644 --- a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx @@ -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(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, 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 (
@@ -105,11 +133,14 @@ export function UnifiedIdentityPanel(props: { token: string }) {
{active ? '已启用' : '未启用'} - +
- {(message || error) &&
{error || message}
} + {(message || error || pollError) &&
{error || pollError || message}
} {active && runtime && ( @@ -128,42 +159,23 @@ export function UnifiedIdentityPanel(props: { token: string }) { -
- - - {previous && ( - - )} - -
+ setConfirmAction('disable')} + onValidate={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)} + />
)} - {pairing && !terminalPairingStatuses.has(pairing.status) && ( - - -
-
正在配对认证中心{pairingStatusLabel(pairing.status)}
- {pairing.status} -
-
- Exchange: {shortID(pairing.remoteExchangeId)} - 有效期至: {new Date(pairing.expiresAt).toLocaleString()} - {pairing.lastTraceId && Trace ID: {pairing.lastTraceId}} - {pairing.authCenterAuditId && Auth Center Audit ID: {pairing.authCenterAuditId}} -
-
-
+ {pairing && (!terminalPairingStatuses.has(pairing.status) || cleanupPending) && ( + setConfirmAction('cancelPairing')} + onResolveConnectionConflict={() => setConfirmAction('retireSecurityEvent')} + /> )} {draft && pairing?.status === 'completed' && ( @@ -198,22 +210,29 @@ export function UnifiedIdentityPanel(props: { token: string }) { )} {draft.state === 'failed' && 验证失败:{draft.lastErrorCategory || 'validation_failed'}。请检查地址和租户后使用新接入码重新配对。} + )} - {pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && ( -
- 配对{pairing.status === 'expired' ? '已过期' : '失败'}:{pairing.lastErrorCategory || pairing.status}。请在认证中心生成新的接入码。 - -
+ {pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && !cleanupPending && ( +
+ + {cleanupCompleted ? '旧配对已放弃,临时凭据与本次创建的 SSF 连接已清理。' : `配对${pairing.status === 'expired' ? '已过期' : '失败'}:${pairingFailureMessage(pairing.lastErrorCategory)}。`} + + {cleanupCompleted + ? + : } +
)} {shouldShowPairing && ( -
{active ? '重新配对认证中心' : '接入认证中心'}

认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。

+
接入认证中心

认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。

接入码 10 分钟有效
@@ -229,7 +248,6 @@ export function UnifiedIdentityPanel(props: { token: string }) {
- {active && }
@@ -237,28 +255,113 @@ export function UnifiedIdentityPanel(props: { token: string }) { )} 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); }} /> ); } +export function PairingProgressCard({ pairing, loading, onCancel, onResolveConnectionConflict }: { + pairing: NonNullable; + 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 ( + + +
+
{cleaning ? '正在清理已放弃的配对' : '正在配对认证中心'}{pairingStatusLabel(pairing.status, pairing.cleanupStatus)}
+ {cleaning ? 'cleanup_pending' : pairing.status} +
+ {pairing.lastErrorCategory && ( +
+ {pairingFailureMessage(pairing.lastErrorCategory)}(错误分类:{safePairingCategory(pairing.lastErrorCategory)})。{cleaning + ? '后台会继续安全清理。' + : connectionConflict + ? '此问题不会通过自动重试恢复;请安全退役现有连接,或放弃本次配对。' + : unsafeCredentialHandoff + ? '此问题不会自动恢复;请先在原配置下断开旧连接,或放弃本次配对。' + : '后台正在自动重试;也可以放弃本次配对后重新配置。'} +
+ )} +
+ Exchange: {shortID(pairing.remoteExchangeId)} + 有效期至: {new Date(pairing.expiresAt).toLocaleString()} + {pairing.lastTraceId && Trace ID: {pairing.lastTraceId}} + {pairing.authCenterAuditId && Auth Center Audit ID: {pairing.authCenterAuditId}} +
+ {!cleaning &&
+ {connectionConflict && } + +
} +
+
+ ); +} + +export function IdentityRuntimeActions({ + loading, + onDisable, + onValidate, + previous, +}: { + loading: boolean; + onDisable: () => void; + onValidate: () => void; + previous: IdentityConfigurationRevision | null; +}) { + return ( +
+ + + 当前 Active 关联认证中心的 OAuth/SSF 资源;请先禁用统一认证,再使用新接入码重新配置。 + + {previous && ( + + 旧版本的远端 OAuth/SSF 资源可能已经变化,不能直接回滚;请使用新的接入码恢复。 + + )} + +
+ ); +} + function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) { return (
@@ -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)[status] ?? status; } +const pairingFailureMessages: Readonly> = { + 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; + onRuntimeChanged: () => void; + onSuccess: (message: string) => void; + refresh: () => Promise; + 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)[value] ?? value; }