diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 69038a057..3ada2e50c 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -101,7 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { message: "Slug must be a valid" }), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), - permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions) + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() }), response: { 200: z.object({ @@ -120,7 +120,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: req.params.roleId, data: { ...req.body, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined } }); return { role }; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index ffd446fad..8c71a6e02 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -162,12 +162,19 @@ export const projectRoleServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); + if (data?.slug) { const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId }); if (existingRole && existingRole.id !== roleId) throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); } - const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data); + const [updatedRole] = await projectRoleDAL.update( + { id: roleId, projectId }, + { + ...data, + permissions: data.permissions ? data.permissions : undefined + } + ); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; }; diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 7f831cd4c..3f782171e 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -10,6 +10,7 @@ import { TDeleteOrgRoleDTO, TDeleteProjectRoleDTO, TOrgRole, + TProjectRole, TUpdateOrgRoleDTO, TUpdateProjectRoleDTO } from "./types"; @@ -17,9 +18,13 @@ import { export const useCreateProjectRole = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => - apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto), + return useMutation({ + mutationFn: async ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => { + const { + data: { role } + } = await apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto); + return role; + }, onSuccess: (_, { projectSlug }) => { queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } @@ -29,9 +34,13 @@ export const useCreateProjectRole = () => { export const useUpdateProjectRole = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => - apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto), + return useMutation({ + mutationFn: async ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => { + const { + data: { role } + } = await apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto); + return role; + }, onSuccess: (_, { projectSlug }) => { queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } @@ -40,10 +49,13 @@ export const useUpdateProjectRole = () => { export const useDeleteProjectRole = () => { const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) => - apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`), + return useMutation({ + mutationFn: async ({ projectSlug, id }: TDeleteProjectRoleDTO) => { + const { + data: { role } + } = await apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`); + return role; + }, onSuccess: (_, { projectSlug }) => { queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } diff --git a/frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx b/frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx new file mode 100644 index 000000000..17c854cee --- /dev/null +++ b/frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { RolePage } from "@app/views/Project/RolePage"; + +export default function Role() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "Project Settings" })} + + + + + ); +} + +Role.requireAuth = true; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 5eb443fb6..4a5fc37ac 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -2,29 +2,12 @@ import { motion } from "framer-motion"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { usePopUp } from "@app/hooks"; import { ProjectRoleList } from "./components/ProjectRoleList"; -import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection"; export const ProjectRoleListTab = withProjectPermission( () => { - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); - - return popUp.editRole.isOpen ? ( - - handlePopUpClose("editRole")} - /> - - ) : ( + return ( - handlePopUpOpen("editRole", slug)} /> + ); }, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index e19e61aec..59a13c67f 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -1,14 +1,17 @@ -import { useState } from "react"; -import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { useRouter } from "next/router"; +import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, - IconButton, - Input, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Table, TableContainer, TableSkeleton, @@ -22,17 +25,17 @@ import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@a import { usePopUp } from "@app/hooks"; import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; +import { RoleModal } from "@app/views/Project/RolePage/components"; -type Props = { - onSelectRole: (slug?: string) => void; -}; - -export const ProjectRoleList = ({ onSelectRole }: Props) => { - const [searchRoles, setSearchRoles] = useState(""); - - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); +export const ProjectRoleList = () => { + const router = useRouter(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteRole" + ] as const); const { currentWorkspace } = useWorkspace(); const projectSlug = currentWorkspace?.slug || ""; + const projectId = currentWorkspace?.id || ""; const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); @@ -54,21 +57,16 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { }; return ( -
-
-
- setSearchRoles(e.target.value)} - leftIcon={} - placeholder="Search roles..." - /> -
+
+
+

Project Roles

