diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index f440891..a531bed 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -279,6 +279,15 @@ export function useApi(): WorkspaceBoundApi { rawApi.updatePlatformEmployee(userId, input), deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId), deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId), + // Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId) + listPlatformRoles: () => rawApi.listPlatformRoles(), + getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode), + listPlatformPermissions: () => rawApi.listPlatformPermissions(), + createPlatformRole: (input) => rawApi.createPlatformRole(input), + updatePlatformRole: (roleCode, input) => rawApi.updatePlatformRole(roleCode, input), + deletePlatformRole: (roleCode) => rawApi.deletePlatformRole(roleCode), + updatePlatformRolePermissions: (roleCode, permissionCodes) => + rawApi.updatePlatformRolePermissions(roleCode, permissionCodes), createScheduleNode: (scheduleId, input) => rawApi.createScheduleNode(workspaceId, scheduleId, input), updateScheduleNode: (scheduleId, nodeId, input) => diff --git a/frontend/app/features/admin/PermissionCheckboxList.tsx b/frontend/app/features/admin/PermissionCheckboxList.tsx new file mode 100644 index 0000000..ad48719 --- /dev/null +++ b/frontend/app/features/admin/PermissionCheckboxList.tsx @@ -0,0 +1,83 @@ +import type { PlatformPermission } from "../../services/api"; + +/** 模块编码 → 展示名(用于权限分组标题)。 */ +const MODULE_LABELS: Record = { + dashboard: "工作台", + script: "构建脚本", + schedule: "调度配置", + system: "系统管理", +}; + +/** + * 权限勾选列表:按 module_code 分组渲染。 + * - targetRoleCode === "admin" 时 system:view 锁定勾选(后端守卫); + * - 非 admin 角色 system:* 全部锁定(后端守卫)。 + */ +export function PermissionCheckboxList({ + permissions, + selected, + targetRoleCode, + onToggle, +}: { + permissions: PlatformPermission[]; + selected: string[]; + targetRoleCode: string; + onToggle: (code: string) => void; +}) { + const groups = permissions.reduce>( + (acc, permission) => { + const bucket = acc[permission.module_code] ?? []; + bucket.push(permission); + acc[permission.module_code] = bucket; + return acc; + }, + {}, + ); + + if (permissions.length === 0) { + return

暂无可用权限

