Files
aiops/frontend/src/components/deploy/CredentialManager.tsx
T
Logan.Li 7fb2236b4d refactor(frontend): P0 API 层收口与 http 统一
- 统一 http.ts baseURL:get/post/download/upload 均走 resolveApiUrl
- agentUtils 下沉至 lib/agent-utils,API 层移除对 components 的依赖
- 扩展 metricsApi/snmpApi/prometheusProxyApi/zabbixProxyApi
- 扩展 pipelineApi/credentialApi 覆盖流水线与凭证场景
- 14 个组件/页面禁止直连 http,统一走 @/services/api
2026-06-25 23:49:46 +08:00

844 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import {
Plus,
Pencil,
Trash2,
RefreshCw,
Key,
Shield,
Globe,
Search,
Lock,
Eye,
EyeOff,
Copy,
Server,
Container,
Database,
FileText,
Cloud,
Clock,
AlertTriangle,
CheckCircle2,
XCircle,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { credentialApi } from '@/services/api';
// ────── Types ──────
interface Credential {
id: number;
name: string;
variable_name: string;
secret_type: SecretType;
summary: string;
tags: string[];
description: string;
expires_at: string | null;
is_active: boolean;
env_prefix: string;
created_at: string;
updated_at: string;
}
type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: 'SSH 密钥', icon: Server, color: 'text-amber-400', desc: 'SSH 私钥/公钥/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'text-cyan-400', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'text-green-400', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'text-purple-400', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'text-blue-400', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'text-orange-400', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'text-gray-400', desc: '自定义 Key/Value 键值对' },
};
const TYPE_BADGE_CLASS: Record<SecretType, string> = {
ssh: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
docker: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
tls: 'bg-green-500/20 text-green-400 border-green-500/30',
aksk: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
database: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
api: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
generic: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
// ────── Detail Dialog ──────
function CredDetailDialog({ open, onClose, credId }: { open: boolean; onClose: () => void; credId: number | null }) {
const [detail, setDetail] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!credId || !open) return;
setLoading(true);
credentialApi.get(credId).then(( res: any) => {
setDetail(res);
}).catch(() => toast.error('加载详情失败')).finally(() => setLoading(false));
}, [credId, open]);
if (!open || !credId) return null;
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success('已复制'); };
const st = detail?.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const content = detail?.content || {};
const Field = ({ label, value, sensitive, fieldKey }: { label: string; value: any; sensitive?: boolean; fieldKey?: string }) => {
if (value === undefined || value === null || value === '') return null;
const isLong = typeof value === 'string' && value.length > 80;
const show = fieldKey ? showMap[fieldKey] : false;
return (
<div className="mb-3">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)] mb-1">
<Lock className="h-3 w-3" />{label}
</div>
<div className="flex gap-1">
{isLong ? (
<textarea readOnly value={sensitive && !show ? '••••••••' : value}
className="flex-1 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-xs font-mono resize-none"
rows={4} />
) : (
<Input readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-xs" />
)}
<div className="flex flex-col gap-0.5 shrink-0">
{sensitive && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => toggleShow(fieldKey!)}>
{show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copyValue(String(value))}>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
{cfg ? <cfg.icon className={`h-5 w-5 ${cfg.color}`} /> : <Key className="h-5 w-5 text-[var(--dg-text-muted)]" />}
凭据详情
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">加载中...</div>
) : detail ? (
<div className="space-y-4">
{/* Basic info */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-[var(--dg-text-muted)]">名称:</span><span className="text-[var(--dg-text-primary)] font-medium">{detail.name}</span></div>
<div><span className="text-[var(--dg-text-muted)]">类型:</span>
<Badge variant="outline" className={`text-xs ml-1 ${TYPE_BADGE_CLASS[st] || ''}`}>{cfg?.label || st}</Badge>
</div>
<div><span className="text-[var(--dg-text-muted)]">状态:</span>
{detail.is_active
? <Badge className="bg-green-500/20 text-green-400 text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
: <Badge className="bg-red-500/20 text-red-400 text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
{detail.expires_at && new Date(detail.expires_at) < new Date() && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue(detail.env_prefix || '')}>
<span className="text-[var(--dg-text-muted)]">引用ID:</span>
<code className="text-[var(--dg-blue)] font-mono text-xs">{detail.env_prefix || '无'}</code>
<Copy className="h-3 w-3 inline ml-1 text-[var(--dg-text-muted)]" />
</div>
{detail.tags?.length > 0 && (
<div className="col-span-2">
<span className="text-[var(--dg-text-muted)]">标签:</span>
{detail.tags.map((t: string) => (
<Badge key={t} variant="outline" className="text-xs ml-1 border-[var(--dg-border)]">{t}</Badge>
))}
</div>
)}
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">描述:</span><span className="text-[var(--dg-text-secondary)]">{detail.description || '无'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">创建:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detail.created_at).toLocaleString()}</span></div>
<div><span className="text-[var(--dg-text-muted)]">更新:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detail.updated_at).toLocaleString()}</span></div>
</div>
{/* Secret data */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)] font-medium mb-3">
<Key className="h-4 w-4 text-yellow-500" />密钥数据
</div>
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
<p className="text-xs text-yellow-400/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
</div>
{st === 'ssh' && <>
<Field label="SSH 用户" value={content.ssh_user} />
<Field label="SSH 端口" value={content.ssh_port} />
<Field label="SSH 密码" value={content.password} sensitive fieldKey="ssh_password" />
<Field label="私钥" value={content.private_key} sensitive fieldKey="ssh_private_key" />
<Field label="公钥" value={content.public_key} fieldKey="ssh_public_key" />
</>}
{st === 'docker' && <>
<Field label="Registry 地址" value={content.server} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="docker_password" />
</>}
{st === 'tls' && <>
<Field label="证书" value={content.cert} sensitive fieldKey="tls_cert" />
<Field label="私钥" value={content.key} sensitive fieldKey="tls_key" />
<Field label="CA 证书" value={content.ca} sensitive fieldKey="tls_ca" />
</>}
{st === 'aksk' && <>
<Field label="Access Key ID" value={content.access_key} />
<Field label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" />
</>}
{st === 'database' && <>
<Field label="数据库类型" value={(content.db_type || '').toUpperCase()} />
<Field label="主机" value={content.host} />
<Field label="端口" value={content.port} />
<Field label="数据库名" value={content.db_name} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="db_password" />
</>}
{st === 'api' && <>
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
<Field label="API 端点" value={content.endpoint} />
</>}
{st === 'generic' && <>
<Field label="Key" value={content.generic_key} />
<Field label="Value" value={content.generic_value} sensitive fieldKey="generic_value" />
</>}
</div>
{/* Usage hint */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">在流水线/脚本中引用此凭据:</div>
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-3 py-1.5 rounded block text-[var(--dg-blue)]">
{'${'}{detail.variable_name || detail.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}{'}'}
</code>
</div>
</div>
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">未找到凭据数据</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ────── Form Dialog ──────
function CredFormDialog({ open, onClose, onSaved, editData }: {
open: boolean; onClose: () => void; onSaved: () => void; editData: Credential | null;
}) {
const isEdit = !!editData;
const [name, setName] = useState('');
const [varName, setVarName] = useState('');
const [secretType, setSecretType] = useState<SecretType>('ssh');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [isActive, setIsActive] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
// Type-specific fields
// SSH
const [sshUser, setSshUser] = useState('root');
const [sshPort, setSshPort] = useState('22');
const [sshPassword, setSshPassword] = useState('');
const [sshPrivateKey, setSshPrivateKey] = useState('');
const [sshPublicKey, setSshPublicKey] = useState('');
// Docker
const [dockerServer, setDockerServer] = useState('');
const [dockerUsername, setDockerUsername] = useState('');
const [dockerPassword, setDockerPassword] = useState('');
// TLS
const [tlsCert, setTlsCert] = useState('');
const [tlsKey, setTlsKey] = useState('');
const [tlsCa, setTlsCa] = useState('');
// AKSK
const [, setAkskProvider] = useState('');
const [akskAccessKey, setAkskAccessKey] = useState('');
const [akskSecretKey, setAkskSecretKey] = useState('');
const [, setAkskRegion] = useState('');
// Database
const [dbType, setDbType] = useState('mysql');
const [dbHost, setDbHost] = useState('');
const [dbPort, setDbPort] = useState('3306');
const [dbName, setDbName] = useState('');
const [dbUsername, setDbUsername] = useState('');
const [dbPassword, setDbPassword] = useState('');
// API
const [apiKey, setApiKey] = useState('');
const [apiEndpoint, setApiEndpoint] = useState('');
// Generic
const [genericKey, setGenericKey] = useState('');
const [genericValue, setGenericValue] = useState('');
useEffect(() => {
if (!open) return;
if (editData) {
setName(editData.name);
setVarName(editData.variable_name || '');
setSecretType(editData.secret_type);
setDescription(editData.description || '');
setTags((editData.tags || []).join(', '));
setIsActive(editData.is_active);
// Don't prefill type-specific fields — user can edit selectively
setSshUser(''); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
} else {
setName(''); setVarName(''); setSecretType('ssh'); setDescription(''); setTags(''); setIsActive(true);
setSshUser('root'); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
}
setShowMap({});
}, [editData, open]);
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const handleSubmit = async () => {
if (!name.trim()) { toast.error('请填写凭据名称'); return; }
setSubmitting(true);
try {
const payload: Record<string, any> = {
name: name.trim(),
secret_type: secretType,
description: description.trim(),
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
is_active: isActive,
};
if (varName.trim()) payload.variable_name = varName.trim();
// Type-specific
if (secretType === 'ssh') {
if (sshUser.trim()) payload.ssh_user = sshUser.trim();
if (sshPort) payload.ssh_port = parseInt(sshPort) || 22;
if (sshPassword.trim()) payload.ssh_password = sshPassword.trim();
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
} else if (secretType === 'docker') {
if (dockerServer.trim()) payload.docker_server = dockerServer.trim();
if (dockerUsername.trim()) payload.docker_username = dockerUsername.trim();
if (dockerPassword.trim()) payload.docker_password = dockerPassword.trim();
} else if (secretType === 'tls') {
if (tlsCert.trim()) payload.tls_cert = tlsCert.trim();
if (tlsKey.trim()) payload.tls_key = tlsKey.trim();
if (tlsCa.trim()) payload.tls_ca = tlsCa.trim();
} else if (secretType === 'aksk') {
if (akskAccessKey.trim()) payload.aksk_access_key = akskAccessKey.trim();
if (akskSecretKey.trim()) payload.aksk_secret_key = akskSecretKey.trim();
} else if (secretType === 'database') {
if (dbType.trim()) payload.db_type = dbType.trim();
if (dbHost.trim()) payload.db_host = dbHost.trim();
if (dbPort) payload.db_port = parseInt(dbPort) || 3306;
if (dbName.trim()) payload.db_name = dbName.trim();
if (dbUsername.trim()) payload.db_username = dbUsername.trim();
if (dbPassword.trim()) payload.db_password = dbPassword.trim();
} else if (secretType === 'api') {
if (apiKey.trim()) payload.api_key = apiKey.trim();
if (apiEndpoint.trim()) payload.api_endpoint = apiEndpoint.trim();
} else if (secretType === 'generic') {
if (genericKey.trim()) payload.generic_key = genericKey.trim();
if (genericValue.trim()) payload.generic_value = genericValue.trim();
}
if (isEdit) {
await credentialApi.update(editData!.id, payload);
toast.success('已更新');
} else {
await credentialApi.create(payload);
toast.success('已创建');
}
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message || '操作失败');
} finally { setSubmitting(false); }
};
const FieldWithToggle = ({ label, value, onChange, placeholder, fieldKey, required, isLong }: {
label: string; value: string; onChange: (v: string) => void; placeholder: string;
fieldKey: string; required?: boolean; isLong?: boolean;
}) => {
const show = showMap[fieldKey];
return (
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{label}{required && !isEdit && <span className="text-red-400"> *</span>}</Label>
<div className="relative">
{isLong ? (
<textarea value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder} rows={5}
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-sm font-mono placeholder:text-[var(--dg-text-muted)]" />
) : (
<Input value={value} onChange={e => onChange(e.target.value)}
type={show ? 'text' : 'password'} placeholder={placeholder}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] pr-10 font-mono text-sm" />
)}
<button type="button" onClick={() => toggleShow(fieldKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] text-xs">
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
};
const TI = TYPE_CONFIG[secretType];
const TIicon = TI?.icon || Key;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[620px] max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<TIicon className={`h-5 w-5 ${TI?.color || ''}`} />
{isEdit ? '编辑凭据' : '新增凭据'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)] text-xs">
{TI?.desc || '管理敏感凭据信息'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Basic */}
<div className="border-b border-[var(--dg-border)] pb-3">
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">基本信息</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="text-red-400">*</span></Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:生产环境 SSH"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">变量别名</Label>
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder="如:MYSQL_PROD"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">类型{!isEdit && <span className="text-red-400"> *</span>}</Label>
<Select value={secretType} onValueChange={(v) => setSecretType(v as SecretType)} disabled={isEdit}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{(Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][]).map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-4 w-4" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">标签</Label>
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder="生产环境, 阿里云"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
<div className="mt-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">描述</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选备注信息"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
{/* Type-specific fields */}
<div>
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2 flex items-center gap-2">
<TIicon className="h-3.5 w-3.5" />{TI?.label || secretType} 配置
{isEdit && <span className="text-[var(--dg-text-muted)]">(仅填写需要修改的字段)</span>}
</div>
{secretType === 'ssh' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 用户</Label>
<Input value={sshUser} onChange={e => setSshUser(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 端口</Label>
<Input value={sshPort} onChange={e => setSshPort(e.target.value)} placeholder="22" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="col-span-2">
<FieldWithToggle label="SSH 密码" value={sshPassword} onChange={setSshPassword}
placeholder="密码(或留空用密钥)" fieldKey="ssh_pwd" />
</div>
<div className="col-span-2">
<FieldWithToggle label="私钥内容" value={sshPrivateKey} onChange={setSshPrivateKey}
placeholder="粘贴 SSH 私钥..." fieldKey="ssh_pk" isLong />
</div>
<div className="col-span-2">
<FieldWithToggle label="公钥内容" value={sshPublicKey} onChange={setSshPublicKey}
placeholder="粘贴 SSH 公钥..." fieldKey="ssh_pub" isLong />
</div>
</div>
)}
{secretType === 'docker' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="text-red-400">*</span></Label>
<Input value={dockerServer} onChange={e => setDockerServer(e.target.value)} placeholder="registry.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dockerUsername} onChange={e => setDockerUsername(e.target.value)} placeholder="admin"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dockerPassword} onChange={setDockerPassword}
placeholder="Registry 密码" fieldKey="docker_pwd" required /></div>
</div>
)}
{secretType === 'tls' && (
<div className="space-y-3">
<FieldWithToggle label="证书 (PEM)" value={tlsCert} onChange={setTlsCert}
placeholder="-----BEGIN CERTIFICATE-----" fieldKey="tls_cert" isLong required />
<FieldWithToggle label="私钥 (PEM)" value={tlsKey} onChange={setTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----" fieldKey="tls_key" isLong required />
<FieldWithToggle label="CA 证书" value={tlsCa} onChange={setTlsCa}
placeholder="CA 证书内容..." fieldKey="tls_ca" isLong />
</div>
)}
{secretType === 'aksk' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Access Key ID <span className="text-red-400">*</span></Label>
<Input value={akskAccessKey} onChange={e => setAkskAccessKey(e.target.value)} placeholder="AKIDxxxx..."
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="col-span-2">
<FieldWithToggle label="Secret Access Key" value={akskSecretKey} onChange={setAkskSecretKey}
placeholder="输入 Secret Key" fieldKey="aksk_sk" required />
</div>
</div>
)}
{secretType === 'database' && (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库类型</Label>
<Select value={dbType} onValueChange={setDbType}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mongodb">MongoDB</SelectItem>
<SelectItem value="redis">Redis</SelectItem>
<SelectItem value="mssql">SQL Server</SelectItem>
<SelectItem value="oracle">Oracle</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="text-red-400">*</span></Label>
<Input value={dbHost} onChange={e => setDbHost(e.target.value)} placeholder="10.0.0.1"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">端口</Label>
<Input value={dbPort} onChange={e => setDbPort(e.target.value)} placeholder="3306" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库名</Label>
<Input value={dbName} onChange={e => setDbName(e.target.value)} placeholder="aiops"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dbUsername} onChange={e => setDbUsername(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dbPassword} onChange={setDbPassword}
placeholder="数据库密码" fieldKey="db_pwd" /></div>
</div>
)}
{secretType === 'api' && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">API 端点</Label>
<Input value={apiEndpoint} onChange={e => setApiEndpoint(e.target.value)} placeholder="https://api.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="API Key / Token" value={apiKey} onChange={setApiKey}
placeholder="sk-xxxx..." fieldKey="api_key" required />
</div>
)}
{secretType === 'generic' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Key <span className="text-red-400">*</span></Label>
<Input value={genericKey} onChange={e => setGenericKey(e.target.value)} placeholder="MY_SECRET"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="Value" value={genericValue} onChange={setGenericValue}
placeholder="密钥值" fieldKey="generic_val" required />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/80">
{submitting ? '保存中...' : (isEdit ? '更新' : '创建')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ────── Main Manager ──────
export default function CredentialManager() {
const confirm = useConfirm();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// Filters
const [filterType, setFilterType] = useState<string>('');
const [filterSearch, setFilterSearch] = useState('');
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res: Record<string, any> = await credentialApi.list({
page_size: 100,
...(filterType ? { secret_type: filterType } : {}),
...(filterSearch ? { search: filterSearch } : {}),
});
setCredentials(res?.list || []);
} catch { toast.error('获取凭证列表失败'); }
finally { setLoading(false); }
}, [filterType, filterSearch]);
useEffect(() => { fetchAll(); }, [fetchAll]);
const handleDelete = async (cred: Credential) => {
const ok = await confirm({
title: '删除凭据',
description: `确定删除凭据「${cred.name}」?此操作不可撤销。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await credentialApi.delete(cred.id);
toast.success('已删除');
fetchAll();
} catch { toast.error('删除失败'); }
};
const handleToggleActive = async (cred: Credential) => {
try {
await credentialApi.toggleActive(cred.id, !cred.is_active);
toast.success(cred.is_active ? '已禁用' : '已启用');
fetchAll();
} catch { toast.error('操作失败'); }
};
const handleCopyPrefix = (prefix: string) => {
navigator.clipboard.writeText(prefix);
toast.success('引用 ID 已复制');
};
const typeOptions = Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">凭据管理</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">
统一管理 SSH 密钥、Docker 密码、AKSK、数据库等敏感凭据(AES 加密存储)
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增凭据
</Button>
</div>
</div>
{/* Search/Filter */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[140px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
{typeOptions.map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-3.5 w-3.5" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input value={filterSearch} onChange={e => setFilterSearch(e.target.value)}
placeholder="搜索名称、描述..."
className="pl-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm" />
</div>
<span className="text-xs text-[var(--dg-text-muted)]">{credentials.length} 个凭据</span>
</div>
{loading ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Zap className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-blue)]" />加载中...
</div>
) : credentials.length === 0 ? (
<div className="text-center py-16 border border-dashed border-[var(--dg-border)] rounded-lg">
<Shield className="w-12 h-12 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2">暂无凭据</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">创建 SSH、Docker、AKSK 等类型的凭据,供流水线和服务器使用</p>
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />创建第一个凭据
</Button>
</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">摘要</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden lg:table-cell">标签</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden xl:table-cell">引用ID</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{credentials.map(cred => {
const st = cred.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const isExpired = cred.expires_at && new Date(cred.expires_at) < new Date();
return (
<tr key={cred.id}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50 cursor-pointer"
onClick={() => { setDetailId(cred.id); setDetailOpen(true); }}>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
{cfg ? <cfg.icon className={`h-4 w-4 ${cfg.color} shrink-0`} /> : <Key className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />}
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{cred.name}</div>
{cred.variable_name && (
<div className="text-xs text-[var(--dg-text-muted)] font-mono">{cred.variable_name}</div>
)}
</div>
</div>
</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className={`text-xs ${TYPE_BADGE_CLASS[st] || ''}`}>
{cfg?.label || st}
</Badge>
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] text-xs hidden md:table-cell max-w-[180px] truncate font-mono">
{cred.summary || '-'}
</td>
<td className="px-3 py-2.5 hidden lg:table-cell">
<div className="flex gap-1 flex-wrap">
{(cred.tags || []).slice(0, 3).map(t => (
<Badge key={t} variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-muted)]">{t}</Badge>
))}
{(cred.tags || []).length > 3 && (
<span className="text-xs text-[var(--dg-text-muted)]">+{cred.tags.length - 3}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 hidden xl:table-cell">
<code className="text-xs text-[var(--dg-blue)] cursor-pointer font-mono"
onClick={(e) => { e.stopPropagation(); handleCopyPrefix(cred.env_prefix); }}>
{cred.env_prefix}
</code>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1">
{cred.is_active ? (
<Badge className="bg-green-500/20 text-green-400 text-xs border-0">启用</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 text-xs border-0">禁用</Badge>
)}
{isExpired && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs border-0"><Clock className="h-2.5 w-2.5 mr-0.5" />过期</Badge>
)}
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditing(cred); setDialogOpen(true); }} title="编辑">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleActive(cred)}
title={cred.is_active ? '禁用' : '启用'}>
{cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => handleDelete(cred)} title="删除">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={fetchAll} editData={editing} />
<CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} />
</div>
);
}