feat: 流水线三级权限体系(手动多选+通配符授权)+ 菜单分级 + SSE 认证修复
权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝): - 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免 - 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*) - 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝 后端: - 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD - RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry - /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限 - 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库 前端: - useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫 - 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选) - 登录页记住密码;GUIDE/API 文档补权限章节
This commit is contained in:
1 parent
7090c8efc2
commit
9103b13510
27 files changed
+1690
-190
No files matched your search
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Trash2, UserPlus } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
// 三级权限:大白话描述,普通操作员无需理解术语
|
||||
export const LEVEL_OPTIONS = [
|
||||
{ value: 'viewer', label: '查看', desc: '只能看流水线与发布成功/失败日志' },
|
||||
{ value: 'operator', label: '发布', desc: '查看 + 触发构建、批量发布、失败重试' },
|
||||
{ value: 'owner', label: '管理', desc: '发布 + 编辑流水线配置、管理成员' },
|
||||
];
|
||||
|
||||
export function levelLabel(level: string): string {
|
||||
return LEVEL_OPTIONS.find((l) => l.value === level)?.label ?? level;
|
||||
}
|
||||
|
||||
export const levelBadgeStyle: Record<string, { bg: string; color: string }> = {
|
||||
viewer: { bg: '#f6f8fa', color: '#6e7781' },
|
||||
operator: { bg: '#ddf4ff', color: '#0969da' },
|
||||
owner: { bg: '#dafbe1', color: '#1a7f37' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
pipelineId: number;
|
||||
pipelineName?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 单流水线成员弹窗:直接添加成员(选用户 + 点选等级),命中的权限组只读展示
|
||||
export default function PipelineMembersDialog({ pipelineId, pipelineName, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [username, setUsername] = useState('');
|
||||
const [level, setLevel] = useState('operator');
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['pipeline-members', pipelineId],
|
||||
queryFn: () => apiClient.listPipelineMembers(pipelineId),
|
||||
enabled: open,
|
||||
});
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: apiClient.listUsers,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pipeline-members', pipelineId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => apiClient.addPipelineMember(pipelineId, { username, level }),
|
||||
onSuccess: () => {
|
||||
toast.success(`已授权 ${username}`);
|
||||
setUsername('');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (mid: any) => apiClient.deletePipelineMember(pipelineId, mid),
|
||||
onSuccess: () => {
|
||||
toast.success('已移除授权');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const members: any[] = membersQuery.data?.members ?? [];
|
||||
const matchedRoles: any[] = membersQuery.data?.matchedRoles ?? [];
|
||||
const userOptions: any[] = users ?? [];
|
||||
|
||||
const submit = () => {
|
||||
if (!username) {
|
||||
toast.error('请选择用户');
|
||||
return;
|
||||
}
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' };
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">成员授权 — {pipelineName || `流水线 #${pipelineId}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 添加成员 */}
|
||||
<div className="space-y-3">
|
||||
<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={username} onValueChange={setUsername}>
|
||||
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择用户" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userOptions.map((u) => (
|
||||
<SelectItem key={u.id} value={u.username} className="text-[12px]">
|
||||
{u.username}{u.nickname ? `(${u.nickname})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>权限等级</Label>
|
||||
<div className="flex gap-1">
|
||||
{LEVEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setLevel(opt.value)}
|
||||
className="flex-1 h-8 text-[12px] rounded-md border transition-colors"
|
||||
style={
|
||||
level === opt.value
|
||||
? { background: 'var(--accent-blue-light)', borderColor: 'var(--accent-blue)', color: 'var(--accent-blue)', fontWeight: 600 }
|
||||
: { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }
|
||||
}
|
||||
title={opt.desc}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{LEVEL_OPTIONS.find((l) => l.value === level)?.desc}
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={addMutation.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{addMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||
添加授权
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 已有直接授权 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-secondary)' }}>直接授权({members.length})</div>
|
||||
{membersQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="py-2 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>暂无直接授权成员</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
{members.map((m) => {
|
||||
const badge = levelBadgeStyle[m.level] ?? levelBadgeStyle.viewer;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between px-3 py-2 border-b last:border-b-0"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-mono" style={{ color: 'var(--text-primary)' }}>{m.username}</span>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded font-medium" style={{ background: badge.bg, color: badge.color }}>
|
||||
{levelLabel(m.level)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="移除授权"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate(m.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 命中的权限组(只读) */}
|
||||
{matchedRoles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-secondary)' }}>命中的权限组(只读,在「流水线权限」页管理)</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{matchedRoles.map((r, i) => {
|
||||
const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[11px] px-2 py-1 rounded border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
title={r.mode === 'pattern' ? `名称匹配:${r.pattern}` : '手动选择的流水线'}
|
||||
>
|
||||
{r.name} · {r.mode === 'pattern' ? r.pattern : '多选'} ·{' '}
|
||||
<span style={{ color: badge.color }}>{levelLabel(r.level)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user