- 统一 http.ts baseURL:get/post/download/upload 均走 resolveApiUrl - agentUtils 下沉至 lib/agent-utils,API 层移除对 components 的依赖 - 扩展 metricsApi/snmpApi/prometheusProxyApi/zabbixProxyApi - 扩展 pipelineApi/credentialApi 覆盖流水线与凭证场景 - 14 个组件/页面禁止直连 http,统一走 @/services/api
844 lines
44 KiB
TypeScript
844 lines
44 KiB
TypeScript
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>
|
||
);
|
||
}
|