权限模型(借鉴 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 文档补权限章节
213 lines
9.0 KiB
TypeScript
213 lines
9.0 KiB
TypeScript
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>
|
||
);
|
||
}
|