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:
Logan.Li committed 2026-10-27 15:40:59 +08:00
1 parent 935a29e2f1
commit ee59706ab9
2 files changed
+304 -24

No files matched your search

+6
View File
@@ -305,6 +305,12 @@ func summarizeCredential(typ, contentJSON string) string {
return typ + " " + pick("server") + " " + pick("username")
case "git":
return typ + " " + pick("url", "username")
case "kubernetes":
mode, _ := m["mode"].(string)
if mode == "kubeconfig" {
return "K8s Kubeconfig"
}
return "K8s Token " + pick("namespace") + "@" + pick("apiServer")
case "kubeconfig":
return typ + " (kubeconfig)"
case "aksk":
+298 -24
View File
@@ -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>