feat: 凭证类型化表单重构(参考 LokiStack/aiops),移除 aksk
- 类型精简为发布流程所需 4 种:SSH 密钥 / Docker 认证 / Git 仓库 / Kubernetes(旧 password/kubeconfig/aksk 仅作历史数据展示,新建不提供) - 类型化表单替代通用 JSON textarea(aiops 模式): ssh=私钥/用户/端口/密码;docker=Registry/用户/Token; git=仓库/用户/Token;编辑时留空=不修改,类型不可改 - Kubernetes 凭证双模式(LokiStack 模式),默认 Token(推荐): apiServer+命名空间+Token,或整份 Kubeconfig+context - 命名空间长期授权引导:折叠块内一键复制 kubectl 命令 (Namespace+SA+命名空间级 Role/RoleBinding+长期 Token Secret), 命名空间随表单动态注入;附最小权限安全说明 - 后端摘要支持 kubernetes 类型(K8s Token ns@server / Kubeconfig)
This commit is contained in:
1 parent
935a29e2f1
commit
ee59706ab9
2 files changed
+304
-24
No files matched your search
@@ -10,7 +10,9 @@ import {
|
||||
GitBranch,
|
||||
Container,
|
||||
Cloud,
|
||||
Boxes,
|
||||
Loader2,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -22,14 +24,19 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
||||
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
|
||||
ssh: { icon: Key, label: 'SSH 密钥', color: '#0969da', bg: '#ddf4ff' },
|
||||
docker: { icon: Container, label: 'Docker 认证', color: '#1a7f37', bg: '#dafbe1' },
|
||||
git: { icon: GitBranch, label: 'Git 仓库', color: '#8250df', bg: '#fbefff' },
|
||||
kubernetes: { icon: Boxes, label: 'Kubernetes', color: '#bc4c00', bg: '#fff1e5' },
|
||||
// 旧类型仅作历史数据展示,不再提供新建
|
||||
password: { icon: Shield, label: '用户名密码', color: '#9a6700', bg: '#fff8c5' },
|
||||
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
|
||||
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
|
||||
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
|
||||
aksk: { icon: Cloud, label: 'AK/SK', color: '#0f766e', bg: '#ccfbf1' },
|
||||
};
|
||||
|
||||
// 新建可选类型(发布流程所需:ssh / docker / git / kubernetes)
|
||||
const CREATE_TYPES = ['ssh', 'docker', 'git', 'kubernetes'];
|
||||
|
||||
const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' };
|
||||
|
||||
function getConfig(type: string) {
|
||||
@@ -45,10 +52,133 @@ interface CredentialForm {
|
||||
name: string;
|
||||
type: string;
|
||||
scope: string;
|
||||
content: string;
|
||||
// ssh
|
||||
privateKey: string; sshUser: string; sshPort: string; sshPassword: string;
|
||||
// docker
|
||||
dockerServer: string; dockerUsername: string; dockerPassword: string;
|
||||
// git
|
||||
gitUrl: string; gitUsername: string; gitToken: string;
|
||||
// kubernetes
|
||||
k8sMode: 'token' | 'kubeconfig';
|
||||
k8sApiServer: string; k8sNamespace: string; k8sToken: string;
|
||||
k8sKubeconfig: string; k8sContext: string;
|
||||
rbacOpen: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: CredentialForm = { name: '', type: 'ssh', scope: '全局', content: '' };
|
||||
const emptyForm: CredentialForm = {
|
||||
name: '', type: 'ssh', scope: '全局',
|
||||
privateKey: '', sshUser: 'root', sshPort: '22', sshPassword: '',
|
||||
dockerServer: '', dockerUsername: '', dockerPassword: '',
|
||||
gitUrl: '', gitUsername: '', gitToken: '',
|
||||
k8sMode: 'token', k8sApiServer: '', k8sNamespace: 'flowstack', k8sToken: '',
|
||||
k8sKubeconfig: '', k8sContext: '',
|
||||
rbacOpen: false,
|
||||
};
|
||||
|
||||
// 按类型组装加密内容 JSON(编辑时仅提交非空字段 = 留空不修改)
|
||||
function buildContent(form: CredentialForm): string | undefined {
|
||||
const has = (s: string) => s && s.trim() !== '';
|
||||
let obj: Record<string, any>;
|
||||
switch (form.type) {
|
||||
case 'ssh':
|
||||
obj = {};
|
||||
if (has(form.privateKey)) obj.private_key = form.privateKey.trim();
|
||||
if (has(form.sshUser)) obj.ssh_user = form.sshUser.trim();
|
||||
if (has(form.sshPort)) obj.ssh_port = Number(form.sshPort) || 22;
|
||||
if (has(form.sshPassword)) obj.password = form.sshPassword;
|
||||
break;
|
||||
case 'docker':
|
||||
obj = {};
|
||||
if (has(form.dockerServer)) obj.server = form.dockerServer.trim();
|
||||
if (has(form.dockerUsername)) obj.username = form.dockerUsername.trim();
|
||||
if (has(form.dockerPassword)) obj.password = form.dockerPassword;
|
||||
break;
|
||||
case 'git':
|
||||
obj = {};
|
||||
if (has(form.gitUrl)) obj.url = form.gitUrl.trim();
|
||||
if (has(form.gitUsername)) obj.username = form.gitUsername.trim();
|
||||
if (has(form.gitToken)) obj.token = form.gitToken;
|
||||
break;
|
||||
case 'kubernetes':
|
||||
obj = { mode: form.k8sMode };
|
||||
if (form.k8sMode === 'token') {
|
||||
if (has(form.k8sApiServer)) obj.apiServer = form.k8sApiServer.trim();
|
||||
if (has(form.k8sNamespace)) obj.namespace = form.k8sNamespace.trim();
|
||||
if (has(form.k8sToken)) obj.token = form.k8sToken.trim();
|
||||
} else {
|
||||
if (has(form.k8sKubeconfig)) obj.kubeconfig = form.k8sKubeconfig.trim();
|
||||
if (has(form.k8sContext)) obj.context = form.k8sContext.trim();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
const values = Object.values(obj).filter((v) => v !== undefined && v !== '' && v !== 'mode');
|
||||
if (values.length === 0) return undefined;
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
// 命名空间最小授权命令(参考 LokiStack:SA + 命名空间级 Role + 长期 Token)
|
||||
function buildRbacCommand(ns: string): string {
|
||||
const safeNs = (ns && ns.trim()) || 'flowstack';
|
||||
return [
|
||||
"# 1) 一次创建:命名空间 + ServiceAccount + 命名空间级 Role + 长期 Token",
|
||||
"kubectl apply -f - <<'YAML'",
|
||||
"apiVersion: v1",
|
||||
"kind: Namespace",
|
||||
"metadata:",
|
||||
" name: " + safeNs,
|
||||
"---",
|
||||
"apiVersion: v1",
|
||||
"kind: ServiceAccount",
|
||||
"metadata:",
|
||||
" name: flowstack",
|
||||
" namespace: " + safeNs,
|
||||
"---",
|
||||
"apiVersion: rbac.authorization.k8s.io/v1",
|
||||
"kind: Role",
|
||||
"metadata:",
|
||||
" name: flowstack",
|
||||
" namespace: " + safeNs,
|
||||
"rules:",
|
||||
' - apiGroups: [""]',
|
||||
' resources: ["pods", "pods/log", "services", "configmaps", "secrets", "events"]',
|
||||
' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]',
|
||||
' - apiGroups: ["apps"]',
|
||||
' resources: ["deployments", "statefulsets", "daemonsets"]',
|
||||
' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]',
|
||||
' - apiGroups: ["networking.k8s.io"]',
|
||||
' resources: ["ingresses"]',
|
||||
' verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]',
|
||||
"---",
|
||||
"apiVersion: rbac.authorization.k8s.io/v1",
|
||||
"kind: RoleBinding",
|
||||
"metadata:",
|
||||
" name: flowstack",
|
||||
" namespace: " + safeNs,
|
||||
"subjects:",
|
||||
" - kind: ServiceAccount",
|
||||
" name: flowstack",
|
||||
" namespace: " + safeNs,
|
||||
"roleRef:",
|
||||
" kind: Role",
|
||||
" name: flowstack",
|
||||
" apiGroup: rbac.authorization.k8s.io",
|
||||
"---",
|
||||
"apiVersion: v1",
|
||||
"kind: Secret",
|
||||
"metadata:",
|
||||
" name: flowstack-token",
|
||||
" namespace: " + safeNs,
|
||||
" annotations:",
|
||||
" kubernetes.io/service-account.name: flowstack",
|
||||
"type: kubernetes.io/service-account-token",
|
||||
"YAML",
|
||||
"",
|
||||
"# 2) 取出长期 Token(不自动过期),粘贴到上方 Token 输入框",
|
||||
"kubectl -n " + safeNs + " get secret flowstack-token -o jsonpath='{.data.token}' | base64 -d",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
@@ -81,8 +211,8 @@ export default function CredentialManager() {
|
||||
setEditing(cred ?? null);
|
||||
setForm(
|
||||
cred
|
||||
? { name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '', content: '' }
|
||||
: emptyForm,
|
||||
? { ...emptyForm, name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '' }
|
||||
: { ...emptyForm },
|
||||
);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -114,9 +244,14 @@ export default function CredentialManager() {
|
||||
toast.error('请填写凭证名称');
|
||||
return;
|
||||
}
|
||||
// 内容加密存储(write-only):编辑时留空表示不修改
|
||||
// 内容加密存储(write-only):编辑时字段留空表示不修改
|
||||
const content = buildContent(form);
|
||||
if (!editing && !content) {
|
||||
toast.error('请填写凭证内容');
|
||||
return;
|
||||
}
|
||||
const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() };
|
||||
if (form.content.trim()) body.content = form.content.trim();
|
||||
if (content) body.content = content;
|
||||
saveMutation.mutate({ id: editing?.id, body });
|
||||
};
|
||||
|
||||
@@ -338,14 +473,14 @@ export default function CredentialManager() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>类型</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })} disabled={!!editing}>
|
||||
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(typeConfig).map(([type, config]) => (
|
||||
{CREATE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type} className="text-[12px]">
|
||||
{config.label}
|
||||
{typeConfig[type].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -362,18 +497,157 @@ export default function CredentialManager() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>内容</Label>
|
||||
<Textarea
|
||||
className="text-[12px] min-h-[88px] font-mono"
|
||||
style={inputStyle}
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
placeholder={editing ? '留空表示不修改' : '私钥 / 密码 / Token / kubeconfig / AK·SK 等'}
|
||||
/>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
内容加密存储,保存后不可查看。
|
||||
</div>
|
||||
{/* ---- 类型化字段(编辑时留空 = 不修改) ---- */}
|
||||
{form.type === 'ssh' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>私钥(PEM)</Label>
|
||||
<Textarea className="text-[11px] min-h-[72px] font-mono" style={inputStyle}
|
||||
value={form.privateKey} onChange={(e) => setForm({ ...form, privateKey: e.target.value })}
|
||||
placeholder={editing ? '留空表示不修改' : '-----BEGIN OPENSSH PRIVATE KEY-----\n...'} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.sshUser}
|
||||
onChange={(e) => setForm({ ...form, sshUser: e.target.value })} placeholder="root" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>端口</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.sshPort}
|
||||
onChange={(e) => setForm({ ...form, sshPort: e.target.value })} placeholder="22" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码(可选)</Label>
|
||||
<Input type="password" className="h-8 text-[12px]" style={inputStyle} value={form.sshPassword}
|
||||
onChange={(e) => setForm({ ...form, sshPassword: e.target.value })} placeholder={editing ? '留空不修改' : '密钥密码'} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'docker' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Registry 地址</Label>
|
||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.dockerServer}
|
||||
onChange={(e) => setForm({ ...form, dockerServer: e.target.value })} placeholder="registry.example.com" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.dockerUsername}
|
||||
onChange={(e) => setForm({ ...form, dockerUsername: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码 / Token</Label>
|
||||
<Input type="password" className="h-8 text-[12px]" style={inputStyle} value={form.dockerPassword}
|
||||
onChange={(e) => setForm({ ...form, dockerPassword: e.target.value })} placeholder={editing ? '留空不修改' : ''} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'git' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>仓库地址</Label>
|
||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.gitUrl}
|
||||
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })} placeholder="https://gitee.com/org/repo.git" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.gitUsername}
|
||||
onChange={(e) => setForm({ ...form, gitUsername: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>访问 Token</Label>
|
||||
<Input type="password" className="h-8 text-[12px]" style={inputStyle} value={form.gitToken}
|
||||
onChange={(e) => setForm({ ...form, gitToken: e.target.value })} placeholder={editing ? '留空不修改' : 'Personal Access Token'} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'kubernetes' && (
|
||||
<>
|
||||
<div className="flex items-center rounded-md border overflow-hidden w-fit" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
{[{ id: 'token', label: 'Token(推荐)' }, { id: 'kubeconfig', label: 'Kubeconfig' }].map((m) => (
|
||||
<button key={m.id} type="button" onClick={() => setForm({ ...form, k8sMode: m.id as 'token' | 'kubeconfig' })}
|
||||
className="px-3 py-[5px] text-[11px] font-medium"
|
||||
style={{ background: form.k8sMode === m.id ? 'var(--accent-blue)' : 'transparent', color: form.k8sMode === m.id ? '#fff' : 'var(--text-secondary)' }}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.k8sMode === 'token' ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Server</Label>
|
||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.k8sApiServer}
|
||||
onChange={(e) => setForm({ ...form, k8sApiServer: e.target.value })} placeholder="https://k8s.example.com:6443" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>命名空间</Label>
|
||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.k8sNamespace}
|
||||
onChange={(e) => setForm({ ...form, k8sNamespace: e.target.value })} placeholder="flowstack" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Token{editing ? '(留空不修改)' : ''}</Label>
|
||||
<Textarea className="text-[11px] min-h-[64px] font-mono" style={inputStyle}
|
||||
value={form.k8sToken} onChange={(e) => setForm({ ...form, k8sToken: e.target.value })}
|
||||
placeholder="eyJhbGciOiJSUzI1NiIs..." />
|
||||
</div>
|
||||
{/* 命名空间长期授权引导(LokiStack 模式) */}
|
||||
<div className="rounded-md border" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<button type="button" onClick={() => setForm({ ...form, rbacOpen: !form.rbacOpen })}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium"
|
||||
style={{ color: 'var(--accent-blue)' }}>
|
||||
<KeyRound className="w-3 h-3" />
|
||||
最小授权命令:只授予「{form.k8sNamespace || 'flowstack'}」命名空间权限(点击展开)
|
||||
</button>
|
||||
{form.rbacOpen && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
在集群执行一次以下命令创建 ServiceAccount + 命名空间级 Role + 长期 Token;Token 泄露只影响该命名空间,不要使用 cluster-admin。
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="text-[10px] font-mono whitespace-pre overflow-x-auto p-2.5 rounded-md max-h-64 overflow-y-auto custom-scrollbar"
|
||||
style={{ background: 'var(--panel-bg)', border: '1px solid var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
{buildRbacCommand(form.k8sNamespace)}
|
||||
</pre>
|
||||
<button type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(buildRbacCommand(form.k8sNamespace)); toast.success('授权命令已复制'); }}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded" style={{ color: 'var(--text-tertiary)', background: 'var(--panel-bg-secondary)' }} title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Token 长期有效且仅绑定该命名空间 Role;内容加密存储、接口不回明文。轮换:删除 Secret 重建即失效。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Kubeconfig(整份 YAML{editing ? ',留空不修改' : ''})</Label>
|
||||
<Textarea className="text-[11px] min-h-[110px] font-mono" style={inputStyle}
|
||||
value={form.k8sKubeconfig} onChange={(e) => setForm({ ...form, k8sKubeconfig: e.target.value })}
|
||||
placeholder={'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://...'} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Context(可选,覆盖默认)</Label>
|
||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.k8sContext}
|
||||
onChange={(e) => setForm({ ...form, k8sContext: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
内容加密存储,保存后不可查看;编辑时留空表示保持原值。
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
Reference in new issue
Block a user