; + } + + const isAdmin = targetRoleCode === "admin"; + + return ( +
+ {Object.entries(groups).map(([module, items]) => ( +
+
+ {MODULE_LABELS[module] ?? module} +
+ {items.map((permission) => { + const code = permission.permission_code; + const locked = isAdmin + ? code === "system:view" + : code.startsWith("system:"); + const checked = selected.includes(code); + return ( + + ); + })} +
+ ))} +
+ ); +} diff --git a/frontend/app/features/admin/PermissionEditDialog.tsx b/frontend/app/features/admin/PermissionEditDialog.tsx new file mode 100644 index 0000000..74ef8c9 --- /dev/null +++ b/frontend/app/features/admin/PermissionEditDialog.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; + +import { X } from "lucide-react"; +import type { PlatformPermission, Role } from "../../services/api"; +import { Button } from "~/components/ui/button"; +import { + modalBackdropClass, + modalCompactClass, + modalEyebrowClass, + modalFooterClass, + modalFormClass, + modalHeaderClass, + modalTitleClass, +} from "../platform/modalUi"; +import { PermissionCheckboxList } from "./PermissionCheckboxList"; + +const CLOSE_BUTTON_CLASS = + "size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]"; +const SECONDARY_BUTTON_CLASS = + "h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]"; +const PRIMARY_BUTTON_CLASS = + "h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]"; + +export function PermissionEditDialog({ + open, + target, + permissions, + currentCodes, + saving, + onSubmit, + onClose, +}: { + open: boolean; + target: Role | null; + permissions: PlatformPermission[]; + currentCodes: string[]; + saving: boolean; + onSubmit: (codes: string[]) => void; + onClose: () => void; +}) { + const [selectedCodes, setSelectedCodes] = useState(() => { + if (!open || !target) return []; + const initial = [...currentCodes]; + // admin 角色必须保留 system:view(后端守卫);前端锁定勾选兜底。 + if (target.role_code === "admin" && !initial.includes("system:view")) { + initial.push("system:view"); + } + return initial; + }); + + useEffect(() => { + const initial = [...currentCodes]; + if (target?.role_code === "admin" && !initial.includes("system:view")) { + initial.push("system:view"); + } + setSelectedCodes(initial); + }, [open, target, currentCodes]); + + const togglePermission = (code: string): void => { + if (!target) return; + const locked = target.role_code === "admin" + ? code === "system:view" + : code.startsWith("system:"); + if (locked) return; + setSelectedCodes((current) => + current.includes(code) + ? current.filter((item) => item !== code) + : [...current, code], + ); + }; + + if (!open || !target) return null; + + return ( +
+
+
+
+ PERMISSIONS +

权限配置 · {target.role_name}

+
+ +
+
+ {target.role_code === "admin" && ( +

admin 角色必须保留 system:view 权限。

+ )} + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/app/features/admin/RoleEditDialog.tsx b/frontend/app/features/admin/RoleEditDialog.tsx new file mode 100644 index 0000000..74b8d58 --- /dev/null +++ b/frontend/app/features/admin/RoleEditDialog.tsx @@ -0,0 +1,263 @@ +import { useEffect, useState } from "react"; + +import { X } from "lucide-react"; +import type { + PlatformPermission, + Role, + RoleCreatePayload, + RoleUpdatePayload, +} from "../../services/api"; +import { Button } from "~/components/ui/button"; +import { + formFieldClass, + formInputClass, + formTextareaClass, + modalBackdropClass, + modalCompactClass, + modalEyebrowClass, + modalFooterClass, + modalFormClass, + modalHeaderClass, + modalTitleClass, +} from "../platform/modalUi"; +import { PermissionCheckboxList } from "./PermissionCheckboxList"; + +const RESERVED_ROLE_CODES = new Set(["admin", "developer"]); +const ROLE_CODE_PATTERN = /^[a-z][a-z0-9_-]{1,63}$/; + +type RoleFormState = { + role_code: string; + role_name: string; + description: string; + permission_codes: string[]; +}; + +const EMPTY_FORM: RoleFormState = { + role_code: "", + role_name: "", + description: "", + permission_codes: [], +}; + +/** 实时校验 role_code:空值不报错,提交时由必填拦截兜底。 */ +function validateRoleCode(value: string): string { + const code = value.trim(); + if (!code) return ""; + if (RESERVED_ROLE_CODES.has(code)) { + return `role_code "${code}" 是预留字,不可使用`; + } + if (!/^[a-z]/.test(code)) return "role_code 必须以小写字母开头"; + if (!/^[a-z][a-z0-9_-]*$/.test(code)) { + return "role_code 仅含小写字母/数字/下划线/连字符"; + } + if (code.length < 2) return "role_code 至少 2 个字符"; + if (code.length > 64) return "role_code 最多 64 个字符"; + if (!ROLE_CODE_PATTERN.test(code)) { + return "role_code 必须以小写字母开头,仅含字母/数字/下划线/连字符"; + } + return ""; +} + +const CLOSE_BUTTON_CLASS = + "size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]"; +const SECONDARY_BUTTON_CLASS = + "h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]"; +const PRIMARY_BUTTON_CLASS = + "h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]"; + +export function RoleEditDialog({ + open, + editing, + permissions, + saving, + onNotify, + onSubmit, + onClose, +}: { + open: boolean; + editing: Role | null; + permissions: PlatformPermission[]; + saving: boolean; + onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void; + onSubmit: (payload: RoleCreatePayload | RoleUpdatePayload) => void; + onClose: () => void; +}) { + const [form, setForm] = useState(() => + editing + ? { + role_code: editing.role_code, + role_name: editing.role_name, + description: editing.description ?? "", + permission_codes: [...editing.permission_codes], + } + : EMPTY_FORM, + ); + const [roleCodeError, setRoleCodeError] = useState(""); + + useEffect(() => { + setForm( + editing + ? { + role_code: editing.role_code, + role_name: editing.role_name, + description: editing.description ?? "", + permission_codes: [...editing.permission_codes], + } + : EMPTY_FORM, + ); + setRoleCodeError(""); + }, [open, editing]); + + const handleRoleCodeChange = ( + event: React.ChangeEvent, + ): void => { + const value = event.target.value; + setForm((current) => ({ ...current, role_code: value })); + setRoleCodeError(validateRoleCode(value)); + }; + + const toggleCreatePermission = (code: string): void => { + // 新建角色为非 admin,system:* 由后端守卫禁止;此处防御性拦截 + if (code.startsWith("system:")) return; + setForm((current) => ({ + ...current, + permission_codes: current.permission_codes.includes(code) + ? current.permission_codes.filter((item) => item !== code) + : [...current.permission_codes, code], + })); + }; + + const handleSubmit = (event: React.FormEvent): void => { + event.preventDefault(); + if (!form.role_name.trim()) { + onNotify({ tone: "error", message: "请输入角色名称" }); + return; + } + if (!editing) { + const codeError = validateRoleCode(form.role_code); + if (codeError) { + setRoleCodeError(codeError); + onNotify({ tone: "error", message: codeError }); + return; + } + } + const payload: RoleCreatePayload | RoleUpdatePayload = editing + ? { + // 内置角色 role_name 锁定;description 可改 + ...(editing.is_builtin ? {} : { role_name: form.role_name.trim() }), + description: form.description.trim() || null, + } + : { + role_code: form.role_code.trim(), + role_name: form.role_name.trim(), + description: form.description.trim() || undefined, + permission_codes: form.permission_codes, + }; + onSubmit(payload); + }; + + if (!open) return null; + + return ( +
+
+
+
+ ROLE +

{editing ? "编辑角色" : "新建角色"}

+
+ +
+
void handleSubmit(event)}> + {editing ? ( + + ) : ( + + )} + +