Files
FlowStack/frontend/src/components/PipelineMembersDialog.tsx
T
Logan.Li 9103b13510 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 文档补权限章节
2026-10-27 17:34:41 +08:00

213 lines
9.0 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 } 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>
);
}