From 6a7760f33f313f2366979ba064b055dee9c26aeb Mon Sep 17 00:00:00 2001 From: = Date: Fri, 20 Sep 2024 18:36:14 +0530 Subject: [PATCH] feat: updated ui for new permission --- .../context/ProjectPermissionContext/types.ts | 24 + frontend/src/hooks/api/roles/types.ts | 2 +- .../ProjectRoleModifySection.utils.ts | 535 +++++++++++++----- .../RolePermissionRow.tsx | 219 ------- .../RolePermissionSecretFoldersRow.tsx | 71 --- .../RolePermissionSecretsRow.tsx | 248 -------- .../RolePermissionsSection.tsx | 234 +++----- .../components/GeneralPermissionOptions.tsx | 130 +++++ .../components/NewPermissionRule.tsx | 117 ++++ .../components/SecretPermissionConditions.tsx | 127 +++++ 10 files changed, 884 insertions(+), 823 deletions(-) delete mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx delete mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx delete mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index b768f5f72..349d01f36 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -7,6 +7,30 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum PermissionConditionOperators { + $IN = "$in", + $ALL = "$all", + $REGEX = "$regex", + $EQ = "$eq", + $NEQ = "$neq", + $GLOB = "$glob" +} + +export type TPermissionConditionOperators = { + [PermissionConditionOperators.$IN]: string[]; + [PermissionConditionOperators.$ALL]: string[]; + [PermissionConditionOperators.$EQ]: string; + [PermissionConditionOperators.$NEQ]: string; + [PermissionConditionOperators.$REGEX]: string; + [PermissionConditionOperators.$GLOB]: string; +}; + +export type TPermissionCondition = Record< + string, + | string + | { $in: string[]; $all: string[]; $regex: string; $eq: string; $neq: string; $glob: string } +>; + export enum ProjectPermissionSub { Role = "role", Member = "member", diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index e2d1b533a..da28d9ca8 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -40,7 +40,7 @@ export type TPermission = { export type TProjectPermission = { conditions?: Record; - action: string; + action: string | string[]; subject: string | string[]; }; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts index 8b4958ee0..cc5c677d0 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts @@ -1,29 +1,39 @@ -/* eslint-disable no-param-reassign */ import { z } from "zod"; -import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { + PermissionConditionOperators, + TPermissionCondition, + TPermissionConditionOperators +} from "@app/context/ProjectPermissionContext/types"; import { TProjectPermission } from "@app/hooks/api/roles/types"; -const generalPermissionSchema = z - .object({ - read: z.boolean().optional(), - edit: z.boolean().optional(), - delete: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional(); +const GeneralPolicyActionSchema = z.object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional() +}); -const multiEnvPermissionSchema = z - .object({ - secretPath: z.string().trim().optional(), - read: z.boolean().optional(), - edit: z.boolean().optional(), - delete: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional(); +const SecretFolderPolicyActionSchema = z.object({ + read: z.boolean().optional() +}); -const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const; +const SecretRollbackPolicyActionSchema = z.object({ + read: z.boolean().optional(), + create: z.boolean().optional() +}); + +const WorkspacePolicyActionSchema = z.object({ + edit: z.boolean().optional(), + delete: z.boolean().optional() +}); + +const ConditionSchema = z.object({ + operator: z.string(), + lhs: z.string(), + rhs: z.string() +}); export const formSchema = z.object({ name: z.string().trim(), @@ -35,139 +45,412 @@ export const formSchema = z.object({ .refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }), permissions: z .object({ - secrets: z.record(multiEnvPermissionSchema).optional(), - "secret-folders": generalPermissionSchema.optional(), - member: generalPermissionSchema, - groups: generalPermissionSchema, - identity: generalPermissionSchema, - role: generalPermissionSchema, - integrations: generalPermissionSchema, - webhooks: generalPermissionSchema, - "service-tokens": generalPermissionSchema, - settings: generalPermissionSchema, - environments: generalPermissionSchema, - tags: generalPermissionSchema, - "ip-allowlist": generalPermissionSchema, - "certificate-authorities": generalPermissionSchema, - certificates: generalPermissionSchema, - "pki-alerts": generalPermissionSchema, - "pki-collections": generalPermissionSchema, - "certificate-templates": generalPermissionSchema, - // akhilmhdh: refactor all keys like below - [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, - workspace: z - .object({ - edit: z.boolean().optional(), - delete: z.boolean().optional() - }) - .optional(), - "secret-rollback": z - .object({ - read: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional() + [ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({ + conditions: ConditionSchema.array().optional().default([]) + }) + .array() + .default([]), + [ProjectPermissionSub.SecretFolders]: SecretFolderPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Integrations]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Webhooks]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Certificates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Workspace]: WorkspacePolicyActionSchema.array().default([]), + [ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]) }) + .partial() .optional() }); export type TFormSchema = z.infer; -const multiEnvApi2Form = ( - formVal: Record, - permission: TProjectPermission -) => { - const isCustomRule = Boolean(permission?.conditions?.environment); - // full access - if (isCustomRule && formVal && !formVal?.custom) { - formVal.custom = { read: true, edit: true, delete: true, create: true }; - } - - const secretEnv = permission?.conditions?.environment || "all"; - const secretPath = permission?.conditions?.secretPath?.$glob; - // initialize - if (formVal && !formVal?.[secretEnv]) { - formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath }; - } - - formVal[secretEnv][permission.action] = true; +const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { + const formConditions: z.infer[] = []; + Object.entries(caslConditions).forEach(([type, condition]) => { + if (typeof condition === "string") { + formConditions.push({ + operator: PermissionConditionOperators.$EQ, + lhs: type, + rhs: condition + }); + } else { + Object.keys(condition).forEach((conditionOperator) => { + const rhs = condition[conditionOperator as PermissionConditionOperators]; + formConditions.push({ + operator: conditionOperator, + lhs: type, + rhs: typeof rhs === "string" ? rhs : rhs.join(",") + }); + }); + } + }); + return formConditions; }; // convert role permission to form compatiable data structure export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { - // any because if it set it as form type due to the discriminated union type of ts - // i would have to write a if loop with both conditions same - const formVal: Record = {}; + const formVal: Partial = {}; permissions.forEach((permission) => { - const { subject: caslSub, action } = permission; - const subject = typeof caslSub === "string" ? caslSub : caslSub[0]; - if (!formVal?.[subject]) formVal[subject] = {}; + const { subject: caslSub, action, conditions } = permission; + const subject = (typeof caslSub === "string" ? caslSub : caslSub[0]) as ProjectPermissionSub; - if (subject === "secrets") { - multiEnvApi2Form(formVal[subject], permission); - } else { - // everything else follows same pattern - // formVal[settings][read | write] = true - formVal[subject][action] = true; + if ( + [ + ProjectPermissionSub.Secrets, + ProjectPermissionSub.Member, + ProjectPermissionSub.Groups, + ProjectPermissionSub.Identity, + ProjectPermissionSub.Role, + ProjectPermissionSub.Integrations, + ProjectPermissionSub.Webhooks, + ProjectPermissionSub.ServiceTokens, + ProjectPermissionSub.Settings, + ProjectPermissionSub.Environments, + ProjectPermissionSub.AuditLogs, + ProjectPermissionSub.IpAllowList, + ProjectPermissionSub.CertificateAuthorities, + ProjectPermissionSub.Certificates, + ProjectPermissionSub.PkiAlerts, + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.CertificateTemplates, + ProjectPermissionSub.SecretApproval, + ProjectPermissionSub.Tags, + ProjectPermissionSub.SecretRotation, + ProjectPermissionSub.Kms + ].includes(subject) + ) { + const canRead = action.includes(ProjectPermissionActions.Read); + const canEdit = action.includes(ProjectPermissionActions.Edit); + const canDelete = action.includes(ProjectPermissionActions.Delete); + const canCreate = action.includes(ProjectPermissionActions.Create); + + // from above statement we are sure it won't be undefined + if (subject === ProjectPermissionSub.Secrets) { + if (!formVal[subject]) formVal[subject] = []; + formVal[subject]!.push({ + read: canRead, + create: canCreate, + edit: canEdit, + delete: canDelete, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [] + }); + } else { + // deduplicate multiple rules for other policies + // because they don't have condition it doesn't make sense for multiple rules + if (!formVal[subject]) formVal[subject] = [{}]; + if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; + if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true; + if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; + if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true; + } + } else if (subject === ProjectPermissionSub.Workspace) { + const canEdit = action.includes(ProjectPermissionActions.Edit); + const canDelete = action.includes(ProjectPermissionActions.Delete); + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canEdit) formVal[subject as ProjectPermissionSub.Workspace]![0].edit = true; + if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true; + } else if (subject === ProjectPermissionSub.SecretRollback) { + const canRead = action.includes(ProjectPermissionActions.Read); + const canCreate = action.includes(ProjectPermissionActions.Create); + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; + if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; + } else if (subject === ProjectPermissionSub.SecretFolders) { + const canRead = action.includes(ProjectPermissionActions.Read); + if (!formVal[subject]) formVal[subject] = []; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; } }); - return formVal; }; -const multiEnvForm2Api = ( - permissions: TProjectPermission[], - formVal: Record, - subject: "secrets" +const convertFormOperatorToCaslCondition = ( + conditions: { lhs: string; rhs: string; operator: string }[] ) => { - if (!formVal) return; - - const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); - // if any of them is set in all push it without any condition - PERMISSION_ACTIONS.forEach((action) => { - if (formVal?.all?.[action]) permissions.push({ action, subject }); + const caslCondition: Record> = {}; + conditions.forEach((el) => { + if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {}; + if ( + el.operator === PermissionConditionOperators.$IN || + el.operator === PermissionConditionOperators.$ALL + ) { + caslCondition[el.lhs][el.operator] = el.rhs.split(","); + } else { + caslCondition[el.lhs][ + el.operator as Exclude< + PermissionConditionOperators, + PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN + > + ] = el.rhs; + } }); - - if (!isFullAccess) { - Object.keys(formVal || {}) - .filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter - .forEach((slug) => { - const actions = Object.keys(formVal?.[slug] || {}) as [ - "read", - "edit", - "create", - "delete", - "secretPath" - ]; - actions.forEach((action) => { - // if not full access for an action - if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) { - const conditions: Record = { environment: slug }; - if (formVal[slug]?.secretPath) - conditions.secretPath = { $glob: formVal?.[slug]?.secretPath }; - - permissions.push({ action, subject, conditions }); - } - }); - }); - } + return caslCondition; }; export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { const permissions: TProjectPermission[] = []; // other than workspace everything else follows same // if in future there is a different follow the above on how workspace is done - Object.entries(formVal || {}).forEach(([rule, actions]) => { - if (rule === "secrets") { - multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(actions || {})), rule); - } else if (actions) { - Object.entries(actions).forEach(([action, isAllowed]) => { - if (isAllowed) { - permissions.push({ subject: rule, action }); - } + Object.entries(formVal || {}).forEach(([subject, rules]) => { + rules.forEach((actions) => { + const caslActions = Object.keys(actions).filter( + (el) => actions?.[el as keyof typeof actions] + ); + const caslConditions = + "conditions" in actions + ? convertFormOperatorToCaslCondition(actions.conditions) + : undefined; + + permissions.push({ + action: caslActions, + subject: [subject], + conditions: caslConditions }); - } + }); }); return permissions; }; + +export type TProjectPermissionObject = { + [K in ProjectPermissionSub]: { + title: string; + actions: { + label: string; + value: keyof Omit< + NonNullable[K]>[number], + "conditions" + >; + }[]; + }; +}; + +export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { + [ProjectPermissionSub.Secrets]: { + title: "Secrets", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretFolders]: { + title: "Secret Folders", + actions: [{ label: "Read", value: "read" }] + }, + [ProjectPermissionSub.Kms]: { + title: "KMS", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Integrations]: { + title: "Integrations", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Workspace]: { + title: "Project", + actions: [ + { label: "Update project details", value: "edit" }, + { label: "Delete project", value: "delete" } + ] + }, + [ProjectPermissionSub.Role]: { + title: "Roles", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Member]: { + title: "User Management", + actions: [ + { label: "View all members", value: "read" }, + { label: "Invite members", value: "create" }, + { label: "Edit members", value: "edit" }, + { label: "Remove members", value: "delete" } + ] + }, + [ProjectPermissionSub.Groups]: { + title: "Group Management", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Identity]: { + title: "Machine Identity Management", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Webhooks]: { + title: "Webhooks", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.ServiceTokens]: { + title: "Service Tokens", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Settings]: { + title: "Settings", + actions: [ + { label: "Read", value: "read" }, + { label: "Modify", value: "edit" } + ] + }, + [ProjectPermissionSub.Environments]: { + title: "Environments", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Tags]: { + title: "Tags", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.AuditLogs]: { + title: "Audit Logs", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.IpAllowList]: { + title: "IP Allowlist", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.CertificateAuthorities]: { + title: "Certificate Authorities", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.Certificates]: { + title: "Certificates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.CertificateTemplates]: { + title: "Certificate Templates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.PkiCollections]: { + title: "PKI Collections", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.PkiAlerts]: { + title: "PKI Alerts", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretApproval]: { + title: "Secret Protect policy", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretRotation]: { + title: "Secret Rotation", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SecretRollback]: { + title: "Secret Rollback", + actions: [ + { label: "Perform rollback", value: "create" }, + { label: "View", value: "read" } + ] + } +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx deleted file mode 100644 index 725a2310e..000000000 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; -import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; -import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -const GENERAL_PERMISSIONS = [ - { action: "read", label: "View" }, - { action: "create", label: "Create" }, - { action: "edit", label: "Modify" }, - { action: "delete", label: "Remove" } -] as const; - -const WORKSPACE_PERMISSIONS = [ - { action: "edit", label: "Update project details" }, - { action: "delete", label: "Delete projects" } -] as const; - -const MEMBERS_PERMISSIONS = [ - { action: "read", label: "View all members" }, - { action: "create", label: "Invite members" }, - { action: "edit", label: "Edit members" }, - { action: "delete", label: "Remove members" } -] as const; - -const SECRET_ROLLBACK_PERMISSIONS = [ - { action: "create", label: "Perform Rollback" }, - { action: "read", label: "View" } -] as const; - -const getPermissionList = (option: Props["formName"]) => { - switch (option) { - case "workspace": - return WORKSPACE_PERMISSIONS; - case "member": - return MEMBERS_PERMISSIONS; - case "secret-rollback": - return SECRET_ROLLBACK_PERMISSIONS; - default: - return GENERAL_PERMISSIONS; - } -}; - -type PermissionName = - | `permissions.workspace.${"edit" | "delete"}` - | `permissions.secret-rollback.${"create" | "read"}` - | `permissions.${Exclude< - keyof NonNullable, - "workspace" | "secret-rollback" | "secrets" - >}.${"read" | "create" | "edit" | "delete"}`; - -type Props = { - isEditable: boolean; - title: string; - formName: keyof Omit, "secrets">; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -export const RolePermissionRow = ({ isEditable, title, formName, control, setValue }: Props) => { - const [isRowExpanded, setIsRowExpanded] = useToggle(); - const [isCustom, setIsCustom] = useToggle(); - - const rule = useWatch({ - control, - name: `permissions.${formName}` - }); - - const selectedPermissionCategory = useMemo(() => { - const actions = Object.keys(rule || {}) as Array; - - switch (formName) { - default: { - const totalActions = GENERAL_PERMISSIONS.length; - const score = actions - .map((key) => (rule?.[key] ? 1 : 0)) - .reduce((a, b) => a + b, 0 as number); - if (isCustom) return Permission.Custom; - if (score === 0) return Permission.NoAccess; - if (score === totalActions) return Permission.FullAccess; - if (rule && "read" in rule) { - if (score === 1 && rule?.read) return Permission.ReadOnly; - } - - return Permission.Custom; - } - } - }, [rule, isCustom]); - - useEffect(() => { - if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - }, [selectedPermissionCategory]); - - useEffect(() => { - const isRowCustom = selectedPermissionCategory === Permission.Custom; - if (isRowCustom) { - setIsRowExpanded.on(); - } - }, []); - - const handlePermissionChange = (val: Permission) => { - if (val === Permission.Custom) { - setIsRowExpanded.on(); - setIsCustom.on(); - return; - } - setIsCustom.off(); - - switch (val) { - case Permission.NoAccess: - setValue( - `permissions.${formName}`, - { read: false, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - case Permission.FullAccess: - setValue( - `permissions.${formName}`, - { read: true, edit: true, create: true, delete: true }, - { shouldDirty: true } - ); - break; - case Permission.ReadOnly: - setValue( - `permissions.${formName}`, - { read: true, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - default: - setValue( - `permissions.${formName}`, - { read: false, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - } - }; - - return ( - <> - setIsRowExpanded.toggle()} - > - - - - {title} - - - - - {isRowExpanded && ( - - -
- {getPermissionList(formName).map(({ action, label }) => { - const permissionName = `permissions.${formName}.${action}` as PermissionName; - return ( - ( - { - if (!isEditable) { - createNotification({ - type: "error", - text: "Failed to update default role" - }); - return; - } - field.onChange(e); - }} - id={permissionName} - > - {label} - - )} - /> - ); - })} -
- - - )} - - ); -}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx deleted file mode 100644 index eb0353b73..000000000 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretFoldersRow.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Control, UseFormSetValue, useWatch } from "react-hook-form"; - -import { Select, SelectItem, Td, Tr } from "@app/components/v2"; -import { ProjectPermissionSub } from "@app/context"; -import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -type Props = { - isEditable: boolean; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - SameAsSecrets = "same-as-secrets", - ReadOnly = "read-only" -} - -export const RowPermissionSecretFoldersRow = ({ isEditable, setValue, control }: Props) => { - const formName = ProjectPermissionSub.SecretFolders; - const rule = useWatch({ - control, - name: `permissions.${formName}` - }); - - const selectedPermissionCategory = - rule !== undefined ? Permission.ReadOnly : Permission.SameAsSecrets; - - const handlePermissionChange = (val: Permission) => { - if (!val) return; - switch (val) { - case Permission.SameAsSecrets: { - setValue(`permissions.${formName}`, undefined, { shouldDirty: true }); - break; - } - // Read-only - default: - setValue( - `permissions.${formName}`, - { - read: true, - edit: false, - create: false, - delete: false - }, - { - shouldDirty: true - } - ); - break; - } - }; - - return ( - - - Secret Folders - - - - - ); -}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx deleted file mode 100644 index 75285db85..000000000 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useMemo } from "react"; -import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form"; -import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples"; -import { - Checkbox, - FormControl, - Input, - Select, - SelectItem, - Table, - TableContainer, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -type Props = { - title: string; - formName: "secrets"; - isEditable: boolean; - setValue: UseFormSetValue; - getValue: UseFormGetValues; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -export const RowPermissionSecretsRow = ({ - title, - formName, - isEditable, - setValue, - getValue, - control -}: Props) => { - const { currentWorkspace } = useWorkspace(); - const environments = currentWorkspace?.environments || []; - - const customRule = useWatch({ - control, - name: `permissions.${formName}.custom` - }); - const isCustom = Boolean(customRule); - - const allRule = useWatch({ control, name: `permissions.${formName}.all` }); - - const selectedPermissionCategory = useMemo(() => { - const { read, delete: del, edit, create } = allRule || {}; - if (read && del && edit && create) return Permission.FullAccess; - if (read) return Permission.ReadOnly; - return Permission.NoAccess; - }, [allRule]); - - const handlePermissionChange = (val: Permission) => { - if (!val) return; - switch (val) { - case Permission.NoAccess: { - const permissions = getValue("permissions"); - if (permissions) delete permissions[formName]; - setValue("permissions", permissions, { shouldDirty: true }); - break; - } - case Permission.FullAccess: - setValue( - `permissions.${formName}`, - { all: { read: true, edit: true, create: true, delete: true } }, - { shouldDirty: true } - ); - break; - case Permission.ReadOnly: - setValue( - `permissions.${formName}`, - { all: { read: true, edit: false, create: false, delete: false } }, - { shouldDirty: true } - ); - break; - default: - setValue( - `permissions.${formName}`, - { custom: { read: false, edit: false, create: false, delete: false } }, - { shouldDirty: true } - ); - break; - } - }; - - return ( - <> - - {isCustom && } - {title} - - - - - {isCustom && ( - - -
- - - - - - - - - - - - - {isCustom && - environments.map(({ name, slug }) => ( - - - - - - - - - ))} - -
- -
- Secret Path - - - -
-
ViewCreateModifyDelete
{name} - ( - /* eslint-disable-next-line no-template-curly-in-string */ - - - - )} - /> - - ( -
- -
- )} - /> -
- ( -
- -
- )} - /> -
- ( -
- -
- )} - /> -
- ( -
- -
- )} - /> -
-
-
- - - )} - - ); -}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 814994f90..8f2cce4a1 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -1,127 +1,52 @@ -import { useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence } from "framer-motion"; import { createNotification } from "@app/components/notifications"; -import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { Button, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; + +import { GeneralPermissionOptions } from "./components/GeneralPermissionOptions"; +import { NewPermissionRule } from "./components/NewPermissionRule"; +import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; import { formRolePermission2API, formSchema, + PROJECT_PERMISSION_OBJECT, rolePermission2Form, TFormSchema -} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils"; - -import { RolePermissionRow } from "./RolePermissionRow"; -import { RowPermissionSecretFoldersRow } from "./RolePermissionSecretFoldersRow"; -import { RowPermissionSecretsRow } from "./RolePermissionSecretsRow"; - -const SINGLE_PERMISSION_LIST = [ - { - title: "Project", - formName: "workspace" - }, - { - title: "Integrations", - formName: "integrations" - }, - { - title: "Secret Protect policy", - formName: ProjectPermissionSub.SecretApproval - }, - { - title: "Roles", - formName: "role" - }, - { - title: "User Management", - formName: "member" - }, - { - title: "Group Management", - formName: "groups" - }, - { - title: "Machine Identity Management", - formName: "identity" - }, - { - title: "Webhooks", - formName: "webhooks" - }, - { - title: "Service Tokens", - formName: "service-tokens" - }, - { - title: "Settings", - formName: "settings" - }, - { - title: "Environments", - formName: "environments" - }, - { - title: "Tags", - formName: "tags" - }, - { - title: "IP Allowlist", - formName: "ip-allowlist" - }, - { - title: "Certificate Authorities", - formName: "certificate-authorities" - }, - { - title: "Certificates", - formName: "certificates" - }, - { - title: "Certificate Templates", - formName: "certificate-templates" - }, - { - title: "PKI Collections", - formName: "pki-collections" - }, - { - title: "PKI Alerts", - formName: "pki-alerts" - }, - { - title: "Secret Rollback", - formName: "secret-rollback" - } -] as const; +} from "./ProjectRoleModifySection.utils"; type Props = { roleSlug: string; + isDisabled?: boolean; }; -export const RolePermissionsSection = ({ roleSlug }: Props) => { +export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const projectSlug = currentWorkspace?.slug || ""; const { data: role } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + const form = useForm({ + values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined, + resolver: zodResolver(formSchema) + }); const { - setValue, - getValues, - control, handleSubmit, formState: { isDirty, isSubmitting }, reset - } = useForm({ - defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, - resolver: zodResolver(formSchema) - }); + } = form; const { mutateAsync: updateRole } = useUpdateProjectRole(); const onSubmit = async (el: TFormSchema) => { try { if (!projectSlug || !role?.id) return; - await updateRole({ id: role?.id as string, projectSlug, @@ -143,70 +68,63 @@ export const RolePermissionsSection = ({ roleSlug }: Props) => { onSubmit={handleSubmit(onSubmit)} className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4" > -
-

Permissions

- {isCustomRole && ( -
- - + +
+

Policies

+
+ {isCustomRole && ( + <> + + + + )}
- )} -
-
- - - - - - - - - - - - {SINGLE_PERMISSION_LIST.map((permission) => { - return ( - - ); - })} - -
- ResourcePermission
-
-
+
+ +
+ {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {subject === ProjectPermissionSub.Secrets ? ( + + ) : undefined} + + ))} +
+
+ handlePopUpToggle("createPolicy", isOpen)} + > + + + + + handlePopUpToggle("createPolicy")} /> + + + ); }; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx new file mode 100644 index 000000000..e55180948 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx @@ -0,0 +1,130 @@ +import { cloneElement } from "react"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; + +import { Button, Checkbox, Tag } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema, TProjectPermissionObject } from "../ProjectRoleModifySection.utils"; + +type Props = { + title: string; + subject: T; + actions: TProjectPermissionObject[T]["actions"]; + children?: JSX.Element; +}; + +export const GeneralPermissionOptions = >({ + subject, + actions, + children, + title +}: Props) => { + const { control } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.${subject}` + }); + const [isOpen, setIsOpen] = useToggle(); + + if (!items.fields.length) return
; + + return ( +
+
setIsOpen.toggle()} + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsOpen.toggle(); + } + }} + > +
+ +
+
{title}
+ {items.fields.length > 1 && ( +
+ + {items.fields.length} rules + +
+ )} +
+ {isOpen && ( + + {items.fields.map((el, rootIndex) => ( +
+
+
Actions
+
+ {actions.map(({ label, value }) => { + if (typeof value !== "string") return undefined; + return ( + ( +
+ + {label} + +
+ )} + /> + ); + })} +
+
+ {children && + cloneElement(children, { + position: rootIndex + })} +
+ {subject === ProjectPermissionSub.Secrets && ( + + )} + +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx new file mode 100644 index 000000000..390e4d272 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule.tsx @@ -0,0 +1,117 @@ +import { Controller, useForm, useFormContext } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + Checkbox, + FormControl, + FormLabel, + ModalClose, + Select, + SelectItem +} from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; + +import { + formSchema, + PROJECT_PERMISSION_OBJECT, + TFormSchema +} from "../ProjectRoleModifySection.utils"; + +type Props = { + onClose: () => void; +}; + +export const NewPermissionRule = ({ onClose }: Props) => { + const rootForm = useFormContext(); + + const form = useForm<{ + type: ProjectPermissionSub; + permissions: NonNullable; + }>({ + resolver: zodResolver( + formSchema.pick({ permissions: true }).extend({ type: z.nativeEnum(ProjectPermissionSub) }) + ), + defaultValues: { + type: ProjectPermissionSub.Secrets + } + }); + + const selectedSubject = form.watch("type"); + + return ( +
+ ( + + + + )} + /> + +
+ {PROJECT_PERMISSION_OBJECT?.[selectedSubject]?.actions?.map(({ label, value }) => ( + ( +
+ + {label} + +
+ )} + /> + ))} +
+
+ + + + +
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx new file mode 100644 index 000000000..3e7574eb0 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx @@ -0,0 +1,127 @@ +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; + +import { TFormSchema } from "../ProjectRoleModifySection.utils"; + +type Props = { + position?: number; +}; + +export const SecretPermissionConditions = ({ position = 0 }: Props) => { + const { control } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.secrets.${position}.conditions` + }); + + return ( +
+
+ {items.fields.map((el, index) => ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ))} +
+ +
+ ); +};