为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。 验证:pnpm test;pnpm lint;pnpm build。
489 lines
25 KiB
TypeScript
489 lines
25 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||
import { CheckCircle2, Link2, RefreshCw, ShieldCheck, Unplug } from 'lucide-react';
|
||
import type {
|
||
IdentityConfigurationRevision,
|
||
IdentityConfigurationView,
|
||
IdentityPairingInput,
|
||
} from '@easyai-ai-gateway/contracts';
|
||
import {
|
||
activateIdentityRevision,
|
||
cancelIdentityPairing,
|
||
disableIdentityConfiguration,
|
||
GatewayApiError,
|
||
getIdentityConfiguration,
|
||
retireIdentityPairingSecurityEventConflict,
|
||
startIdentityPairing,
|
||
updateIdentityRevisionPolicy,
|
||
validateIdentityRevision,
|
||
} from '../../api';
|
||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui';
|
||
|
||
const terminalPairingStatuses = new Set(['completed', 'failed', 'expired', 'cancelled']);
|
||
|
||
export function UnifiedIdentityPanel(props: { token: string }) {
|
||
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
|
||
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
|
||
const [showPairingForm, setShowPairingForm] = useState(false);
|
||
const [localTenantKey, setLocalTenantKey] = useState('default');
|
||
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [message, setMessage] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [pollError, setPollError] = useState('');
|
||
const [confirmAction, setConfirmAction] = useState<'disable' | 'cancelPairing' | 'retireSecurityEvent' | null>(null);
|
||
const operationInProgress = useRef(false);
|
||
|
||
async function refresh() {
|
||
const next = await getIdentityConfiguration(props.token);
|
||
setConfiguration(next);
|
||
setPollError('');
|
||
if (next.draft) {
|
||
setLocalTenantKey(next.draft.localTenantKey);
|
||
setLegacyJwtEnabled(next.draft.legacyJwtEnabled);
|
||
}
|
||
return next;
|
||
}
|
||
|
||
useEffect(() => {
|
||
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置加载失败')));
|
||
}, [props.token]);
|
||
|
||
useEffect(() => {
|
||
const status = configuration?.pairing?.status;
|
||
const cleanupPending = status === 'cancelled' && configuration?.pairing?.cleanupStatus === 'pending';
|
||
if (!status || (terminalPairingStatuses.has(status) && !cleanupPending)) return undefined;
|
||
const timer = window.setInterval(() => {
|
||
void refresh().catch(() => setPollError('统一认证状态自动刷新失败,请点击“刷新”重试。'));
|
||
}, 2000);
|
||
return () => window.clearInterval(timer);
|
||
}, [configuration?.pairing?.cleanupStatus, configuration?.pairing?.status, props.token]);
|
||
|
||
async function run(action: () => Promise<unknown>, success: string, runtimeChanged = false) {
|
||
if (operationInProgress.current) return false;
|
||
operationInProgress.current = true;
|
||
setLoading(true);
|
||
setError('');
|
||
setMessage('');
|
||
try {
|
||
return await executeIdentityOperation({
|
||
action,
|
||
onRuntimeChanged: () => window.dispatchEvent(new Event('identity-runtime-changed')),
|
||
onSuccess: setMessage,
|
||
refresh,
|
||
runtimeChanged,
|
||
success,
|
||
});
|
||
} catch (caught) {
|
||
try {
|
||
await refresh();
|
||
} catch {
|
||
// Preserve the original operation error; the manual refresh remains available.
|
||
}
|
||
setError(errorMessage(caught, '统一认证操作失败'));
|
||
if (isStaleIdentityOperation(caught)) setConfirmAction(null);
|
||
return false;
|
||
} finally {
|
||
operationInProgress.current = false;
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function submitPairing(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const input = { ...form };
|
||
setForm((current) => ({ ...current, onboardingCode: '' }));
|
||
await run(async () => {
|
||
await startIdentityPairing(props.token, input);
|
||
setShowPairingForm(false);
|
||
}, '接入码已领取,正在由 Gateway 后端准备并保存配置。');
|
||
}
|
||
|
||
async function saveDraftPolicy() {
|
||
const draft = configuration?.draft;
|
||
if (!draft) return;
|
||
await run(() => updateIdentityRevisionPolicy(props.token, draft.id, draft.version, {
|
||
localTenantKey: localTenantKey.trim(),
|
||
legacyJwtEnabled,
|
||
}), 'Gateway 本地认证策略已保存。');
|
||
}
|
||
|
||
async function retireConflictingSecurityEventConnection() {
|
||
const pairing = configuration?.pairing;
|
||
if (!pairing) return false;
|
||
return run(async () => {
|
||
await retireIdentityPairingSecurityEventConflict(props.token, pairing.id, pairing.version);
|
||
}, '现有安全事件连接已进入安全退役;退役完成后本次配对会自动继续。');
|
||
}
|
||
|
||
const active = configuration?.active;
|
||
const draft = configuration?.draft;
|
||
const previous = configuration?.previous;
|
||
const pairing = configuration?.pairing;
|
||
const runtime = configuration?.runtime;
|
||
const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending';
|
||
const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed';
|
||
const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted);
|
||
|
||
return (
|
||
<section className="pageStack">
|
||
<div className="fileStorageToolbar">
|
||
<div>
|
||
<strong>统一认证</strong>
|
||
<span>使用认证中心生成的一次性接入码完成标准应用配对;Gateway 自动配置 OIDC、Introspection 与可选 SSF。</span>
|
||
</div>
|
||
<div className="fileStorageToolbar">
|
||
<Badge variant={active ? 'success' : 'outline'}>{active ? '已启用' : '未启用'}</Badge>
|
||
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => {
|
||
setError('');
|
||
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置刷新失败')));
|
||
}}><RefreshCw size={14} />刷新</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{(message || error || pollError) && <div className={`formMessage${error || pollError ? ' error' : ''}`} role={error || pollError ? 'alert' : 'status'} aria-live="polite">{error || pollError || message}</div>}
|
||
|
||
{active && runtime && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div>
|
||
<CardTitle>当前生效配置</CardTitle>
|
||
<p className="mutedText">Revision {shortID(active.id)} · {active.issuer}</p>
|
||
</div>
|
||
<Badge variant="success"><CheckCircle2 size={13} />Active</Badge>
|
||
</CardHeader>
|
||
<CardContent className="pageStack">
|
||
<div className="identityHealthGrid">
|
||
<IdentityHealth label="OIDC 登录" value={runtime.login} />
|
||
<IdentityHealth label="JIT 用户" value={runtime.jit} />
|
||
<IdentityHealth label="Token Introspection" value={runtime.tokenIntrospection} />
|
||
<IdentityHealth label="SSF 会话撤销" value={runtime.sessionRevocation} />
|
||
</div>
|
||
<RevisionDetails revision={active} />
|
||
<IdentityRuntimeActions
|
||
loading={loading}
|
||
previous={previous ?? null}
|
||
onDisable={() => setConfirmAction('disable')}
|
||
onValidate={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{pairing && (!terminalPairingStatuses.has(pairing.status) || cleanupPending) && (
|
||
<PairingProgressCard
|
||
pairing={pairing}
|
||
loading={loading}
|
||
onCancel={() => setConfirmAction('cancelPairing')}
|
||
onResolveConnectionConflict={() => setConfirmAction('retireSecurityEvent')}
|
||
/>
|
||
)}
|
||
|
||
{draft && pairing?.status === 'completed' && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div><CardTitle>待启用配置</CardTitle><p className="mutedText">机器凭据已写入 SecretStore,页面不会显示或保存明文。</p></div>
|
||
<Badge variant={draft.state === 'failed' ? 'destructive' : 'secondary'}>{draft.state}</Badge>
|
||
</CardHeader>
|
||
<CardContent className="pageStack">
|
||
<RevisionDetails revision={draft} />
|
||
{draft.state === 'draft' && (
|
||
<div className="formGrid two">
|
||
<Label>Gateway 本地租户映射<Input value={localTenantKey} onChange={(event) => setLocalTenantKey(event.target.value)} /></Label>
|
||
<label className="identityCheckbox">
|
||
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
|
||
<span><strong>继续兼容 Legacy JWT</strong><small>仅在迁移期确有旧调用方时开启。</small></span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<div className="fileStorageToolbar">
|
||
{draft.state === 'draft' && <>
|
||
<Button type="button" variant="outline" disabled={loading || !localTenantKey.trim()} onClick={() => void saveDraftPolicy()}>保存本地策略</Button>
|
||
<Button type="button" disabled={loading || !localTenantKey.trim()}
|
||
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
|
||
<ShieldCheck size={14} />验证配置
|
||
</Button>
|
||
</>}
|
||
{draft.state === 'validated' && (
|
||
<Button type="button" disabled={loading}
|
||
onClick={() => void run(() => activateIdentityRevision(props.token, draft.id, draft.version), '统一认证已热切换启用,无需重启。', true)}>
|
||
<CheckCircle2 size={14} />激活配置
|
||
</Button>
|
||
)}
|
||
{draft.state === 'failed' && <span className="formMessage">验证失败:{draft.lastErrorCategory || 'validation_failed'}。请检查地址和租户后使用新接入码重新配对。</span>}
|
||
<Button type="button" variant="outline" disabled={loading} onClick={() => setConfirmAction('cancelPairing')}>
|
||
放弃此草稿并重新配置
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && !cleanupPending && (
|
||
<div className="fileStorageToolbar">
|
||
<span className="formMessage" role="status">
|
||
{cleanupCompleted ? '旧配对已放弃,临时凭据与本次创建的 SSF 连接已清理。' : `配对${pairing.status === 'expired' ? '已过期' : '失败'}:${pairingFailureMessage(pairing.lastErrorCategory)}。`}
|
||
</span>
|
||
{cleanupCompleted
|
||
? <Button type="button" variant="outline" onClick={() => setShowPairingForm(true)}>重新配置</Button>
|
||
: <Button type="button" variant="outline" disabled={loading} onClick={() => setConfirmAction('cancelPairing')}>清理并重新配置</Button>}
|
||
</div>
|
||
)}
|
||
|
||
{shouldShowPairing && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div><CardTitle>接入认证中心</CardTitle><p className="mutedText">认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。</p></div>
|
||
<Badge variant="outline">接入码 10 分钟有效</Badge>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form className="formGrid two" onSubmit={(event) => void submitPairing(event)}>
|
||
<Label>Auth Center 地址<Input required value={form.authCenterUrl} onChange={(event) => setForm({ ...form, authCenterUrl: event.target.value })} placeholder="https://auth.example.com" /></Label>
|
||
<Label>一次性接入码<Input required type="password" autoComplete="one-time-code" value={form.onboardingCode} onChange={(event) => setForm({ ...form, onboardingCode: event.target.value })} placeholder="仅本次提交,不写入日志或数据库" /></Label>
|
||
<Label>Gateway API 公网地址<Input required value={form.publicBaseUrl} onChange={(event) => setForm({ ...form, publicBaseUrl: event.target.value })} placeholder="https://api.gateway.example.com" /></Label>
|
||
<Label>Gateway Web 地址<Input required value={form.webBaseUrl} onChange={(event) => setForm({ ...form, webBaseUrl: event.target.value })} placeholder="https://gateway.example.com" /></Label>
|
||
<Label>Gateway 本地租户映射<Input required value={form.localTenantKey} onChange={(event) => setForm({ ...form, localTenantKey: event.target.value })} placeholder="default" /></Label>
|
||
<label className="identityCheckbox">
|
||
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
|
||
<span><strong>继续兼容 Legacy JWT</strong><small>默认关闭;本地应急管理员登录始终保留。</small></span>
|
||
</label>
|
||
<div className="fileStorageToolbar spanTwo">
|
||
<Button type="submit" disabled={loading || !form.onboardingCode.trim()}><Link2 size={15} />开始安全配对</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
confirmLabel={confirmAction === 'cancelPairing'
|
||
? '确认放弃'
|
||
: confirmAction === 'retireSecurityEvent'
|
||
? '确认安全退役'
|
||
: '确认禁用'}
|
||
description={confirmAction === 'cancelPairing'
|
||
? '系统会封存当前未激活草稿,并异步销毁本地临时凭据和仅由本次配对创建的 SSF 连接。已经完成的远端 Exchange 不会被伪装为已撤销;使用新接入码时会轮换机器凭据。'
|
||
: confirmAction === 'retireSecurityEvent'
|
||
? '系统将安全退役当前 SSF Stream。退役期间现有统一认证会自动使用 Introspection 降级保护;退役完成后,本次配对会自动创建新的标准连接。'
|
||
: '统一认证将关闭并清理现有 BFF Session;本地应急管理员登录继续可用。'}
|
||
loading={loading}
|
||
open={confirmAction !== null}
|
||
title={confirmAction === 'cancelPairing'
|
||
? '放弃当前本地配对?'
|
||
: confirmAction === 'retireSecurityEvent'
|
||
? '安全退役现有 SSF 连接?'
|
||
: '禁用统一认证?'}
|
||
onCancel={() => setConfirmAction(null)}
|
||
onConfirm={async () => {
|
||
let succeeded = false;
|
||
if (confirmAction === 'disable' && active) {
|
||
succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true);
|
||
} else if (confirmAction === 'cancelPairing' && pairing) {
|
||
succeeded = await run(() => cancelIdentityPairing(props.token, pairing.id, pairing.version), '已放弃当前草稿,后台正在安全清理临时资源。');
|
||
} else if (confirmAction === 'retireSecurityEvent') {
|
||
succeeded = await retireConflictingSecurityEventConnection();
|
||
}
|
||
if (succeeded) setConfirmAction(null);
|
||
}}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function PairingProgressCard({ pairing, loading, onCancel, onResolveConnectionConflict }: {
|
||
pairing: NonNullable<IdentityConfigurationView['pairing']>;
|
||
loading: boolean;
|
||
onCancel: () => void;
|
||
onResolveConnectionConflict?: () => void;
|
||
}) {
|
||
const cleaning = pairing.status === 'cancelled' && pairing.cleanupStatus === 'pending';
|
||
const connectionConflict = pairing.lastErrorCategory === 'security_event_connection_conflict';
|
||
const unsafeCredentialHandoff = pairing.lastErrorCategory === 'security_event_credential_handoff_unsafe';
|
||
return (
|
||
<Card>
|
||
<CardContent className="pageStack" aria-busy="true">
|
||
<div className="fileStorageToolbar" role="status" aria-live="polite">
|
||
<div><strong>{cleaning ? '正在清理已放弃的配对' : '正在配对认证中心'}</strong><span>{pairingStatusLabel(pairing.status, pairing.cleanupStatus)}</span></div>
|
||
<Badge variant="secondary">{cleaning ? 'cleanup_pending' : pairing.status}</Badge>
|
||
</div>
|
||
{pairing.lastErrorCategory && (
|
||
<div className="formMessage error" role="alert">
|
||
{pairingFailureMessage(pairing.lastErrorCategory)}(错误分类:{safePairingCategory(pairing.lastErrorCategory)})。{cleaning
|
||
? '后台会继续安全清理。'
|
||
: connectionConflict
|
||
? '此问题不会通过自动重试恢复;请安全退役现有连接,或放弃本次配对。'
|
||
: unsafeCredentialHandoff
|
||
? '此问题不会自动恢复;请先在原配置下断开旧连接,或放弃本次配对。'
|
||
: '后台正在自动重试;也可以放弃本次配对后重新配置。'}
|
||
</div>
|
||
)}
|
||
<div className="fileStorageMeta">
|
||
<span>Exchange: {shortID(pairing.remoteExchangeId)}</span>
|
||
<span>有效期至: {new Date(pairing.expiresAt).toLocaleString()}</span>
|
||
{pairing.lastTraceId && <span>Trace ID: {pairing.lastTraceId}</span>}
|
||
{pairing.authCenterAuditId && <span>Auth Center Audit ID: {pairing.authCenterAuditId}</span>}
|
||
</div>
|
||
{!cleaning && <div className="fileStorageToolbar">
|
||
{connectionConflict && <Button type="button" variant="outline" disabled={loading} onClick={onResolveConnectionConflict}>安全退役现有连接</Button>}
|
||
<Button type="button" variant="outline" disabled={loading} onClick={onCancel}>放弃并清理本次配对</Button>
|
||
</div>}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export function IdentityRuntimeActions({
|
||
loading,
|
||
onDisable,
|
||
onValidate,
|
||
previous,
|
||
}: {
|
||
loading: boolean;
|
||
onDisable: () => void;
|
||
onValidate: () => void;
|
||
previous: IdentityConfigurationRevision | null;
|
||
}) {
|
||
return (
|
||
<div className="fileStorageToolbar">
|
||
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={onValidate}>
|
||
<RefreshCw size={14} />重新验证
|
||
</Button>
|
||
<span className="mutedText" role="note">
|
||
当前 Active 关联认证中心的 OAuth/SSF 资源;请先禁用统一认证,再使用新接入码重新配置。
|
||
</span>
|
||
{previous && (
|
||
<span className="mutedText" role="note">
|
||
旧版本的远端 OAuth/SSF 资源可能已经变化,不能直接回滚;请使用新的接入码恢复。
|
||
</span>
|
||
)}
|
||
<Button type="button" variant="destructive" size="sm" disabled={loading} onClick={onDisable}>
|
||
<Unplug size={14} />禁用统一认证
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) {
|
||
return (
|
||
<div className="fileStorageMeta">
|
||
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
|
||
<span>Audience: {revision.audience || '等待 Manifest'}</span>
|
||
<span>Tenant: {revision.tenantId || '等待 Manifest'}</span>
|
||
<span>本地租户: {revision.localTenantKey}</span>
|
||
<span>登录 Client: {revision.browserClientId || '未启用'}</span>
|
||
<span>服务 Client: {revision.machineClientId || '未启用'}</span>
|
||
<span>能力: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
|
||
<span>Scope: {revision.scopes.join(', ') || '无'}</span>
|
||
{revision.lastTraceId && <span>Trace ID: {revision.lastTraceId}</span>}
|
||
{revision.lastAuditId && <span>Audit ID: {revision.lastAuditId}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IdentityHealth({ label, value }: { label: string; value: string }) {
|
||
const healthy = value === 'healthy' || value === 'push_healthy';
|
||
return <div className="identityHealthItem"><span>{label}</span><Badge variant={healthy ? 'success' : value === 'disabled' ? 'outline' : 'secondary'}>{healthLabel(value)}</Badge></div>;
|
||
}
|
||
|
||
function defaultPairingInput(): IdentityPairingInput {
|
||
const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin;
|
||
const configuredApiBaseUrl = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, '');
|
||
return {
|
||
authCenterUrl: 'https://auth.51easyai.com',
|
||
onboardingCode: '',
|
||
publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '',
|
||
webBaseUrl,
|
||
localTenantKey: 'default',
|
||
legacyJwtEnabled: false,
|
||
};
|
||
}
|
||
|
||
function pairingStatusLabel(status: string, cleanupStatus?: string) {
|
||
if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接';
|
||
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
|
||
}
|
||
|
||
const pairingFailureMessages: Readonly<Record<string, string>> = {
|
||
security_event_configuration_invalid: '安全事件配置不完整或无效',
|
||
security_event_discovery_failed: '无法获取或校验 SSF Discovery(网络、HTTP、JSON、Issuer 或端点异常)',
|
||
security_event_connection_conflict: '已有安全事件连接与本次接入配置冲突',
|
||
security_event_credential_handoff_unsafe: '旧安全事件连接与本次认证中心或服务 Client 不匹配;系统不会发送旧凭据,请先在原配置下断开旧连接',
|
||
security_event_connection_binding_missing: '安全事件连接尚未建立',
|
||
security_event_connection_binding_unavailable: '安全事件连接状态暂时不可用',
|
||
security_event_connection_binding_invalid: '安全事件连接缺少 Revision 绑定信息',
|
||
security_event_connection_binding_mismatch: '认证中心返回的安全事件 Audience 或连接归属与本次配置不一致',
|
||
security_event_retirement_pending: '现有安全事件连接正在安全退役,完成后本次配对会自动继续',
|
||
security_event_management_token_failed: '服务客户端暂时无法取得安全事件管理 Token',
|
||
security_event_stream_create_failed: '安全事件 Stream 创建失败',
|
||
security_event_stream_response_invalid: '安全事件 Stream 返回内容不符合预期',
|
||
security_event_receiver_activation_failed: 'Gateway 安全事件 Receiver 启动失败',
|
||
security_event_preparation_failed: '安全事件能力准备失败',
|
||
metadata_submission_failed: '业务地址提交失败',
|
||
exchange_status_unavailable: '认证中心资源准备状态暂时不可用',
|
||
credential_delivery_failed: '一次性机器凭据领取或保存失败',
|
||
exchange_expired: '一次性接入 Exchange 已过期',
|
||
remote_exchange_failed: '认证中心未能完成应用资源准备,请结合 Audit ID 排查',
|
||
remote_state_invalid: '认证中心返回了不符合当前流程的 Exchange 状态',
|
||
exchange_completion_failed: 'Gateway 暂时无法确认认证中心 Exchange 已完成',
|
||
cleanup_revision_unavailable: '待清理的统一认证草稿暂时不可用',
|
||
cleanup_security_event_unavailable: '安全事件清理服务暂时不可用',
|
||
cleanup_security_event_retirement_pending: '安全事件 Stream 已断开,正在等待安全退役窗口结束',
|
||
cleanup_security_event_connection_cleanup_failed: '安全事件连接清理暂时失败',
|
||
cleanup_security_event_secret_cleanup_failed: '安全事件临时凭据清理暂时失败',
|
||
cleanup_security_event_failed: '安全事件连接清理暂时失败',
|
||
cleanup_secret_store_failed: '统一认证临时凭据清理暂时失败',
|
||
cleanup_finalize_failed: '清理结果暂时无法确认',
|
||
};
|
||
|
||
export function pairingFailureMessage(category?: string) {
|
||
return pairingFailureMessages[category ?? ''] ?? '统一认证配对步骤暂时失败';
|
||
}
|
||
|
||
function safePairingCategory(category: string) {
|
||
return Object.hasOwn(pairingFailureMessages, category) ? category : 'pairing_step_failed';
|
||
}
|
||
|
||
export function isStaleIdentityOperation(caught: unknown) {
|
||
return caught instanceof GatewayApiError && (caught.details.status === 409 || caught.details.status === 412);
|
||
}
|
||
|
||
export async function executeIdentityOperation(options: {
|
||
action: () => Promise<unknown>;
|
||
onRuntimeChanged: () => void;
|
||
onSuccess: (message: string) => void;
|
||
refresh: () => Promise<unknown>;
|
||
runtimeChanged: boolean;
|
||
success: string;
|
||
}) {
|
||
await options.action();
|
||
try {
|
||
await options.refresh();
|
||
} catch (caught) {
|
||
if (!options.runtimeChanged) throw caught;
|
||
const followUp = isIdentityRefreshAuthenticationLoss(caught)
|
||
? '当前统一认证会话已失效,请重新登录。'
|
||
: '最新状态刷新失败,请稍后点击“刷新”确认。';
|
||
options.onSuccess(`${options.success} 操作已提交;${followUp}`);
|
||
options.onRuntimeChanged();
|
||
return true;
|
||
}
|
||
options.onSuccess(options.success);
|
||
if (options.runtimeChanged) options.onRuntimeChanged();
|
||
return true;
|
||
}
|
||
|
||
function isIdentityRefreshAuthenticationLoss(caught: unknown) {
|
||
return caught instanceof GatewayApiError && caught.details.status === 401;
|
||
}
|
||
|
||
function capabilityLabel(value: string) {
|
||
return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record<string, string>)[value] ?? value;
|
||
}
|
||
|
||
function healthLabel(value: string) {
|
||
return ({ healthy: '健康', disabled: '未启用', push_healthy: 'Push 健康', introspection_fallback: 'Introspection 降级', bootstrap: '启动保护' } as Record<string, string>)[value] ?? value;
|
||
}
|
||
|
||
function shortID(value: string) { return value ? `${value.slice(0, 8)}…` : '-'; }
|
||
|
||
function errorMessage(caught: unknown, fallback: string) { return caught instanceof Error ? caught.message : fallback; }
|