fix(web): 完善统一认证恢复与重配交互
为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。 验证:pnpm test;pnpm lint;pnpm build。
This commit is contained in:
parent
a312ad880d
commit
b179162330
@ -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(() => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
29
apps/web/src/components/ui/confirm-dialog.test.tsx
Normal file
29
apps/web/src/components/ui/confirm-dialog.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user