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

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

验证:pnpm test;pnpm lint;pnpm build。
This commit is contained in:
chengcheng 2026-07-17 18:31:38 +08:00
parent a312ad880d
commit b179162330
9 changed files with 696 additions and 98 deletions

View File

@ -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<LoadState>('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<DataKey>());
const loadingDataKeysRef = useRef(new Set<DataKey>());
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<Awaited<ReturnType<typeof restoreOIDCBrowserSession>>>) => {
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(() => {

View File

@ -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', () => {

View File

@ -1019,6 +1019,14 @@ export async function getIdentityPairing(token: string, pairingId: string): Prom
return request<IdentityPairingExchange>(`${identityConfigurationPath}/pairings/${pairingId}`, { token });
}
export async function cancelIdentityPairing(token: string, pairingId: string, version: number): Promise<IdentityPairingExchange> {
return identityConfigurationWrite<IdentityPairingExchange>(token, `${identityConfigurationPath}/pairings/${pairingId}/cancel`, 'POST', version, undefined, 'cancel');
}
export async function retireIdentityPairingSecurityEventConflict(token: string, pairingId: string, version: number): Promise<IdentityPairingExchange> {
return identityConfigurationWrite<IdentityPairingExchange>(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<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate');
}
export async function rollbackIdentityRevision(token: string, revisionId: string, version: number): Promise<IdentityConfigurationRevision> {
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/rollback`, 'POST', version, undefined, 'rollback');
}
export async function disableIdentityConfiguration(token: string, version: number): Promise<IdentityConfigurationRevision> {
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable');
}
@ -1267,6 +1271,10 @@ const gatewayProvisioningErrorMessages: Record<string, string> = {
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) {

View File

@ -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(
<ConfirmDialog open title="放弃当前配对?" description="将清理临时资源" onCancel={() => 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(
<ConfirmDialog open loading title="安全退役?" onCancel={() => undefined} onConfirm={() => undefined} />,
);
expect(html).toContain('aria-busy="true"');
expect(html).toContain('tabindex="-1"');
expect(html.match(/disabled=""/g)?.length).toBe(2);
});
});

View File

@ -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<HTMLElement>(null);
const cancelRef = React.useRef<HTMLButtonElement>(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<HTMLElement>('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 (
<div className="confirmDialogBackdrop" role="presentation">
<section className={cn('confirmDialog', props.className)} role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<div className="confirmDialogIcon">
<section ref={dialogRef} className={cn('confirmDialog', props.className)} role="alertdialog" tabIndex={-1} aria-busy={props.loading || undefined} aria-modal="true" aria-labelledby={titleId} aria-describedby={props.description ? descriptionId : undefined}>
<div className="confirmDialogIcon" aria-hidden="true">
<AlertTriangle size={18} />
</div>
<div className="confirmDialogBody">
<strong id="confirm-dialog-title">{props.title}</strong>
{props.description && <p>{props.description}</p>}
<strong id={titleId}>{props.title}</strong>
{props.description && <p id={descriptionId}>{props.description}</p>}
{props.children}
</div>
<footer className="confirmDialogActions">
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
<Button ref={cancelRef} type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
{props.cancelLabel ?? '取消'}
</Button>
<Button

View File

@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { OIDC_BROWSER_SESSION_CREDENTIAL } from '../api';
import { restoreOIDCBrowserSession } from './oidc-browser-session';
import {
reconcileOIDCBrowserSessionAfterRuntimeChange,
restoreOIDCBrowserSession,
} from './oidc-browser-session';
describe('OIDC browser session lifecycle', () => {
it('restores a shared cookie session in a fresh tab', async () => {
@ -21,4 +24,42 @@ describe('OIDC browser session lifecycle', () => {
await expect(restoreOIDCBrowserSession()).resolves.toBeNull();
});
it('resets App authentication when an OIDC sentinel session re-probe returns 401', async () => {
const resetAuthenticatedSession = vi.fn();
const onRestored = vi.fn();
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: 'unauthorized', status: 401 },
}), { status: 401, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
await expect(reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: OIDC_BROWSER_SESSION_CREDENTIAL,
oidcEnabled: true,
onReset: resetAuthenticatedSession,
onRestored,
})).resolves.toBe('reset');
expect(fetchMock).toHaveBeenCalledOnce();
expect(resetAuthenticatedSession).toHaveBeenCalledOnce();
expect(onRestored).not.toHaveBeenCalled();
});
it('resets App authentication without probing /me when identity is disabled', async () => {
const resetAuthenticatedSession = vi.fn();
const onRestored = vi.fn();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(reconcileOIDCBrowserSessionAfterRuntimeChange({
credential: OIDC_BROWSER_SESSION_CREDENTIAL,
oidcEnabled: false,
onReset: resetAuthenticatedSession,
onRestored,
})).resolves.toBe('reset');
expect(fetchMock).not.toHaveBeenCalled();
expect(resetAuthenticatedSession).toHaveBeenCalledOnce();
expect(onRestored).not.toHaveBeenCalled();
});
});

View File

@ -11,6 +11,13 @@ export interface RestoredOIDCBrowserSession {
user: CurrentUser;
}
export interface ReconcileOIDCBrowserSessionOptions {
credential: string;
oidcEnabled: boolean;
onReset: () => void;
onRestored: (session: RestoredOIDCBrowserSession) => void;
}
export async function restoreOIDCBrowserSession(): Promise<RestoredOIDCBrowserSession | null> {
try {
const user = await getCurrentUser(OIDC_BROWSER_SESSION_CREDENTIAL);
@ -20,3 +27,20 @@ export async function restoreOIDCBrowserSession(): Promise<RestoredOIDCBrowserSe
throw error;
}
}
export async function reconcileOIDCBrowserSessionAfterRuntimeChange(
options: ReconcileOIDCBrowserSessionOptions,
): Promise<'ignored' | 'reset' | 'restored'> {
if (options.credential !== OIDC_BROWSER_SESSION_CREDENTIAL) return 'ignored';
if (!options.oidcEnabled) {
options.onReset();
return 'reset';
}
const restored = await restoreOIDCBrowserSession();
if (!restored) {
options.onReset();
return 'reset';
}
options.onRestored(restored);
return 'restored';
}

View File

@ -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',
};
}

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;
}