{(isAllowed) => (
-
- - - - - - - - - - {isRolesLoading && } - {roles?.map((role) => { - const { id, name, slug } = role; - const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug); + +
NameSlug -
+ + + + + + + + {isRolesLoading && } + {roles?.map((role) => { + const { id, name, slug } = role; + const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug); - return ( - - - - router.push(`/project/${projectId}/roles/${slug}`)} + > + + + - - ); - })} - -
NameSlug +
{name}{slug} -
+ return ( +
{name}{slug} + + +
+ +
+
+ {(isAllowed) => ( - onSelectRole(role.slug)} - variant="plain" + { + e.stopPropagation(); + router.push(`/project/${projectId}/roles/${slug}`); + }} + disabled={!isAllowed} > - - + {`${isNonMutatable ? "View" : "Edit"} Role`} + )} - - {(isAllowed) => ( - handlePopUpOpen("deleteRole", role)} - variant="plain" - isDisabled={isNonMutatable || !isAllowed} - > - - - )} - - -
-
-
+ {!isNonMutatable && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", role); + }} + disabled={!isAllowed} + > + Delete Role + + )} + + )} + + + + + ); + })} + + + + handlePopUpClose("deleteRole")} onDeleteApproved={handleRoleDelete} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index 2140887de..c386ebc11 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -14,7 +14,8 @@ import { faShield, faTags, faUser, - faUsers} from "@fortawesome/free-solid-svg-icons"; + faUsers +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; diff --git a/frontend/src/views/Project/RolePage/RolePage.tsx b/frontend/src/views/Project/RolePage/RolePage.tsx new file mode 100644 index 000000000..7e19b49f4 --- /dev/null +++ b/frontend/src/views/Project/RolePage/RolePage.tsx @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteProjectRole,useGetProjectRoleBySlug } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components"; + +export const RolePage = withProjectPermission( + () => { + const router = useRouter(); + const roleSlug = router.query.roleSlug as string; + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + + const { mutateAsync: deleteProjectRole } = useDeleteProjectRole(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteRole" + ] as const); + + const onDeleteRoleSubmit = async () => { + try { + if (!currentWorkspace?.slug || !data?.id) return; + + await deleteProjectRole({ + projectSlug: currentWorkspace.slug, + id: data.id + }); + + createNotification({ + text: "Successfully deleted project role", + type: "success" + }); + handlePopUpClose("deleteRole"); + router.push(`/project/${projectId}/members`); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to delete project role"; + + createNotification({ + text, + type: "error" + }); + } + }; + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return ( +
+ {data && ( +
+ +
+

{data.name}

+ {isCustomRole && ( + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("role", { + roleSlug + }) + } + disabled={!isAllowed} + > + Edit Role + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteRole")} + disabled={!isAllowed} + > + Delete Role + + )} + + +
+ )} +
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteRole", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onDeleteRoleSubmit()} + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } +); diff --git a/frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx b/frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx new file mode 100644 index 000000000..2b76a2fcd --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx @@ -0,0 +1,95 @@ +import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetProjectRoleBySlug } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + roleSlug: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: {}) => void; +}; + +export const RoleDetailsSection = ({ roleSlug, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { currentWorkspace } = useWorkspace(); + const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return data ? ( +
+
+

Details

+ {isCustomRole && ( + + {(isAllowed) => { + return ( + + { + handlePopUpOpen("role", { + roleSlug + }); + }} + > + + + + ); + }} + + )} +
+
+
+

Role ID

+
+

{data.id}

+
+ + { + navigator.clipboard.writeText(data.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Name

+

{data.name}

+
+
+

Slug

+

{data.slug}

+
+
+

Description

+

+ {data.description?.length ? data.description : "-"} +

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RoleModal.tsx b/frontend/src/views/Project/RolePage/components/RoleModal.tsx new file mode 100644 index 000000000..435378a02 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RoleModal.tsx @@ -0,0 +1,196 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateProjectRole, + useGetProjectRoleBySlug, + useUpdateProjectRole} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z + .object({ + name: z.string(), + description: z.string(), + slug: z.string() + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["role"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void; +}; + +export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { + const router = useRouter(); + + const popupData = popUp?.role?.data as { + roleSlug: string; + }; + + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + + const { data: role } = useGetProjectRoleBySlug(projectSlug, popupData?.roleSlug ?? ""); + + const { mutateAsync: createProjectRole } = useCreateProjectRole(); + const { mutateAsync: updateProjectRole } = useUpdateProjectRole(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + description: "" + } + }); + + useEffect(() => { + if (role) { + reset({ + name: role.name, + description: role.description, + slug: role.slug + }); + } else { + reset({ + name: "", + description: "", + slug: "" + }); + } + }, [role]); + + const onFormSubmit = async ({ name, description, slug }: FormData) => { + try { + if (!projectSlug) return; + + if (role) { + // update + await updateProjectRole({ + id: role.id, + projectSlug, + name, + description, + slug + }); + + handlePopUpToggle("role", false); + } else { + // create + const newRole = await createProjectRole({ + projectSlug, + name, + description, + slug, + permissions: [] + }); + + router.push(`/project/${currentWorkspace?.id}/roles/${newRole.slug}`); + handlePopUpToggle("role", false); + } + + createNotification({ + text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = + error?.response?.data?.message ?? + `Failed to ${popUp?.role?.data ? "update" : "create"} role`; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("role", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx new file mode 100644 index 000000000..a98bf0d93 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -0,0 +1,219 @@ +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/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/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/RolePermissionSecretsRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx new file mode 100644 index 000000000..5833fee13 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx @@ -0,0 +1,247 @@ +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/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/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 new file mode 100644 index 000000000..5010842ba --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -0,0 +1,198 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; +import { + formRolePermission2API, + formSchema, + rolePermission2Form, + TFormSchema +} from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; + +import { RolePermissionRow } from "./RolePermissionRow"; +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: "Audit Logs", + formName: "audit-logs" + }, + { + title: "IP Allowlist", + formName: "ip-allowlist" + }, + { + title: "Certificate Authorities", + formName: "certificate-authorities" + }, + { + title: "Certificates", + formName: "certificates" + }, + { + title: "Secret Rollback", + formName: "secret-rollback" + } +] as const; + +type Props = { + roleSlug: string; +}; + +export const RolePermissionsSection = ({ roleSlug }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + const { data: role } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + + const { + setValue, + getValues, + control, + handleSubmit, + formState: { isDirty, isSubmitting }, + reset + } = useForm({ + defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + resolver: zodResolver(formSchema) + }); + + const { mutateAsync: updateRole } = useUpdateProjectRole(); + + const onSubmit = async (el: TFormSchema) => { + try { + if (!projectSlug || !role?.id) return; + + await updateRole({ + id: role?.id as string, + projectSlug, + ...el, + permissions: formRolePermission2API(el.permissions) + }); + + createNotification({ type: "success", text: "Successfully updated role" }); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update role" }); + } + }; + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? ""); + + return ( +
+
+

Permissions

+ {isCustomRole && ( +
+ + +
+ )} +
+
+ + + + + + + + + + + {SINGLE_PERMISSION_LIST.map((permission) => { + return ( + + ); + })} + +
+ ResourcePermission
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx new file mode 100644 index 000000000..104e2144e --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx @@ -0,0 +1 @@ +export { RolePermissionsSection } from "./RolePermissionsSection"; diff --git a/frontend/src/views/Project/RolePage/components/index.tsx b/frontend/src/views/Project/RolePage/components/index.tsx new file mode 100644 index 000000000..3f57cd670 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/index.tsx @@ -0,0 +1,3 @@ +export { RoleDetailsSection } from "./RoleDetailsSection"; +export { RoleModal } from "./RoleModal"; +export { RolePermissionsSection } from "./RolePermissionsSection"; diff --git a/frontend/src/views/Project/RolePage/index.tsx b/frontend/src/views/Project/RolePage/index.tsx new file mode 100644 index 000000000..71e7114fc --- /dev/null +++ b/frontend/src/views/Project/RolePage/index.tsx @@ -0,0 +1 @@ +export { RolePage } from "./RolePage";