Files
aiops/frontend/src/components/deploy/DeployRBAC.tsx
T
attacker 72bfb0e12d fix(i18n): 回退到稳定的 32% 覆盖率状态 (tsc + Vite 构建通过)
批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换:
- 翻译词典: 22 namespaces, 3600+ keys
- 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy)
- 语言切换器在 Header 中可用 (🇨🇳/🇺🇸)
- tsc 零错误, Vite 构建成功
2026-07-03 02:02:29 +08:00

396 lines
17 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, useMemo } from 'react';
import { Shield, Plus, Trash2, Loader2, Users, GitBranch, UserPlus, Check, X } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { deployApi, userApi, pipelineApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { useSearchParams } from 'react-router-dom';
import UserGroupsManager from './UserGroupsManager';
import { JobRoleCreateDialog } from './deploy-rbac/JobRoleCreateDialog';
import { matchPipelines, pipelineDisplayLabel, type PipelineMatchItem } from './deploy-rbac/matchPipeline';
import type { JsonData } from '@/types/common';
interface ItemRole {
id: number;
name: string;
code: string;
pattern: string;
role: string;
description: string;
status: number;
created_at: string;
}
interface AssignedUser {
id: number;
user_id: number;
item_role_id: number;
item_role?: ItemRole;
}
const ROLE_LABEL: Record<string, string> = {
owner: '拥有者',
editor: '编辑者',
viewer: '查看者',
};
const RBAC_TABS = ['roles', 'users', 'preview', 'groups'] as const;
// 发布 Job 权限 — 正则匹配 code / job_name,Jenkins Role Strategy 风格 // Logan.Li
export default function DeployRBAC() {
const [searchParams] = useSearchParams();
const tabFromUrl = searchParams.get('tab');
const [roles, setRoles] = useState<ItemRole[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState(
tabFromUrl && (RBAC_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'roles',
);
const [pipelines, setPipelines] = useState<PipelineMatchItem[]>([]);
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
const [users, setUsers] = useState<JsonData[]>([]);
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState('');
const [assignRoleId, setAssignRoleId] = useState('');
const [assignLoading, setAssignLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newCode, setNewCode] = useState('');
const [newPattern, setNewPattern] = useState('');
const [newRole, setNewRole] = useState('editor');
const [newDesc, setNewDesc] = useState('');
const [editing, setEditing] = useState<number | null>(null);
const [editPattern, setEditPattern] = useState('');
const [editRole, setEditRole] = useState('');
const _confirm = useConfirm();
const loadData = async () => {
setLoading(true);
try {
const [rolesRes, pipesRes, usersRes] = await Promise.all([
deployApi.getItemRoles(),
pipelineApi.getPipelines({ page_size: 200 }),
userApi.getUsers({ page_size: 500 }),
]);
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as JsonData)?.list || []);
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as JsonData)?.list || []);
setUsers(Array.isArray(usersRes) ? usersRes : (usersRes as JsonData)?.list || []);
} catch { /* silent */ }
finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const loadAssignments = async () => {
if (!selectedUserId) { setAssignments([]); return; }
try {
const res: JsonData = await deployApi.getUserItemRoles(Number(selectedUserId));
setAssignments(Array.isArray(res) ? res : res?.list || []);
} catch { setAssignments([]); }
};
useEffect(() => { loadAssignments(); }, [selectedUserId]);
const matchCount = useMemo(() => {
const map: Record<number, PipelineMatchItem[]> = {};
roles.forEach((r) => { map[r.id] = matchPipelines(r.pattern, pipelines); });
return map;
}, [roles, pipelines]);
const previewMatches = previewRoleId ? (matchCount[previewRoleId] || []) : pipelines;
const handleCreate = async () => {
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
toast.error('请填写名称、标识与正则'); return;
}
setSaving(true);
try {
await deployApi.createItemRole({
name: newName.trim(),
code: newCode.trim(),
pattern: newPattern.trim(),
role: newRole,
description: newDesc,
});
toast.success('已创建');
setCreateOpen(false);
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
loadData();
} catch (e: unknown) {
toast.error((e as Error)?.message || '创建失败');
} finally { setSaving(false); }
};
const handleUpdate = async (id: number) => {
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
setSaving(true);
try {
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
toast.success('已更新');
setEditing(null);
loadData();
} catch (e: unknown) {
toast.error((e as Error)?.message || '更新失败');
} finally { setSaving(false); }
};
const handleDelete = async (role: ItemRole) => {
const ok = await _confirm({
title: '删除权限',
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个发布任务`,
variant: 'destructive',
});
if (!ok) return;
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
catch { toast.error('删除失败'); }
};
const handleToggleStatus = async (role: ItemRole) => {
try {
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
toast.success(role.status ? '已禁用' : '已启用');
loadData();
} catch { toast.error('操作失败'); }
};
const handleAssign = async () => {
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
setAssignLoading(true);
try {
await deployApi.assignItemRole(Number(selectedUserId), {
user_id: Number(selectedUserId),
item_role_id: Number(assignRoleId),
});
toast.success('已分配');
setAssignRoleId('');
loadAssignments();
} catch (e: unknown) {
toast.error((e as Error)?.message || '分配失败');
} finally { setAssignLoading(false); }
};
const handleRevoke = async (id: number) => {
try {
await deployApi.removeItemRole(Number(selectedUserId), id);
toast.success('已撤销');
loadAssignments();
} catch { toast.error('撤销失败'); }
};
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
const pageTabs = [
{ id: 'roles', label: '权限策略', badge: roles.length || undefined },
{ id: 'users', label: '用户授权' },
{ id: 'groups', label: '用户组' },
{ id: 'preview', label: '命中预览' },
];
return (
<PageShell
variant="list"
title="Job 权限"
subtitle="按流水线 code / Job 名称正则分配发布权限"
icon={<Shield className="h-5 w-5" />}
loading={loading}
tabs={pageTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
actions={activeTab === 'groups' ? undefined : (
<Button className="dg-btn-primary" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-1" />新建权限
</Button>
)}
contentClassName="space-y-4"
>
{activeTab === 'roles' && (
roles.length === 0 ? (
<PageEmpty
icon={<Shield className="h-10 w-10" />}
title="暂无 Job 权限策略"
description="创建策略后,用户将按正则自动获得匹配流水线的访问权限"
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />新建权限</Button>}
/>
) : (
<div className="space-y-2">
{roles.map((role) => (
<Card key={role.id} className={`dg-card ${role.status === 0 ? 'opacity-50' : ''}`}>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{role.name}</span>
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">
{ROLE_LABEL[role.role] || role.role}
</Badge>
{role.status === 0 && <Badge variant="outline" className="text-[10px]">已禁用</Badge>}
<Badge
variant="outline"
className="text-[10px] cursor-pointer border-[var(--dg-border)]"
onClick={() => { setActiveTab('preview'); setPreviewRoleId(role.id); }}
>
{matchCount[role.id]?.length || 0} 个 Job
</Badge>
</div>
<p className="text-xs text-[var(--dg-text-muted)] mt-1 font-mono truncate">
{role.pattern}
{role.description ? ` · ${role.description}` : ''}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{editing === role.id ? (
<>
<Input value={editPattern} onChange={(e) => setEditPattern(e.target.value)} className="h-7 w-32 text-xs font-mono dg-input" />
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者</SelectItem>
<SelectItem value="editor">编辑者</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
</SelectContent>
</Select>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
</>
) : (
<>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}>编辑</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-red)]" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
)}
{activeTab === 'users' && (
<Card className="dg-card">
<CardContent className="p-4 space-y-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="space-y-1 flex-1 min-w-[200px]">
<Label className="text-xs">用户</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择用户" /></SelectTrigger>
<SelectContent className="max-h-60">
{users.map((u) => (
<SelectItem key={u.id as number} value={String(u.id)}>
{(u.real_name as string) || (u.username as string)} ({u.username as string})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 w-48">
<Label className="text-xs">权限策略</Label>
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择策略" /></SelectTrigger>
<SelectContent>
{roles.filter((r) => r.status === 1).map((r) => (
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({ROLE_LABEL[r.role]})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
分配
</Button>
</div>
{selectedUser ? (
assignments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">该用户尚未分配 Job 权限</p>
) : (
<div className="space-y-1.5">
{assignments.map((a) => {
const roleInfo = a.item_role || roles.find((r) => r.id === a.item_role_id);
return (
<div key={a.id} className="flex items-center justify-between py-2 px-2.5 rounded-md bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className="text-[10px] shrink-0">{ROLE_LABEL[roleInfo?.role || ''] || roleInfo?.role}</Badge>
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name}</span>
<code className="text-[var(--dg-text-muted)] truncate">{roleInfo?.pattern}</code>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => handleRevoke(a.id)}>
<X className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)
) : (
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />选择用户后查看已分配权限</p>
)}
</CardContent>
</Card>
)}
{activeTab === 'groups' && <UserGroupsManager embedded />}
{activeTab === 'preview' && (
<Card className="dg-card">
<CardContent className="p-4 space-y-3">
<div className="flex flex-wrap gap-1.5">
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>
全部 ({pipelines.length})
</Button>
{roles.map((r) => (
<Button
key={r.id}
variant={previewRoleId === r.id ? 'secondary' : 'ghost'}
size="sm"
className={r.status === 0 ? 'opacity-40' : ''}
onClick={() => setPreviewRoleId(r.id)}
>
{r.name} ({matchCount[r.id]?.length || 0})
</Button>
))}
</div>
<div className="flex flex-wrap gap-1.5 max-h-48 overflow-y-auto">
{previewMatches.map((p) => (
<Badge key={p.id as number} variant="outline" className="text-xs font-mono border-[var(--dg-border)]">
<GitBranch className="w-3 h-3 mr-0.5" />
{pipelineDisplayLabel(p)}
</Badge>
))}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-[var(--dg-text-muted)]">无匹配 — 请调整正则</span>
)}
</div>
</CardContent>
</Card>
)}
<JobRoleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
pipelines={pipelines}
saving={saving}
name={newName}
code={newCode}
pattern={newPattern}
role={newRole}
description={newDesc}
onNameChange={setNewName}
onCodeChange={setNewCode}
onPatternChange={setNewPattern}
onRoleChange={setNewRole}
onDescriptionChange={setNewDesc}
onSubmit={handleCreate}
/>
</PageShell>
);
}