feat(web): 增加统一认证接入与运维页面
系统设置新增统一认证配对、验证、激活、重验证、回滚与禁用流程,安全事件改为统一能力状态。前端登录入口运行时读取公开配置,不再依赖 VITE_OIDC 构建变量。\n\n验证:web 31 项测试;web typecheck;pnpm lint;web production build
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
|
||||
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
|
||||
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
|
||||
|
||||
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
|
||||
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity';
|
||||
|
||||
type ClientCustomizationForm = {
|
||||
clientEnglishName: string;
|
||||
@@ -54,21 +55,16 @@ const resultUploadPolicyOptions = [
|
||||
];
|
||||
|
||||
export function SystemSettingsPanel(props: {
|
||||
token: string;
|
||||
channels: FileStorageChannel[];
|
||||
clientCustomizationSettings: ClientCustomizationSettings | null;
|
||||
message: string;
|
||||
settings: FileStorageSettings | null;
|
||||
securityEventConnection: SecurityEventConnectionResponse | null;
|
||||
state: LoadState;
|
||||
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
|
||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
|
||||
onDisconnectSecurityEvents: () => Promise<void>;
|
||||
onRefreshSecurityEvents: () => Promise<void>;
|
||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
||||
onVerifySecurityEvents: () => Promise<void>;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -78,8 +74,6 @@ export function SystemSettingsPanel(props: {
|
||||
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
||||
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
|
||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||
@@ -89,13 +83,6 @@ export function SystemSettingsPanel(props: {
|
||||
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
||||
}, [props.clientCustomizationSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
|
||||
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
|
||||
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingChannel(null);
|
||||
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
|
||||
@@ -177,7 +164,7 @@ export function SystemSettingsPanel(props: {
|
||||
tabs={[
|
||||
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
|
||||
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
|
||||
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
|
||||
{ value: 'identity', label: '统一认证', icon: <ShieldCheck size={15} /> },
|
||||
]}
|
||||
onValueChange={setActiveTab}
|
||||
/>
|
||||
@@ -291,19 +278,7 @@ export function SystemSettingsPanel(props: {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'securityEvents' && (
|
||||
<SecurityEventConnectionPanel
|
||||
connection={props.securityEventConnection}
|
||||
issuer={transmitterIssuer}
|
||||
loading={props.state === 'loading'}
|
||||
onIssuerChange={setTransmitterIssuer}
|
||||
onConnect={props.onConnectSecurityEvents}
|
||||
onDisconnect={() => setDisconnectOpen(true)}
|
||||
onRefresh={props.onRefreshSecurityEvents}
|
||||
onRotate={props.onRotateSecurityEventsCredential}
|
||||
onVerify={props.onVerifySecurityEvents}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'identity' && <UnifiedIdentityPanel token={props.token} />}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
|
||||
@@ -393,137 +368,10 @@ export function SystemSettingsPanel(props: {
|
||||
onCancel={() => setPendingDeleteChannel(null)}
|
||||
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
confirmLabel="断开连接"
|
||||
description="系统会先删除远端 Stream,并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
|
||||
loading={props.state === 'loading'}
|
||||
open={disconnectOpen}
|
||||
title="确认断开认证中心安全事件连接?"
|
||||
onCancel={() => setDisconnectOpen(false)}
|
||||
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityEventConnectionPanel(props: {
|
||||
connection: SecurityEventConnectionResponse | null;
|
||||
issuer: string;
|
||||
loading: boolean;
|
||||
onIssuerChange(value: string): void;
|
||||
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
|
||||
onDisconnect(): void;
|
||||
onRefresh(): Promise<void>;
|
||||
onRotate(): Promise<void>;
|
||||
onVerify(): Promise<void>;
|
||||
}) {
|
||||
const connection = props.connection?.connection;
|
||||
const prerequisites = props.connection?.prerequisites;
|
||||
const [managementClientId, setManagementClientId] = useState('');
|
||||
const [managementClientSecret, setManagementClientSecret] = useState('');
|
||||
useEffect(() => {
|
||||
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
|
||||
}, [managementClientId, prerequisites?.managementClientId]);
|
||||
const missing = [
|
||||
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
||||
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<section className="fileStoragePanel">
|
||||
<div className="fileStorageSettingsCard">
|
||||
<div>
|
||||
<strong>SSF / CAEP 实时会话撤销</strong>
|
||||
<span>只需连接认证中心。Gateway 后端自动生成并托管 Push Bearer,复用现有 RFC 7662 机器 Client,不需要重启。</span>
|
||||
</div>
|
||||
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
|
||||
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
|
||||
</Badge>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
|
||||
<RefreshCw size={14} />刷新
|
||||
</Button>
|
||||
</div>
|
||||
{!connection && (
|
||||
<Card>
|
||||
<CardContent className="pageStack">
|
||||
<div>
|
||||
<strong>连接认证中心</strong>
|
||||
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
||||
</div>
|
||||
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
||||
<Label>
|
||||
SSF Transmitter Issuer
|
||||
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
|
||||
<small>Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client ID
|
||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client Secret
|
||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||
<small>浏览器只负责本次传递;Gateway 后端会立即写入 SecretStore,数据库、响应和日志均不保存明文。</small>
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
|
||||
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||
<Link2 size={15} />连接认证中心
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{connection && (
|
||||
<Card>
|
||||
<CardContent className="pageStack">
|
||||
<div className="fileStorageMeta">
|
||||
<span>机器 Client: {connection.managementClientId}</span>
|
||||
<span>Receiver Endpoint: {connection.receiverEndpoint}</span>
|
||||
<span>Issuer: {connection.transmitterIssuer}</span>
|
||||
<span>Audience: {connection.audience ?? '正在获取'}</span>
|
||||
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
|
||||
<span>健康模式: {securityEventHealthLabel(connection.healthMode)}</span>
|
||||
<span>最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
|
||||
{props.connection?.traceId && <span>最近操作 Trace ID: {props.connection.traceId}</span>}
|
||||
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
||||
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
||||
</div>
|
||||
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
|
||||
<div className="formMessage">认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。</div>
|
||||
<Label>
|
||||
Machine Client ID
|
||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
||||
</Label>
|
||||
<Label>
|
||||
Machine Client Secret
|
||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
||||
</Label>
|
||||
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
|
||||
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
||||
<Link2 size={15} />保存凭据并恢复连接
|
||||
</Button>
|
||||
</div>}
|
||||
<div className="fileStorageToolbar">
|
||||
{connection.lifecycleStatus === 'error' && (
|
||||
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} />重试连接</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={() => void props.onVerify()}><RefreshCw size={14} />重新验证</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.loading || connection.lifecycleStatus === 'rotating'} onClick={() => void props.onRotate()}><RotateCcw size={14} />轮换接收凭据</Button>
|
||||
<Button type="button" variant="destructive" size="sm" disabled={props.loading || connection.lifecycleStatus === 'retiring'} onClick={props.onDisconnect}><Unplug size={14} />断开连接</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function securityEventLifecycleLabel(status?: string) {
|
||||
return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record<string, string>)[status ?? ''] ?? '未连接';
|
||||
}
|
||||
|
||||
function securityEventHealthLabel(mode: string) {
|
||||
return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record<string, string>)[mode] ?? mode;
|
||||
}
|
||||
|
||||
function defaultClientCustomizationForm(): ClientCustomizationForm {
|
||||
return {
|
||||
clientEnglishName: 'EasyAI AI Gateway',
|
||||
|
||||
Reference in New Issue
Block a user