diff --git a/backend/docs/API.md b/backend/docs/API.md index f39da58..2e05f44 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -37,25 +37,41 @@ LDAP 用户名在目录验证通过后自动建号/同步(JIT);错误统 | 接口 | 方法 | 说明 | |---|---|---| -| `/api/pipelines` | GET | 列表(每条附 `lastRun` 最近运行) | -| `/api/pipelines` | POST | 创建(name 必填;deployType: host/kubernetes 值为 `k8s`/gitops) | -| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun) | -| `/api/pipelines/:id` | PUT | 更新(白名单字段,含触发器/变量/缓存) | -| `/api/pipelines/:id` | DELETE | 删除(级联删节点连线) | -| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点 | -| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线 | -| `/api/pipelines/:id/runs` | POST | 触发运行(body 可带 branch/env/triggerType) | +| `/api/pipelines` | GET | 列表(每条附 `lastRun` 与 `accessLevel`;普通用户仅返回被授权的) | +| `/api/pipelines` | POST | 创建(**管理员**) | +| `/api/pipelines/:id` | GET | 详情(pipeline + nodes + edges + lastRun + `accessLevel`;需「查看」) | +| `/api/pipelines/:id` | PUT | 更新(需「管理」) | +| `/api/pipelines/:id` | DELETE | 删除(**管理员**;级联删节点连线) | +| `/api/pipelines/:id/nodes` | PUT | 全量保存画布节点(需「管理」) | +| `/api/pipelines/:id/edges` | PUT | 全量保存画布连线(需「管理」) | +| `/api/pipelines/:id/runs` | POST | 触发运行(需「发布」;body 可带 branch/env/triggerType) | `type=jenkins` 且已关联服务器的流水线触发时走真实 Jenkins 引擎(队列→构建→增量日志回传)。 +## 流水线权限 + +三级等级:`viewer` 查看 / `operator` 发布 / `owner` 管理;管理员全局豁免。未被授权的普通用户默认不可见任何流水线。 + +| 接口 | 方法 | 说明 | +|---|---|---| +| `/api/me` | GET | 当前会话(username/role/isAdmin) | +| `/api/pipeline-roles` | GET | 权限组列表(**管理员**;附 matchedPipelines 匹配数) | +| `/api/pipeline-roles` | POST | 创建(name/scopeMode=manual|pattern/pattern/pipelineIds/level/members;pattern 仅支持 `*` 通配符) | +| `/api/pipeline-roles/:id` | PUT/DELETE | 更新 / 删除(**管理员**) | +| `/api/pipelines/:id/members` | GET | 单流水线直接成员 + 命中的权限组(需「查看」) | +| `/api/pipelines/:id/members` | POST | 添加/更新成员等级(需「管理」;body: username/level) | +| `/api/pipelines/:id/members/:mid` | DELETE | 移除直接授权(需「管理」) | + +资源(主机/集群/凭证/构建节点/工具/Webhook)、Jenkins 服务器、GitOps、插件与系统设置接口均要求**管理员**。 + ## 运行记录 | 接口 | 方法 | 说明 | |---|---|---| -| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`) | -| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`) | -| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(免鉴权;事件 connected/log,历史先回放) | -| `/api/runs/:runId/retry` | POST | 以同分支重新触发 | +| `/api/runs` | GET | 运行列表(`?pipelineId=&status=`;普通用户仅返回被授权流水线的,每条附 `canRetry`) | +| `/api/runs/:runId` | GET | 详情(run + stages + logs;失败含 AI `analysis`;需「查看」) | +| `/api/runs/:runId/logs/stream` | GET | SSE 实时日志(需登录:`?token=` 传会话 token 且需「查看」;事件 connected/log) | +| `/api/runs/:runId/retry` | POST | 以同分支重新触发(需「发布」) | ## AI 与批量发布 @@ -133,7 +149,7 @@ curl -X POST http://localhost:9527/api/ai/release \ |---|---| | 400 | 参数错误 / 未启用对应功能 | | 401 | 未登录 / token 过期 / 用户名或密码错误 | -| 403 | 需要 admin 角色 | +| 403 | 需要 admin 角色 / 无该流水线的对应权限等级 | | 404 | 资源不存在 | | 409 | 名称重复等冲突 | diff --git a/backend/docs/GUIDE.md b/backend/docs/GUIDE.md index 271efbe..e739791 100644 --- a/backend/docs/GUIDE.md +++ b/backend/docs/GUIDE.md @@ -119,6 +119,33 @@ cex-agent-admin - 凭证内容 AES 加密存储、只写不回显,编辑留空=不修改 - SSH 凭证关联构建节点后即可远程磁盘测试 +## 流水线权限 + +**是什么**:按流水线控制「谁能看、谁能发布、谁能改」。未被授权的普通用户看不到任何流水线与发布记录(默认拒绝);管理员始终拥有全部权限。 + +**三级权限**: + +| 等级 | 能做什么 | +|---|---| +| 查看 | 看流水线、发布记录、发布成功/失败日志(只读,不能构建) | +| 发布 | 查看 + 触发构建、批量发布、失败重试 | +| 管理 | 发布 + 编辑流水线配置、管理该流水线的成员授权 | + +**怎么授权(管理员,系统 → 流水线权限)**: +- 新建权限组 → **选择流水线**:直接勾选要授权的流水线(推荐,全程点选) +- 或 **名称匹配(高级)**:只写 `cex-*` 这类通配符(`*` 代表任意字符,无需正则),一次授权整批,之后新建的同名流水线自动纳入 +- 选好权限等级,勾选成员,保存即生效 +- 也可以在单条流水线里:编辑器顶栏「成员」按钮,给个别人单独授权(作为权限组的例外补充) + +**普通用户能看什么**: +- 菜单只剩「发布」组(流水线/批量发布/发布记录)和「文档中心」;资源、集成、GitOps、系统管理类菜单全部隐藏(直接输 URL 也会被拦回) +- 流水线列表只显示被授权的;按钮按等级显隐:查看级只有只读入口,发布级出现「执行/保存并运行/重试」,管理级才有「保存/成员」 +- 新建/删除流水线、凭证/主机/集群/构建节点等资源管理仅管理员 + +**技巧**: +- AI 助手批量发布同样受控:对未授权的 Job 会在结果里明确标注「无该流水线的执行权限」 +- 构建日志 SSE 已要求登录(`?token=` 传递会话),未登录直接访问返回 401 + ## 常见问题 **初始密码在哪**:服务器 `data/initial-admin-password.txt` 或 `grep -A6 "已生成管理员账号" logs/flowstack.log` diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 8290efe..2d2f27b 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -36,6 +36,7 @@ func Init() error { if err := DB.AutoMigrate( &model.User{}, + &model.PipelineRole{}, &model.PipelineMember{}, &model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{}, &model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{}, &model.Application{}, &model.Plugin{}, diff --git a/backend/internal/handler/ai.go b/backend/internal/handler/ai.go index 0a334e0..cb1e644 100644 --- a/backend/internal/handler/ai.go +++ b/backend/internal/handler/ai.go @@ -362,6 +362,7 @@ func AiRelease(hub *Hub) gin.HandlerFunc { return } autoCreate := body.AutoCreate == nil || *body.AutoCreate + username, isAdmin := currentUser(c) candidates := allCandidates() type triggered struct { @@ -378,12 +379,24 @@ func AiRelease(hub *Hub) gin.HandlerFunc { var failList []failed for _, name := range body.Jobs { + // 权限预检(operator 及以上):已存在的流水线按完整权限判定; + // 未导入的 Jenkins Job 按正则角色命中判定,且必须先于自动创建——避免建出请求者自己都无权可见的流水线 + var existing model.Pipeline + if database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&existing).Error == nil { + if pipelineAccessLevel(username, isAdmin, &existing) < LevelRank(LevelOperator) { + failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限"}) + continue + } + } else if !isAdmin && roleLevelForName(username, name) < LevelRank(LevelOperator) { + failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限(权限组未匹配该名称)"}) + continue + } p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID) if err != nil { failList = append(failList, failed{Job: name, Reason: err.Error()}) continue } - run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, "admin", "batch") + run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, username, "batch") if err != nil { failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()}) continue diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 7679f80..a9e8597 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -68,23 +68,28 @@ func VerifyToken(token string) (string, error) { return parts[0], nil } -// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook/SSE 日志流);SPA 与静态资源放行 +// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook);SPA 与静态资源放行。 +// SSE 日志流(*/logs/stream)的 EventSource 无法携带 Authorization 头,额外接受 ?token= 查询参数。 func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { p := c.Request.URL.Path if p == "/health" || !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || - strings.HasPrefix(p, "/api/hooks/") || - strings.HasSuffix(p, "/logs/stream") { + strings.HasPrefix(p, "/api/hooks/") { c.Next() return } - auth := c.GetHeader("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { + token := "" + if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + token = strings.TrimPrefix(auth, "Bearer ") + } else if strings.HasSuffix(p, "/logs/stream") { + token = c.Query("token") + } + if token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } - username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer ")) + username, err := VerifyToken(token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"}) return @@ -100,6 +105,15 @@ func AuthMiddleware() gin.HandlerFunc { } } +// Me GET /api/me —— 当前会话信息(前端菜单分级与按钮权限依据) +func Me(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "username": c.GetString("username"), + "role": c.GetString("role"), + "isAdmin": c.GetString("role") == "admin", + }) +} + // AdminOnly 管理员专用接口守卫 func AdminOnly() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/handler/perm.go b/backend/internal/handler/perm.go new file mode 100644 index 0000000..99652a0 --- /dev/null +++ b/backend/internal/handler/perm.go @@ -0,0 +1,226 @@ +package handler + +import ( + "net/http" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + + "flowstack/internal/database" + "flowstack/internal/model" +) + +// 三级流水线权限:viewer 查看 / operator 执行 / owner 管理;admin 全局豁免(等级 4) +const ( + LevelViewer = "viewer" + LevelOperator = "operator" + LevelOwner = "owner" + + adminAccessLevel = 4 +) + +// LevelRank 数值化权限等级,用于比较 +func LevelRank(level string) int { + switch level { + case LevelOwner: + return 3 + case LevelOperator: + return 2 + case LevelViewer: + return 1 + } + return 0 +} + +// currentUser 从 AuthMiddleware 注入的上下文取当前用户 +func currentUser(c *gin.Context) (username string, isAdmin bool) { + return c.GetString("username"), c.GetString("role") == "admin" +} + +func memberInList(members []string, username string) bool { + for _, m := range members { + if m == username { + return true + } + } + return false +} + +// globToRegexp 通配符(* 任意字符、? 单字符)转正则,其余字符按字面量——操作员无需正则知识 +func globToRegexp(glob string) *regexp.Regexp { + expr := regexp.QuoteMeta(glob) + expr = strings.ReplaceAll(expr, `\*`, `.*`) + expr = strings.ReplaceAll(expr, `\?`, `.`) + return regexp.MustCompile(expr) +} + +// roleMatchPipeline 权限组是否覆盖某流水线:manual 按多选 ID、pattern 按通配符匹配名称 +func roleMatchPipeline(role *model.PipelineRole, p *model.Pipeline) bool { + if role.ScopeMode == "pattern" { + if role.Pattern == "" { + return false + } + re := globToRegexp(role.Pattern) + return re.MatchString(p.Name) || (p.JenkinsJobName != "" && re.MatchString(p.JenkinsJobName)) + } + for _, id := range role.PipelineIDs { + if id == p.ID { + return true + } + } + return false +} + +// roleLevelForPipeline 用户通过权限组对某流水线获得的最高等级(manual + pattern 一并计算) +func roleLevelForPipeline(username string, p *model.Pipeline) int { + if username == "" { + return 0 + } + var roles []model.PipelineRole + database.DB.Find(&roles) + best := 0 + for i := range roles { + role := &roles[i] + if !memberInList(role.Members, username) { + continue + } + if roleMatchPipeline(role, p) { + if r := LevelRank(role.Level); r > best { + best = r + } + } + } + return best +} + +// roleLevelForName 按名称预检(AI 自动创建前):只有 pattern 模式能命中尚未创建的流水线 +func roleLevelForName(username string, name string) int { + if username == "" { + return 0 + } + var roles []model.PipelineRole + database.DB.Find(&roles) + best := 0 + for _, role := range roles { + if !memberInList(role.Members, username) || role.ScopeMode != "pattern" || role.Pattern == "" { + continue + } + if globToRegexp(role.Pattern).MatchString(name) { + if r := LevelRank(role.Level); r > best { + best = r + } + } + } + return best +} + +// pipelineAccessLevel 用户对某流水线的最高权限等级: +// admin 豁免 → 单流水线直接成员 → 权限组命中(manual/pattern,取最大值) +func pipelineAccessLevel(username string, isAdmin bool, p *model.Pipeline) int { + if isAdmin { + return adminAccessLevel + } + best := 0 + var member model.PipelineMember + if err := database.DB.Where("pipeline_id = ? AND username = ?", p.ID, username).First(&member).Error; err == nil { + best = LevelRank(member.Level) + } + if r := roleLevelForPipeline(username, p); r > best { + best = r + } + return best +} + +// AccessiblePipelineIDs 列表过滤用:直接成员 ∪ 权限组命中的流水线 ID。 +// 第二个返回值表示是否需要过滤:admin 返回 (nil, false) 全量可见; +// 普通用户返回 (ids, true),ids 可为空 = 无任何可见流水线 +func AccessiblePipelineIDs(username string, isAdmin bool) ([]uint, bool) { + if isAdmin { + return nil, false + } + if username == "" { + return []uint{}, true + } + ids := map[uint]bool{} + var memberIDs []uint + database.DB.Model(&model.PipelineMember{}).Where("username = ?", username).Pluck("pipeline_id", &memberIDs) + for _, id := range memberIDs { + ids[id] = true + } + + var roles []model.PipelineRole + database.DB.Find(&roles) + patterns := make([]*regexp.Regexp, 0, len(roles)) + for i := range roles { + role := &roles[i] + if !memberInList(role.Members, username) { + continue + } + if role.ScopeMode == "pattern" && role.Pattern != "" { + patterns = append(patterns, globToRegexp(role.Pattern)) + } else { + for _, id := range role.PipelineIDs { + ids[id] = true + } + } + } + if len(patterns) > 0 { + var pipelines []model.Pipeline + database.DB.Select("id", "name", "jenkins_job_name").Find(&pipelines) + for _, p := range pipelines { + for _, re := range patterns { + if re.MatchString(p.Name) || (p.JenkinsJobName != "" && re.MatchString(p.JenkinsJobName)) { + ids[p.ID] = true + break + } + } + } + } + out := make([]uint, 0, len(ids)) + for id := range ids { + out = append(out, id) + } + return out, true +} + +// RequirePipelineAccess 流水线级权限守卫:解析 :id 后按最低等级校验,通过后注入 pipeline +func RequirePipelineAccess(minLevel int) gin.HandlerFunc { + return func(c *gin.Context) { + var p model.Pipeline + if err := database.DB.First(&p, c.Param("id")).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) + return + } + username, isAdmin := currentUser(c) + if pipelineAccessLevel(username, isAdmin, &p) < minLevel { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该流水线"}) + return + } + c.Set("pipeline", &p) + c.Next() + } +} + +// RequireRunAccess 运行级权限守卫:runId → run → pipeline 再校验(防 runId 绕过流水线权限) +func RequireRunAccess(minLevel int) gin.HandlerFunc { + return func(c *gin.Context) { + var run model.PipelineRun + if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&run).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "run not found"}) + return + } + var p model.Pipeline + if err := database.DB.First(&p, run.PipelineID).Error; err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) + return + } + username, isAdmin := currentUser(c) + if pipelineAccessLevel(username, isAdmin, &p) < minLevel { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该流水线的运行记录"}) + return + } + c.Set("pipeline", &p) + c.Next() + } +} diff --git a/backend/internal/handler/pipeline_roles.go b/backend/internal/handler/pipeline_roles.go new file mode 100644 index 0000000..c160e78 --- /dev/null +++ b/backend/internal/handler/pipeline_roles.go @@ -0,0 +1,212 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "flowstack/internal/database" + "flowstack/internal/model" +) + +// ============================================================ +// 流水线权限组(默认手动多选流水线;高级模式用 * 通配符匹配名称)—— 仅管理员 +// ============================================================ + +type roleBody struct { + Name string `json:"name"` + ScopeMode string `json:"scopeMode"` // manual 手动多选 | pattern 名称匹配 + Pattern string `json:"pattern"` // scopeMode=pattern 时生效,支持 * 通配符 + PipelineIDs []uint `json:"pipelineIds"` + Level string `json:"level"` + Members []string `json:"members"` +} + +func validateRoleBody(b *roleBody) string { + if b.Name == "" { + return "名称不能为空" + } + if b.ScopeMode == "" { + b.ScopeMode = "manual" + } + if b.ScopeMode != "manual" && b.ScopeMode != "pattern" { + return "授权范围类型不合法" + } + if b.ScopeMode == "pattern" && b.Pattern == "" { + return "名称匹配需要填写匹配规则,如 cex-*" + } + if b.ScopeMode == "manual" && len(b.PipelineIDs) == 0 { + return "请至少选择一条流水线" + } + if LevelRank(b.Level) == 0 { + return "请选择权限等级" + } + return "" +} + +// ListPipelineRoles GET /api/pipeline-roles —— 附匹配流水线数 +func ListPipelineRoles(c *gin.Context) { + var roles []model.PipelineRole + database.DB.Order("id").Find(&roles) + var pipelines []model.Pipeline + database.DB.Select("id", "name", "jenkins_job_name").Find(&pipelines) + + type roleWithCount struct { + model.PipelineRole + MatchedPipelines int `json:"matchedPipelines"` + } + out := make([]roleWithCount, 0, len(roles)) + for i := range roles { + role := &roles[i] + n := 0 + for j := range pipelines { + if roleMatchPipeline(role, &pipelines[j]) { + n++ + } + } + out = append(out, roleWithCount{PipelineRole: *role, MatchedPipelines: n}) + } + c.JSON(http.StatusOK, out) +} + +// CreatePipelineRole POST /api/pipeline-roles +func CreatePipelineRole(c *gin.Context) { + var body roleBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if msg := validateRoleBody(&body); msg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + role := model.PipelineRole{ + Name: body.Name, ScopeMode: body.ScopeMode, Pattern: body.Pattern, + PipelineIDs: body.PipelineIDs, Level: body.Level, Members: body.Members, + } + if role.PipelineIDs == nil { + role.PipelineIDs = []uint{} + } + if role.Members == nil { + role.Members = []string{} + } + if err := database.DB.Create(&role).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败(名称可能已存在): " + err.Error()}) + return + } + c.JSON(http.StatusOK, role) +} + +// UpdatePipelineRole PUT /api/pipeline-roles/:id +func UpdatePipelineRole(c *gin.Context) { + var role model.PipelineRole + if err := database.DB.First(&role, c.Param("id")).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "权限组不存在"}) + return + } + var body roleBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if msg := validateRoleBody(&body); msg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + role.Name, role.ScopeMode, role.Pattern = body.Name, body.ScopeMode, body.Pattern + role.PipelineIDs, role.Level, role.Members = body.PipelineIDs, body.Level, body.Members + if role.PipelineIDs == nil { + role.PipelineIDs = []uint{} + } + if role.Members == nil { + role.Members = []string{} + } + if err := database.DB.Save(&role).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, role) +} + +// DeletePipelineRole DELETE /api/pipeline-roles/:id +func DeletePipelineRole(c *gin.Context) { + database.DB.Delete(&model.PipelineRole{}, c.Param("id")) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// ============================================================ +// 单流水线成员(云效式直接授权)—— owner 或 admin +// ============================================================ + +// ListPipelineMembers GET /api/pipelines/:id/members —— 直接成员 + 命中的权限组(只读参考) +func ListPipelineMembers(c *gin.Context) { + p, _ := c.Get("pipeline") + pipeline := p.(*model.Pipeline) + + var members []model.PipelineMember + database.DB.Where("pipeline_id = ?", pipeline.ID).Order("id").Find(&members) + + var roles []model.PipelineRole + database.DB.Find(&roles) + type matchedRole struct { + Name string `json:"name"` + Level string `json:"level"` + Pattern string `json:"pattern"` + Mode string `json:"mode"` + Members []string `json:"members"` + } + matched := make([]matchedRole, 0) + for i := range roles { + role := &roles[i] + if roleMatchPipeline(role, pipeline) { + matched = append(matched, matchedRole{Name: role.Name, Level: role.Level, Pattern: role.Pattern, Mode: role.ScopeMode, Members: role.Members}) + } + } + c.JSON(http.StatusOK, gin.H{"members": members, "matchedRoles": matched}) +} + +// AddPipelineMember POST /api/pipelines/:id/members —— 已存在则更新等级 +func AddPipelineMember(c *gin.Context) { + p, _ := c.Get("pipeline") + pipeline := p.(*model.Pipeline) + + var body struct { + Username string `json:"username"` + Level string `json:"level"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择用户"}) + return + } + if LevelRank(body.Level) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择权限等级"}) + return + } + var user model.User + if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在(LDAP 用户请先导入或登录一次)"}) + return + } + + var member model.PipelineMember + if err := database.DB.Where("pipeline_id = ? AND username = ?", pipeline.ID, body.Username).First(&member).Error; err == nil { + member.Level = body.Level + database.DB.Save(&member) + c.JSON(http.StatusOK, member) + return + } + member = model.PipelineMember{PipelineID: pipeline.ID, Username: body.Username, Level: body.Level} + if err := database.DB.Create(&member).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, member) +} + +// DeletePipelineMember DELETE /api/pipelines/:id/members/:mid +func DeletePipelineMember(c *gin.Context) { + p, _ := c.Get("pipeline") + pipeline := p.(*model.Pipeline) + database.DB.Where("pipeline_id = ? AND id = ?", pipeline.ID, c.Param("mid")).Delete(&model.PipelineMember{}) + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/backend/internal/handler/pipelines.go b/backend/internal/handler/pipelines.go index 2be9301..6757747 100644 --- a/backend/internal/handler/pipelines.go +++ b/backend/internal/handler/pipelines.go @@ -14,10 +14,17 @@ import ( "flowstack/internal/model" ) -// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息 +// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息与当前用户权限等级 func ListPipelines(c *gin.Context) { + username, isAdmin := currentUser(c) + ids, filter := AccessiblePipelineIDs(username, isAdmin) + + q := database.DB.Order("updated_at DESC") + if filter { + q = q.Where("id IN ?", ids) + } var list []model.Pipeline - database.DB.Order("updated_at DESC").Find(&list) + q.Find(&list) // 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需) var runs []model.PipelineRun @@ -31,11 +38,12 @@ func ListPipelines(c *gin.Context) { type pipelineWithLastRun struct { model.Pipeline - LastRun *model.PipelineRun `json:"lastRun"` + LastRun *model.PipelineRun `json:"lastRun"` + AccessLevel int `json:"accessLevel"` // 1查看 2执行 3管理 4管理员 } result := make([]pipelineWithLastRun, 0, len(list)) for _, p := range list { - item := pipelineWithLastRun{Pipeline: p} + item := pipelineWithLastRun{Pipeline: p, AccessLevel: pipelineAccessLevel(username, isAdmin, &p)} if r, ok := lastByPipeline[p.ID]; ok { item.LastRun = &r } @@ -57,8 +65,10 @@ func GetPipelineDetail(c *gin.Context) { database.DB.Where("pipeline_id = ?", p.ID).Find(&edges) var lastRun model.PipelineRun hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil + username, isAdmin := currentUser(c) c.JSON(http.StatusOK, gin.H{ "pipeline": p, "nodes": nodes, "edges": edges, + "accessLevel": pipelineAccessLevel(username, isAdmin, &p), "lastRun": func() *model.PipelineRun { if hasRun { return &lastRun diff --git a/backend/internal/handler/runs.go b/backend/internal/handler/runs.go index dce8f2a..6e9934a 100644 --- a/backend/internal/handler/runs.go +++ b/backend/internal/handler/runs.go @@ -29,7 +29,12 @@ func CreateRun(hub *Hub) gin.HandlerFunc { } _ = c.ShouldBindJSON(&body) - run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType) + // 触发人以会话身份为准(服务端可信,覆盖前端传入) + triggerBy := c.GetString("username") + if triggerBy == "" { + triggerBy = body.TriggerBy + } + run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, triggerBy, body.TriggerType) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -222,9 +227,15 @@ func stageScript(st *model.RunStage) []string { } } -// ListRuns GET /api/runs?pipelineId=&status= +// ListRuns GET /api/runs?pipelineId=&status= —— 按流水线可见性过滤,附 canRetry func ListRuns(c *gin.Context) { + username, isAdmin := currentUser(c) + ids, filter := AccessiblePipelineIDs(username, isAdmin) + q := database.DB.Model(&model.PipelineRun{}).Order("started_at DESC").Limit(100) + if filter { + q = q.Where("pipeline_id IN ?", ids) + } if v := c.Query("pipelineId"); v != "" { q = q.Where("pipeline_id = ?", v) } @@ -233,7 +244,25 @@ func ListRuns(c *gin.Context) { } var runs []model.PipelineRun q.Find(&runs) - c.JSON(http.StatusOK, runs) + + type runWithPerm struct { + model.PipelineRun + CanRetry bool `json:"canRetry"` + } + levelCache := map[uint]int{} + out := make([]runWithPerm, 0, len(runs)) + for _, r := range runs { + lv, ok := levelCache[r.PipelineID] + if !ok { + var p model.Pipeline + if database.DB.First(&p, r.PipelineID).Error == nil { + lv = pipelineAccessLevel(username, isAdmin, &p) + } + levelCache[r.PipelineID] = lv + } + out = append(out, runWithPerm{PipelineRun: r, CanRetry: lv >= LevelRank(LevelOperator)}) + } + c.JSON(http.StatusOK, out) } // GetRun GET /api/runs/:runId @@ -316,7 +345,7 @@ func RetryRun(hub *Hub) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"}) return } - run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual") + run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, c.GetString("username"), "manual") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 78c64ec..44d9305 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -40,6 +40,30 @@ type Pipeline struct { UpdatedAt time.Time `json:"updatedAt"` } +// PipelineRole 流水线权限角色:默认手动多选流水线(scopeMode=manual), +// 高级模式用通配符 Pattern(scopeMode=pattern,如 cex-*,免正则)批量匹配名称 +type PipelineRole struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"uniqueIndex" json:"name"` // 如 cex 发布组 + ScopeMode string `gorm:"default:manual" json:"scopeMode"` // manual 手动多选 | pattern 名称匹配 + Pattern string `json:"pattern"` // scopeMode=pattern 时生效,支持 * 通配符 + PipelineIDs []uint `gorm:"serializer:json" json:"pipelineIds"` // scopeMode=manual 时生效 + Level string `gorm:"default:viewer" json:"level"` // viewer | operator | owner + Members []string `gorm:"serializer:json" json:"members"` // 授权用户名列表 + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PipelineMember 单流水线直接授权(云效式成员管理,作正则角色的例外补充) +type PipelineMember struct { + ID uint `gorm:"primaryKey" json:"id"` + PipelineID uint `gorm:"uniqueIndex:idx_pipeline_member" json:"pipelineId"` + Username string `gorm:"uniqueIndex:idx_pipeline_member" json:"username"` + Level string `gorm:"default:viewer" json:"level"` // viewer | operator | owner + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + // PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐) type PipelineNode struct { ID uint `gorm:"primaryKey" json:"dbId"` diff --git a/backend/main.go b/backend/main.go index 10a0a75..1d90d35 100644 --- a/backend/main.go +++ b/backend/main.go @@ -59,101 +59,140 @@ func main() { { api.POST("/auth/login", handler.Login) api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) }) + api.GET("/me", handler.Me) - // 用户管理(管理员) + // 用户管理(列表登录即可读——权限成员选择器需要;增删改仅管理员) + api.GET("/users", handler.ListUsers) users := api.Group("/users", handler.AdminOnly()) { - users.GET("", handler.ListUsers) users.POST("", handler.CreateUser) users.PUT("/:id", handler.UpdateUser) users.DELETE("/:id", handler.DeleteUser) users.POST("/:id/reset-password", handler.ResetPassword) users.POST("/ldap-import", handler.LdapImport) } + // 流水线权限角色(管理员;Jenkins Item-role 正则风格) + pipelineRoles := api.Group("/pipeline-roles", handler.AdminOnly()) + { + pipelineRoles.GET("", handler.ListPipelineRoles) + pipelineRoles.POST("", handler.CreatePipelineRole) + pipelineRoles.PUT("/:id", handler.UpdatePipelineRole) + pipelineRoles.DELETE("/:id", handler.DeletePipelineRole) + } api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers) api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP) api.GET("/docs", handler.GetDocs(docsFS)) api.GET("/summary", handler.GetSummary) - api.GET("/settings", handler.GetSettings) - api.PUT("/settings", handler.UpdateSettings) - api.POST("/settings/test-llm", handler.TestLLM) - api.POST("/settings/test-lark", handler.TestLark) + api.GET("/settings", handler.AdminOnly(), handler.GetSettings) + api.PUT("/settings", handler.AdminOnly(), handler.UpdateSettings) + api.POST("/settings/test-llm", handler.AdminOnly(), handler.TestLLM) + api.POST("/settings/test-lark", handler.AdminOnly(), handler.TestLark) api.POST("/ai/chat", handler.AiChat) api.POST("/ai/chat/stream", handler.AiChatStream) api.POST("/ai/release", handler.AiRelease(hub)) api.POST("/batch/parse", handler.BatchParse) + // 流水线:查看 viewer / 编辑配置 owner / 触发构建 operator;新建与删除仅管理员 api.GET("/pipelines", handler.ListPipelines) - api.POST("/pipelines", handler.CreatePipeline) - api.GET("/pipelines/:id", handler.GetPipelineDetail) - api.PUT("/pipelines/:id", handler.UpdatePipeline) - api.DELETE("/pipelines/:id", handler.DeletePipeline) - api.PUT("/pipelines/:id/nodes", handler.SaveNodes) - api.PUT("/pipelines/:id/edges", handler.SaveEdges) - api.POST("/pipelines/:id/runs", handler.CreateRun(hub)) + api.POST("/pipelines", handler.AdminOnly(), handler.CreatePipeline) + api.GET("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.GetPipelineDetail) + api.PUT("/pipelines/:id", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.UpdatePipeline) + api.DELETE("/pipelines/:id", handler.AdminOnly(), handler.DeletePipeline) + api.PUT("/pipelines/:id/nodes", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveNodes) + api.PUT("/pipelines/:id/edges", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.SaveEdges) + api.POST("/pipelines/:id/runs", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOperator)), handler.CreateRun(hub)) api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(hub)) + // 单流水线成员:查看列表 viewer,增删 owner + api.GET("/pipelines/:id/members", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelViewer)), handler.ListPipelineMembers) + api.POST("/pipelines/:id/members", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.AddPipelineMember) + api.DELETE("/pipelines/:id/members/:mid", handler.RequirePipelineAccess(handler.LevelRank(handler.LevelOwner)), handler.DeletePipelineMember) + api.GET("/runs", handler.ListRuns) - api.GET("/runs/:runId", handler.GetRun) - api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub)) - api.POST("/runs/:runId/retry", handler.RetryRun(hub)) + api.GET("/runs/:runId", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.GetRun) + api.GET("/runs/:runId/logs/stream", handler.RequireRunAccess(handler.LevelRank(handler.LevelViewer)), handler.StreamLogs(hub)) + api.POST("/runs/:runId/retry", handler.RequireRunAccess(handler.LevelRank(handler.LevelOperator)), handler.RetryRun(hub)) - api.GET("/gitops/apps", handler.ListGitOpsApps) - api.GET("/gitops/argo/apps", handler.ArgoList) - api.POST("/gitops/argo/sync", handler.ArgoSync) - api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree) - api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff) + // GitOps 部署(全局基础设施操作,未按流水线隔离)→ 管理员 + gitops := api.Group("/gitops", handler.AdminOnly()) + { + gitops.GET("/apps", handler.ListGitOpsApps) + gitops.GET("/argo/apps", handler.ArgoList) + gitops.POST("/argo/sync", handler.ArgoSync) + gitops.GET("/argo/:appName/tree", handler.ArgoResourceTree) + gitops.GET("/argo/:appName/diff", handler.ArgoDiff) + } - api.GET("/plugins", handler.ListPlugins) - api.GET("/plugins/available", handler.AvailablePlugins) - api.POST("/plugins/install", handler.InstallPlugin) - api.POST("/plugins/:id/toggle", handler.TogglePlugin) - api.DELETE("/plugins/:id", handler.UninstallPlugin) + // 插件市场 → 管理员 + plugins := api.Group("/plugins", handler.AdminOnly()) + { + plugins.GET("", handler.ListPlugins) + plugins.GET("/available", handler.AvailablePlugins) + plugins.POST("/install", handler.InstallPlugin) + plugins.POST("/:id/toggle", handler.TogglePlugin) + plugins.DELETE("/:id", handler.UninstallPlugin) + } - api.GET("/hosts", handler.ListHosts) - api.POST("/hosts", handler.CreateHost) - api.PUT("/hosts/:id", handler.UpdateHost) - api.DELETE("/hosts/:id", handler.DeleteHost) - api.POST("/hosts/:id/test", handler.TestHost) - - api.GET("/clusters", handler.ListClusters) - api.POST("/clusters", handler.CreateCluster) - api.PUT("/clusters/:id", handler.UpdateCluster) - api.DELETE("/clusters/:id", handler.DeleteCluster) - api.POST("/clusters/:id/test", handler.TestCluster) - - api.GET("/credentials", handler.ListCredentials) - api.POST("/credentials", handler.CreateCredential) - api.PUT("/credentials/:id", handler.UpdateCredential) - api.DELETE("/credentials/:id", handler.DeleteCredential) - - api.GET("/build-nodes", handler.ListBuildNodes) - api.POST("/build-nodes", handler.CreateBuildNode) - api.PUT("/build-nodes/:id", handler.UpdateBuildNode) - api.DELETE("/build-nodes/:id", handler.DeleteBuildNode) - api.POST("/build-nodes/:id/test", handler.TestBuildNode) - api.POST("/build-nodes/:id/disk-test", handler.TestBuildNodeDisk) - - api.GET("/tools", handler.ListTools) - api.PUT("/tools/:id", handler.UpdateTool) - api.POST("/tools/:id/check", handler.CheckTool) - api.POST("/tools/:id/install", handler.InstallTool) - - api.GET("/webhooks", handler.ListWebhooks) - api.POST("/webhooks", handler.CreateWebhook) - api.PUT("/webhooks/:id", handler.UpdateWebhook) - api.DELETE("/webhooks/:id", handler.DeleteWebhook) - api.POST("/webhooks/:id/test", handler.TestWebhook) - - api.GET("/jenkins-servers", handler.ListJenkinsServers) - api.POST("/jenkins-servers", handler.CreateJenkinsServer) - api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer) - api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer) - api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer) - api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs) - api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs) + // 资源(主机/集群/凭证/构建节点/工具/Webhook)与 Jenkins 集成 → 管理员 + hosts := api.Group("/hosts", handler.AdminOnly()) + { + hosts.GET("", handler.ListHosts) + hosts.POST("", handler.CreateHost) + hosts.PUT("/:id", handler.UpdateHost) + hosts.DELETE("/:id", handler.DeleteHost) + hosts.POST("/:id/test", handler.TestHost) + } + clusters := api.Group("/clusters", handler.AdminOnly()) + { + clusters.GET("", handler.ListClusters) + clusters.POST("", handler.CreateCluster) + clusters.PUT("/:id", handler.UpdateCluster) + clusters.DELETE("/:id", handler.DeleteCluster) + clusters.POST("/:id/test", handler.TestCluster) + } + credentials := api.Group("/credentials", handler.AdminOnly()) + { + credentials.GET("", handler.ListCredentials) + credentials.POST("", handler.CreateCredential) + credentials.PUT("/:id", handler.UpdateCredential) + credentials.DELETE("/:id", handler.DeleteCredential) + } + buildNodes := api.Group("/build-nodes", handler.AdminOnly()) + { + buildNodes.GET("", handler.ListBuildNodes) + buildNodes.POST("", handler.CreateBuildNode) + buildNodes.PUT("/:id", handler.UpdateBuildNode) + buildNodes.DELETE("/:id", handler.DeleteBuildNode) + buildNodes.POST("/:id/test", handler.TestBuildNode) + buildNodes.POST("/:id/disk-test", handler.TestBuildNodeDisk) + } + tools := api.Group("/tools", handler.AdminOnly()) + { + tools.GET("", handler.ListTools) + tools.PUT("/:id", handler.UpdateTool) + tools.POST("/:id/check", handler.CheckTool) + tools.POST("/:id/install", handler.InstallTool) + } + webhooks := api.Group("/webhooks", handler.AdminOnly()) + { + webhooks.GET("", handler.ListWebhooks) + webhooks.POST("", handler.CreateWebhook) + webhooks.PUT("/:id", handler.UpdateWebhook) + webhooks.DELETE("/:id", handler.DeleteWebhook) + webhooks.POST("/:id/test", handler.TestWebhook) + } + jenkins := api.Group("/jenkins-servers", handler.AdminOnly()) + { + jenkins.GET("", handler.ListJenkinsServers) + jenkins.POST("", handler.CreateJenkinsServer) + jenkins.PUT("/:id", handler.UpdateJenkinsServer) + jenkins.DELETE("/:id", handler.DeleteJenkinsServer) + jenkins.POST("/:id/test", handler.TestJenkinsServer) + jenkins.GET("/:id/jobs", handler.ListJenkinsJobs) + jenkins.POST("/:id/import", handler.ImportJenkinsJobs) + } } distFS, _ := fs.Sub(staticFS, "static") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 346def5..aee58a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import { Toaster } from 'sonner'; import { useLocation, useNavigate } from 'react-router'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import Sidebar from '@/components/Sidebar'; import TopBar from '@/components/TopBar'; import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor'; @@ -24,11 +24,19 @@ import AiAssistant from '@/components/ai/AiAssistant'; import Login from '@/pages/Login'; import UserManage from '@/pages/UserManage'; import Docs from '@/pages/Docs'; +import PipelineRoles from '@/pages/PipelineRoles'; import { apiClient, auth } from '@/lib/api'; +import { useSession } from '@/providers/session'; -export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings'; +export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'plugins' | 'users' | 'docs' | 'settings' | 'pipeline-roles'; export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布'; +// 仅管理员可见的模块(资源/集成/系统管理类) +const ADMIN_ONLY_MODULES = new Set([ + 'gitops', 'environments', 'credentials', 'build-nodes', 'tools', 'jenkins-import', + 'plugins', 'users', 'settings', 'pipeline-roles', +]); + // 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等) const ROUTE_TO_MODULE: Record = { '/': 'pipelines', @@ -45,6 +53,7 @@ const ROUTE_TO_MODULE: Record = { '/users': 'users', '/docs': 'docs', '/settings': 'settings', + '/pipeline-roles': 'pipeline-roles', }; const MODULE_TO_ROUTE: Record = { @@ -61,12 +70,15 @@ const MODULE_TO_ROUTE: Record = { users: '/users', docs: '/docs', settings: '/settings', + 'pipeline-roles': '/pipeline-roles', }; export default function App() { const location = useLocation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [loggedIn, setLoggedIn] = useState(!!auth.getToken()); + const { isAdmin, loading: sessionLoading } = useSession(); // 未登录访问任何页面 → 跳登录页 useEffect(() => { @@ -85,6 +97,13 @@ export default function App() { const [activeModule, setActiveModule] = useState('pipelines'); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // 普通用户访问管理员模块 → 回流水线列表(菜单已隐藏,此处防直接输 URL) + useEffect(() => { + if (loggedIn && !sessionLoading && !isAdmin && ADMIN_ONLY_MODULES.has(activeModule)) { + navigate('/pipelines', { replace: true }); + } + }, [loggedIn, sessionLoading, isAdmin, activeModule, navigate]); + // Pipeline list/editor view toggle const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list'); const [currentPipelineId, setCurrentPipelineId] = useState(1); @@ -137,6 +156,8 @@ export default function App() { const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId); const latestStatus = currentRuns[0]?.status as string | undefined; const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending'; + // 当前用户对该流水线的权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员(列表未加载前按 4 乐观显示,后端强校验) + const editorAccessLevel = currentPipeline?.accessLevel ?? 4; const handleSelectPipeline = useCallback((id: string) => { setCurrentPipelineId(Number(id) || 1); @@ -183,6 +204,7 @@ export default function App() { isRunning={isPipelineRunning} executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'} onBack={() => { setPipelineView('list'); navigate('/pipelines'); }} + accessLevel={editorAccessLevel} />
{activeTab === '流程配置' && ( @@ -233,6 +255,8 @@ export default function App() { return ; case 'users': return ; + case 'pipeline-roles': + return ; case 'docs': return ; case 'settings': @@ -243,7 +267,19 @@ export default function App() { }; if (!loggedIn || location.pathname === '/login') { - return { setLoggedIn(true); navigate('/pipelines', { replace: true }); }} />; + return ( + <> + + { + setLoggedIn(true); + // 刷新会话信息(切换账号后 ['me'] 缓存需要失效) + queryClient.invalidateQueries({ queryKey: ['me'] }); + navigate('/pipelines', { replace: true }); + }} + /> + + ); } return ( diff --git a/frontend/src/components/BasicInfoPanel.tsx b/frontend/src/components/BasicInfoPanel.tsx index 2f12dff..b94eecf 100644 --- a/frontend/src/components/BasicInfoPanel.tsx +++ b/frontend/src/components/BasicInfoPanel.tsx @@ -15,6 +15,8 @@ export default function BasicInfoPanel({ pipelineId }: Props) { queryFn: () => apiClient.getPipeline(pipelineId), }); const p = detail?.pipeline; + // 编辑权限:「管理」等级及以上才能保存 + const canEdit = (detail?.accessLevel ?? 4) >= 3; const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' }); @@ -56,6 +58,7 @@ export default function BasicInfoPanel({ pipelineId }: Props) {
{p && 更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}} + {canEdit && ( + )}
diff --git a/frontend/src/components/PipelineMembersDialog.tsx b/frontend/src/components/PipelineMembersDialog.tsx new file mode 100644 index 0000000..9ffd973 --- /dev/null +++ b/frontend/src/components/PipelineMembersDialog.tsx @@ -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 = { + 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 ( + !v && onClose()}> + + + 成员授权 — {pipelineName || `流水线 #${pipelineId}`} + + + {/* 添加成员 */} +
+
+
+ + +
+
+ +
+ {LEVEL_OPTIONS.map((opt) => ( + + ))} +
+
+
+
+ {LEVEL_OPTIONS.find((l) => l.value === level)?.desc} +
+ +
+ + {/* 已有直接授权 */} +
+
直接授权({members.length})
+ {membersQuery.isLoading ? ( +
+ 加载中... +
+ ) : members.length === 0 ? ( +
暂无直接授权成员
+ ) : ( +
+ {members.map((m) => { + const badge = levelBadgeStyle[m.level] ?? levelBadgeStyle.viewer; + return ( +
+
+ {m.username} + + {levelLabel(m.level)} + +
+ +
+ ); + })} +
+ )} +
+ + {/* 命中的权限组(只读) */} + {matchedRoles.length > 0 && ( +
+
命中的权限组(只读,在「流水线权限」页管理)
+
+ {matchedRoles.map((r, i) => { + const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer; + return ( + + {r.name} · {r.mode === 'pattern' ? r.pattern : '多选'} ·{' '} + {levelLabel(r.level)} + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ReleasePanel.tsx b/frontend/src/components/ReleasePanel.tsx index 5ea4fe3..b23fc81 100644 --- a/frontend/src/components/ReleasePanel.tsx +++ b/frontend/src/components/ReleasePanel.tsx @@ -44,6 +44,13 @@ export default function ReleasePanel({ pipelineId }: Props) { enabled: !!expandedRunId, }); + // 当前用户对该流水线的权限(详情接口附带):重试需要「执行」等级 + const { data: pipelineDetail } = useQuery({ + queryKey: ['pipeline', pipelineId], + queryFn: () => apiClient.getPipeline(pipelineId), + }); + const canRun = (pipelineDetail?.accessLevel ?? 4) >= 2; + const expandedRun = runs.find((r) => r.runId === expandedRunId); const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending'; const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning); @@ -104,7 +111,7 @@ export default function ReleasePanel({ pipelineId }: Props) { {new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })} - {(r.status === 'failed' || r.status === 'cancelled') && ( + {(r.status === 'failed' || r.status === 'cancelled') && canRun && ( - + {accessLevel >= 3 && ( + + )} + {accessLevel >= 3 && ( + + )} + {accessLevel >= 2 && ( + + )} + setMembersOpen(false)} + /> ); } diff --git a/frontend/src/components/TriggerPanel.tsx b/frontend/src/components/TriggerPanel.tsx index a20acc9..af26cea 100644 --- a/frontend/src/components/TriggerPanel.tsx +++ b/frontend/src/components/TriggerPanel.tsx @@ -22,6 +22,8 @@ export default function TriggerPanel({ pipelineId }: Props) { queryFn: () => apiClient.getPipeline(pipelineId), }); const p = detail?.pipeline; + // 编辑权限:「管理」等级及以上才能保存 + const canEdit = (detail?.accessLevel ?? 4) >= 3; const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' }); const [copied, setCopied] = useState(false); @@ -68,6 +70,7 @@ export default function TriggerPanel({ pipelineId }: Props) {

触发方式

+ {canEdit && ( + )}
diff --git a/frontend/src/components/VariablesPanel.tsx b/frontend/src/components/VariablesPanel.tsx index 9bb88a8..c9adfc4 100644 --- a/frontend/src/components/VariablesPanel.tsx +++ b/frontend/src/components/VariablesPanel.tsx @@ -28,6 +28,8 @@ export default function VariablesPanel({ pipelineId }: Props) { queryFn: () => apiClient.getPipeline(pipelineId), }); const p = detail?.pipeline; + // 编辑权限:「管理」等级及以上才能保存 + const canEdit = (detail?.accessLevel ?? 4) >= 3; const [vars, setVars] = useState([]); const [caches, setCaches] = useState([]); @@ -105,9 +107,13 @@ export default function VariablesPanel({ pipelineId }: Props) {
+ {canEdit ? ( + ) : ( + 只读(需要「管理」权限才能编辑) + )}
); diff --git a/frontend/src/components/flow/PipelineFlowEditor.tsx b/frontend/src/components/flow/PipelineFlowEditor.tsx index 6deed4a..04d14f7 100644 --- a/frontend/src/components/flow/PipelineFlowEditor.tsx +++ b/frontend/src/components/flow/PipelineFlowEditor.tsx @@ -22,7 +22,7 @@ import { import { toast } from 'sonner'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import TektonNode, { type TektonNodeData } from './TektonNode'; -import { apiClient } from '@/lib/api'; +import { apiClient, auth } from '@/lib/api'; interface FlowEditorProps { pipelineId: number; @@ -177,7 +177,9 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit setAllNodeStatus('pending'); toast.success(`运行已触发 ${run.runId}`, { description: `${nodes.length} 个节点开始执行` }); - const es = new EventSource(`/api/runs/${run.runId}/logs/stream`); + // SSE 无法携带 Authorization 头,token 通过查询参数传递 + const token = encodeURIComponent(auth.getToken() ?? ''); + const es = new EventSource(`/api/runs/${run.runId}/logs/stream?token=${token}`); es.addEventListener('log', (evt) => { try { const entry = JSON.parse((evt as MessageEvent).data); @@ -342,9 +344,12 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit {catalogOpen ? : }Task 库
+ {(pipelineData?.accessLevel ?? 4) >= 3 && ( + )} + {(pipelineData?.accessLevel ?? 4) >= 2 && ( + )}
diff --git a/frontend/src/hooks/usePipelineLogs.ts b/frontend/src/hooks/usePipelineLogs.ts index 79c43da..9fb9a4e 100644 --- a/frontend/src/hooks/usePipelineLogs.ts +++ b/frontend/src/hooks/usePipelineLogs.ts @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; +import { auth } from '@/lib/api'; export interface LogMessage { id?: number; @@ -26,7 +27,9 @@ export function usePipelineLogs(runId: string | null, enabled: boolean) { return; } - const es = new EventSource(`/api/runs/${runId}/logs/stream`); + // SSE 无法携带 Authorization 头,token 通过查询参数传递(后端校验签名与 viewer 权限) + const token = encodeURIComponent(auth.getToken() ?? ''); + const es = new EventSource(`/api/runs/${runId}/logs/stream?token=${token}`); es.addEventListener('connected', () => setConnected(true)); es.addEventListener('log', (evt) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bacc142..2b2f42e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -74,6 +74,7 @@ export interface PipelineDetail { pipeline: any nodes: PipelineNodeDTO[] edges: PipelineEdgeDTO[] + accessLevel?: number // 1 查看 / 2 执行 / 3 管理 / 4 管理员 } export interface PipelineRunDTO { @@ -178,6 +179,7 @@ export const apiClient = { // 用户与会话 login: (username: string, password: string) => api('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + me: () => api<{ username: string; role: string; isAdmin: boolean }>('/api/me'), listUsers: () => api('/api/users'), createUser: (b: any) => api('/api/users', { method: 'POST', body: JSON.stringify(b) }), updateUser: (id: any, b: any) => api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(b) }), @@ -187,4 +189,14 @@ export const apiClient = { ldapImport: (users: any[]) => api('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }), testLDAP: (body: any) => api('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }), getDocs: () => api('/api/docs'), + // 流水线权限组(管理员)与单流水线成员 + listPipelineRoles: () => api('/api/pipeline-roles'), + createPipelineRole: (b: any) => api('/api/pipeline-roles', { method: 'POST', body: JSON.stringify(b) }), + updatePipelineRole: (id: any, b: any) => api(`/api/pipeline-roles/${id}`, { method: 'PUT', body: JSON.stringify(b) }), + deletePipelineRole: (id: any) => api(`/api/pipeline-roles/${id}`, { method: 'DELETE' }), + listPipelineMembers: (pipelineId: number | string) => api(`/api/pipelines/${pipelineId}/members`), + addPipelineMember: (pipelineId: number | string, body: { username: string; level: string }) => + api(`/api/pipelines/${pipelineId}/members`, { method: 'POST', body: JSON.stringify(body) }), + deletePipelineMember: (pipelineId: number | string, memberId: any) => + api(`/api/pipelines/${pipelineId}/members/${memberId}`, { method: 'DELETE' }), } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d573b11..7c321c5 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,9 +3,26 @@ import { Rocket, Loader2, KeyRound, User } from 'lucide-react'; import { toast } from 'sonner'; import { apiClient, auth } from '@/lib/api'; +const REMEMBER_KEY = 'flowstack-remember'; + +// 记住密码:明文存 localStorage 仅适合内网工具场景;不勾选则彻底不落盘 +function readRemember(): { username: string; password: string } | null { + try { + const raw = localStorage.getItem(REMEMBER_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed?.username && parsed?.password) return parsed; + } catch { + // 脏数据忽略 + } + return null; +} + export default function Login({ onSuccess }: { onSuccess: () => void }) { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); + const remembered = readRemember(); + const [username, setUsername] = useState(remembered?.username ?? ''); + const [password, setPassword] = useState(remembered?.password ?? ''); + const [remember, setRemember] = useState(!!remembered); const [loading, setLoading] = useState(false); const submit = async (e?: React.FormEvent) => { @@ -18,6 +35,11 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) { try { const res = await apiClient.login(username.trim(), password); auth.setToken(res.token); + if (remember) { + localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username: username.trim(), password })); + } else { + localStorage.removeItem(REMEMBER_KEY); + } toast.success(`欢迎,${res.nickname || res.username}`); onSuccess(); } catch (err) { @@ -69,6 +91,15 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) { /> + - {/* New pipeline button */} - + {/* New pipeline button(新建/删除仅管理员) */} + {isAdmin && ( + + )} @@ -314,7 +320,11 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (

未找到流水线

-

尝试调整搜索条件或新建流水线

+

+ {items.length === 0 && !isAdmin + ? '暂无被授权的流水线,请联系管理员在「流水线权限」中授权' + : '尝试调整搜索条件或新建流水线'} +

) : viewMode === 'card' ? (
@@ -397,19 +407,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
- + {pipeline.accessLevel >= 2 && ( + + )} - + {isAdmin && ( + + )}
@@ -506,19 +520,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: ( {pipeline.triggerText}
- + {pipeline.accessLevel >= 2 && ( + + )} - + {isAdmin && ( + + )}
diff --git a/frontend/src/pages/PipelineRoles.tsx b/frontend/src/pages/PipelineRoles.tsx new file mode 100644 index 0000000..20d2c66 --- /dev/null +++ b/frontend/src/pages/PipelineRoles.tsx @@ -0,0 +1,488 @@ +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Plus, Search, Trash2, Edit2, Loader2, ShieldCheck, ListChecks, Wand2, CheckSquare, Square, X, +} from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { LEVEL_OPTIONS, levelLabel, levelBadgeStyle } from '@/components/PipelineMembersDialog'; + +// 与后端 globToRegexp 语义一致:* 任意字符、? 单字符,其余按字面量(不锚定,部分命中即可) +function globMatch(glob: string, s: string): boolean { + try { + const re = new RegExp(glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*').replace(/\\\?/g, '.')); + return re.test(s); + } catch { + return false; + } +} + +interface RoleForm { + name: string; + scopeMode: 'manual' | 'pattern'; + pattern: string; + pipelineIds: number[]; + level: string; + members: string[]; +} + +const emptyForm: RoleForm = { + name: '', scopeMode: 'manual', pattern: '', pipelineIds: [], level: 'operator', members: [], +}; + +const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }; + +export default function PipelineRoles() { + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [pipelineSearch, setPipelineSearch] = useState(''); + const [userSearch, setUserSearch] = useState(''); + + const rolesQuery = useQuery({ queryKey: ['pipeline-roles'], queryFn: apiClient.listPipelineRoles }); + const pipelinesQuery = useQuery({ queryKey: ['pipelines'], queryFn: apiClient.listPipelines }); + const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers }); + + const roles: any[] = rolesQuery.data ?? []; + const pipelines: any[] = pipelinesQuery.data ?? []; + const users: any[] = usersQuery.data ?? []; + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['pipeline-roles'] }); + + const saveMutation = useMutation({ + mutationFn: (f: RoleForm) => { + const body = { name: f.name.trim(), scopeMode: f.scopeMode, pattern: f.pattern.trim(), pipelineIds: f.pipelineIds, level: f.level, members: f.members }; + return editing ? apiClient.updatePipelineRole(editing.id, body) : apiClient.createPipelineRole(body); + }, + onSuccess: () => { + toast.success(editing ? '权限组已更新' : '权限组已创建'); + invalidate(); + setDialogOpen(false); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: any) => apiClient.deletePipelineRole(id), + onSuccess: () => { + toast.success('权限组已删除'); + invalidate(); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const openCreate = () => { + setEditing(null); + setForm(emptyForm); + setPipelineSearch(''); + setUserSearch(''); + setDialogOpen(true); + }; + + const openEdit = (r: any) => { + setEditing(r); + setForm({ + name: r.name, scopeMode: r.scopeMode || 'manual', pattern: r.pattern || '', + pipelineIds: (r.pipelineIds ?? []).map(Number), level: r.level, members: r.members ?? [], + }); + setPipelineSearch(''); + setUserSearch(''); + setDialogOpen(true); + }; + + const submit = () => { + if (!form.name.trim()) { + toast.error('请填写名称'); + return; + } + if (form.scopeMode === 'manual' && form.pipelineIds.length === 0) { + toast.error('请至少勾选一条流水线'); + return; + } + if (form.scopeMode === 'pattern' && !form.pattern.trim()) { + toast.error('请填写名称匹配规则,如 cex-*'); + return; + } + saveMutation.mutate(form); + }; + + // 手动多选列表(带搜索) + const filteredPipelines = useMemo(() => { + const q = pipelineSearch.toLowerCase(); + return q ? pipelines.filter((p) => p.name?.toLowerCase().includes(q)) : pipelines; + }, [pipelines, pipelineSearch]); + + // 通配符实时预览:命中多少条流水线 + const patternPreview = useMemo(() => { + if (form.scopeMode !== 'pattern' || !form.pattern.trim()) return null; + const n = pipelines.filter( + (p) => globMatch(form.pattern.trim(), p.name || '') || (p.jenkinsJobName && globMatch(form.pattern.trim(), p.jenkinsJobName)), + ).length; + return n; + }, [form.scopeMode, form.pattern, pipelines]); + + const filteredUsers = useMemo(() => { + const q = userSearch.toLowerCase(); + return q ? users.filter((u: any) => u.username?.toLowerCase().includes(q) || u.nickname?.toLowerCase().includes(q)) : users; + }, [users, userSearch]); + + const pipelineNameById = useMemo(() => { + const m = new Map(); + pipelines.forEach((p) => m.set(Number(p.id), p.name)); + return m; + }, [pipelines]); + + const togglePipeline = (id: number) => + setForm((f) => ({ + ...f, + pipelineIds: f.pipelineIds.includes(id) ? f.pipelineIds.filter((x) => x !== id) : [...f.pipelineIds, id], + })); + const toggleMember = (username: string) => + setForm((f) => ({ + ...f, + members: f.members.includes(username) ? f.members.filter((x) => x !== username) : [...f.members, username], + })); + + return ( +
+ {/* Header */} +
+
+

流水线权限

+ + {roles.length} + + + 未被授权的普通用户看不到任何流水线;授权后按等级「查看 / 发布 / 管理」放开 + +
+ +
+ + {/* Table */} +
+ {rolesQuery.isLoading ? ( +
+ 加载中... +
+ ) : roles.length === 0 ? ( +
+ +
还没有权限组
+
+ 新建一个权限组:勾选流水线 → 选择等级 → 添加成员,全程点选无需输入规则 +
+
+ ) : ( +
+ + + + {['名称', '授权范围', '等级', '成员', '匹配流水线', '操作'].map((h) => ( + + ))} + + + + {roles.map((r) => { + const badge = levelBadgeStyle[r.level] ?? levelBadgeStyle.viewer; + const memberText = (r.members ?? []).slice(0, 3).join('、') + ((r.members?.length ?? 0) > 3 ? ` 等 ${r.members.length} 人` : ''); + return ( + + + + + + + + + ); + })} + +
+ {h} +
+ {r.name} + + {r.scopeMode === 'pattern' ? ( + + {r.pattern} + + ) : ( + + {r.pipelineIds?.length ?? 0} 条流水线 + + )} + + + {levelLabel(r.level)} + + + {r.members?.length ? memberText : —} + + {r.matchedPipelines ?? 0} 条 + +
+ + +
+
+
+ )} +
+ + {/* 新建/编辑弹窗 */} + { setDialogOpen(v); if (!v) setEditing(null); }}> + + + {editing ? '编辑权限组' : '新建权限组'} + + +
+ {/* 名称 */} +
+ + setForm({ ...form, name: e.target.value })} + placeholder="如:cex 发布组" + /> +
+ + {/* 授权范围 */} +
+ +
+ + +
+ + {form.scopeMode === 'manual' ? ( +
+
+ + setPipelineSearch(e.target.value)} + placeholder="搜索流水线..." + className="bg-transparent outline-none flex-1 text-[12px]" + style={{ color: 'var(--text-primary)' }} + /> + + +
+
+ {filteredPipelines.map((p) => { + const checked = form.pipelineIds.includes(Number(p.id)); + return ( + + ); + })} + {filteredPipelines.length === 0 && ( +
没有匹配的流水线
+ )} +
+
+ 已选 {form.pipelineIds.length} 条 + {form.pipelineIds.length > 0 && ( + + {form.pipelineIds.slice(0, 5).map((id) => ( + + {pipelineNameById.get(id) ?? `#${id}`} + togglePipeline(id)} /> + + ))} + {form.pipelineIds.length > 5 && …} + + )} +
+
+ ) : ( +
+ setForm({ ...form, pattern: e.target.value })} + placeholder="cex-*" + /> +
+ 只需用 * 代表任意字符,例如 cex-* 授权所有 cex- 开头的流水线,不需要正则表达式。 +
+ {patternPreview !== null && ( +
0 ? '#1a7f37' : '#cf222e' }}> + 当前规则命中 {patternPreview} 条流水线{patternPreview === 0 ? '(请检查写法,如 cex-*)' : ''} +
+ )} +
+ )} +
+ + {/* 权限等级 */} +
+ +
+ {LEVEL_OPTIONS.map((opt) => { + const active = form.level === opt.value; + return ( + + ); + })} +
+
+ + {/* 成员 */} +
+ +
+
+ + setUserSearch(e.target.value)} + placeholder="搜索用户..." + className="bg-transparent outline-none flex-1 text-[12px]" + style={{ color: 'var(--text-primary)' }} + /> +
+
+ {filteredUsers.map((u: any) => { + const checked = form.members.includes(u.username); + return ( + + ); + })} + {filteredUsers.length === 0 && ( +
没有匹配的用户
+ )} +
+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/ReleaseRecords.tsx b/frontend/src/pages/ReleaseRecords.tsx index ea67104..5be8ba5 100644 --- a/frontend/src/pages/ReleaseRecords.tsx +++ b/frontend/src/pages/ReleaseRecords.tsx @@ -55,6 +55,7 @@ interface RunRecord { buildNumber?: number; externalUrl?: string; analysis?: string; // 失败时后端自动生成的 AI 失败分析 + canRetry?: boolean; // 后端按当前用户权限附加:执行等级及以上才允许重试 } interface RunStage { @@ -284,7 +285,7 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: { className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> 查看日志 - {(record.status === 'failed' || record.status === 'cancelled') && ( + {(record.status === 'failed' || record.status === 'cancelled') && record.canRetry && (