diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 6691032a8..ae7304907 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -52,6 +52,36 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:organizationId/roles/:roleId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + organizationId: z.string().trim(), + roleId: z.string().trim() + }), + response: { + 200: z.object({ + role: OrgRolesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const role = await server.services.orgRole.getRole( + req.permission.id, + req.params.organizationId, + req.params.roleId, + req.permission.authMethod, + req.permission.orgId + ); + return { role }; + } + }); + server.route({ method: "PATCH", url: "/:organizationId/roles/:roleId", @@ -69,7 +99,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .trim() .optional() .refine( - (val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val), + (val) => typeof val !== "undefined" && !Object.keys(OrgMembershipRole).includes(val), "Please choose a different slug, the slug you have entered is reserved." ) .refine((val) => typeof val === "undefined" || slugify(val) === val, { diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index 70c54ff18..26cfd67eb 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -42,6 +42,61 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol return role; }; + const getRole = async ( + userId: string, + orgId: string, + roleId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + + switch (roleId) { + case "b11b49a9-09a9-4443-916a-4246f9ff2c69": { + return { + id: roleId, + orgId, + name: "Admin", + slug: "admin", + description: "Complete administration access over the organization", + permissions: packRules(orgAdminPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }; + } + case "b11b49a9-09a9-4443-916a-4246f9ff2c70": { + return { + id: roleId, + orgId, + name: "Member", + slug: "member", + description: "Non-administrative role in an organization", + permissions: packRules(orgMemberPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }; + } + case "b10d49a9-09a9-4443-916a-4246f9ff2c72": { + return { + id: "b10d49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + orgId, + name: "No Access", + slug: "no-access", + description: "No access to any resources in the organization", + permissions: packRules(orgNoAccessPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }; + } + default: { + const role = await orgRoleDAL.findOne({ id: roleId, orgId }); + if (!role) throw new BadRequestError({ message: "Role not found", name: "Get role" }); + return role; + } + } + }; + const updateRole = async ( userId: string, orgId: string, @@ -144,5 +199,5 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol return { permissions: packRules(permission.rules), membership }; }; - return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; + return { createRole, getRole, updateRole, deleteRole, listRoles, getUserPermission }; }; diff --git a/frontend/src/hooks/api/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx index 53c05a7b6..fd8e4e705 100644 --- a/frontend/src/hooks/api/roles/index.tsx +++ b/frontend/src/hooks/api/roles/index.tsx @@ -7,6 +7,7 @@ export { useUpdateProjectRole } from "./mutation"; export { + useGetOrgRole, useGetOrgRoles, useGetProjectRoleBySlug, useGetProjectRoles, diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index ae3e170de..ef330e053 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -9,6 +9,7 @@ import { TCreateProjectRoleDTO, TDeleteOrgRoleDTO, TDeleteProjectRoleDTO, + TOrgRole, TUpdateOrgRoleDTO, TUpdateProjectRoleDTO } from "./types"; @@ -52,12 +53,17 @@ export const useDeleteProjectRole = () => { export const useCreateOrgRole = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ orgId, permissions, ...dto }: TCreateOrgRoleDTO) => - apiRequest.post(`/api/v1/organization/${orgId}/roles`, { + return useMutation({ + mutationFn: async ({ orgId, permissions, ...dto }: TCreateOrgRoleDTO) => { + const { + data: { role } + } = await apiRequest.post(`/api/v1/organization/${orgId}/roles`, { ...dto, permissions: permissions.length ? packRules(permissions) : [] - }), + }); + + return role; + }, onSuccess: (_, { orgId }) => { queryClient.invalidateQueries(roleQueryKeys.getOrgRoles(orgId)); } @@ -67,14 +73,20 @@ export const useCreateOrgRole = () => { export const useUpdateOrgRole = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, orgId, permissions, ...dto }: TUpdateOrgRoleDTO) => - apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, { + return useMutation({ + mutationFn: async ({ id, orgId, permissions, ...dto }: TUpdateOrgRoleDTO) => { + const { + data: { role } + } = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, { ...dto, permissions: permissions?.length ? packRules(permissions) : [] - }), - onSuccess: (_, { orgId }) => { + }); + + return role; + }, + onSuccess: (_, { id, orgId }) => { queryClient.invalidateQueries(roleQueryKeys.getOrgRoles(orgId)); + queryClient.invalidateQueries(roleQueryKeys.getOrgRole(orgId, id)); } }); }; @@ -82,13 +94,19 @@ export const useUpdateOrgRole = () => { export const useDeleteOrgRole = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ orgId, id }: TDeleteOrgRoleDTO) => - apiRequest.delete(`/api/v1/organization/${orgId}/roles/${id}`, { + return useMutation({ + mutationFn: async ({ orgId, id }: TDeleteOrgRoleDTO) => { + const { + data: { role } + } = await apiRequest.delete(`/api/v1/organization/${orgId}/roles/${id}`, { data: { orgId } - }), - onSuccess: (_, { orgId }) => { + }); + + return role; + }, + onSuccess: (_, { id, orgId }) => { queryClient.invalidateQueries(roleQueryKeys.getOrgRoles(orgId)); + queryClient.invalidateQueries(roleQueryKeys.getOrgRole(orgId, id)); } }); }; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index f04af697d..865d28789 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -40,6 +40,7 @@ export const roleQueryKeys = { getProjectRoleBySlug: (projectSlug: string, roleSlug: string) => ["roles", { projectSlug, roleSlug }] as const, getOrgRoles: (orgId: string) => ["org-roles", { orgId }] as const, + getOrgRole: (orgId: string, roleId: string) => [{ orgId, roleId }, "org-role"] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => ["user-permissions", { orgId }] as const, getUserProjectPermissions: ({ workspaceId }: TGetUserProjectPermissionDTO) => @@ -89,6 +90,21 @@ export const useGetOrgRoles = (orgId: string, enable = true) => enabled: Boolean(orgId) && enable }); +export const useGetOrgRole = (orgId: string, roleId: string) => + useQuery({ + queryKey: roleQueryKeys.getOrgRole(orgId, roleId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + role: Omit & { permissions: unknown }; + }>(`/api/v1/organization/${orgId}/roles/${roleId}`); + return { + ...data.role, + permissions: unpackRules(data.role.permissions as PackRule[]) + }; + }, + enabled: Boolean(orgId && roleId) + }); + const getUserOrgPermissions = async ({ orgId }: TGetUserOrgPermissionsDTO) => { if (orgId === "") return { permissions: [], membership: null }; diff --git a/frontend/src/pages/org/[id]/roles/[roleId]/index.tsx b/frontend/src/pages/org/[id]/roles/[roleId]/index.tsx new file mode 100644 index 000000000..082f2d885 --- /dev/null +++ b/frontend/src/pages/org/[id]/roles/[roleId]/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/Org/RolePage"; + +export default function Role() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +Role.requireAuth = true; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx index f08196df6..d7134dfee 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -68,7 +68,7 @@ export const IdentitySection = withPermission( }; return ( -
+

Identities

diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index 32bbe4786..9c8bb4da2 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -106,8 +106,6 @@ const SIMPLE_PERMISSION_OPTIONS = [ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { const isNonEditable = ["owner", "admin", "member", "no-access"].includes(role?.slug || ""); const isNewRole = !role?.slug; - - const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx index 2d4dcd358..ef83bca9c 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx @@ -7,7 +7,7 @@ import { OrgRoleModifySection } from "./OrgRoleModifySection"; import { OrgRoleTable } from "./OrgRoleTable"; export const OrgRoleTabSection = () => { - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); + const { popUp, handlePopUpClose } = usePopUp(["editRole"] as const); return popUp.editRole.isOpen ? ( { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} > - handlePopUpOpen("editRole", role)} /> + ); }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx index b4e08f51a..0fce9f06b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.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 { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, - IconButton, - Input, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Table, TableContainer, TableSkeleton, @@ -22,17 +25,17 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a import { usePopUp } from "@app/hooks"; import { useDeleteOrgRole, useGetOrgRoles } from "@app/hooks/api"; import { TOrgRole } from "@app/hooks/api/roles/types"; +import { RoleModal } from "@app/views/Org/RolePage/components"; -type Props = { - onSelectRole: (role?: TOrgRole) => void; -}; - -export const OrgRoleTable = ({ onSelectRole }: Props) => { - const [searchRoles, setSearchRoles] = useState(""); +export const OrgRoleTable = () => { + const router = useRouter(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteRole" + ] as const); const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId); @@ -54,100 +57,113 @@ export const OrgRoleTable = ({ onSelectRole }: Props) => { }; return ( -
-
-
- setSearchRoles(e.target.value)} - leftIcon={} - placeholder="Search roles..." - /> -
+
+
+

Organization Roles

{(isAllowed) => ( )}
-
- - - - - - - - - - {isRolesLoading && } - {roles?.map((role) => { - const { id, name, slug } = role; - const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug); - - return ( - - - - + + ); + })} + +
NameSlug -
{name}{slug} -
+ + + + + + + + + + {isRolesLoading && } + {roles?.map((role) => { + const { id, name, slug } = role; + const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug); + return ( + router.push(`/org/${orgId}/roles/${id}`)} + > + + + - - ); - })} - -
NameSlug +
{name}{slug} + + +
+ +
+
+ {(isAllowed) => ( - onSelectRole(role)} - variant="plain" + { + e.stopPropagation(); + router.push(`/org/${orgId}/roles/${id}`); + }} + 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 + + )} + + )} + + +
+
+ handlePopUpToggle("deleteRole", isOpen)} deleteKey={(popUp?.deleteRole?.data as TOrgRole)?.slug || ""} onClose={() => handlePopUpClose("deleteRole")} onDeleteApproved={handleRoleDelete} diff --git a/frontend/src/views/Org/RolePage/RolePage.tsx b/frontend/src/views/Org/RolePage/RolePage.tsx new file mode 100644 index 000000000..c8e78a54e --- /dev/null +++ b/frontend/src/views/Org/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 { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteOrgRole, useGetOrgRole } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components"; + +export const RolePage = withPermission( + () => { + const router = useRouter(); + const roleId = router.query.roleId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { data } = useGetOrgRole(orgId, roleId); + const { mutateAsync: deleteOrgRole } = useDeleteOrgRole(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteOrgRole" + ] as const); + + const onDeleteOrgRoleSubmit = async () => { + try { + if (!orgId || !roleId) return; + + await deleteOrgRole({ + orgId, + id: roleId + }); + + createNotification({ + text: "Successfully deleted organization role", + type: "success" + }); + + handlePopUpClose("deleteOrgRole"); + router.push(`/org/${orgId}/members`); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to delete organization role"; + + createNotification({ + text, + type: "error" + }); + } + }; + + const isCustomRole = !["admin", "member", "no-access"].includes(data?.slug ?? ""); + + return ( +
+ {data && ( +
+ +
+

{data.name}

+ {isCustomRole && ( + + +
+ + + +
+
+ + + {(isAllowed) => ( + { + handlePopUpOpen("role", { + roleId + }); + }} + disabled={!isAllowed} + > + Edit Role + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteOrgRole"); + }} + disabled={!isAllowed} + > + Delete Role + + )} + + +
+ )} +
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteOrgRole", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onDeleteOrgRoleSubmit()} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Role } +); diff --git a/frontend/src/views/Org/RolePage/components/RoleDetailsSection.tsx b/frontend/src/views/Org/RolePage/components/RoleDetailsSection.tsx new file mode 100644 index 000000000..6497f5ba6 --- /dev/null +++ b/frontend/src/views/Org/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 { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetOrgRole } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + roleId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: {}) => void; +}; + +export const RoleDetailsSection = ({ roleId, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { data } = useGetOrgRole(orgId, roleId); + const isCustomRole = !["admin", "member", "no-access"].includes(data?.slug ?? ""); + + return data ? ( +
+
+

Details

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

Role ID

+
+

{roleId}

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

Name

+

{data.name}

+
+
+

Slug

+

{data.slug}

+
+
+

Description

+

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

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx new file mode 100644 index 000000000..ab909d931 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -0,0 +1,200 @@ +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 { useOrganization } from "@app/context"; +import { useCreateOrgRole, useGetOrgRole, useUpdateOrgRole } 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 { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const popupData = popUp?.role?.data as { + roleId: string; + }; + + const { data: role } = useGetOrgRole(orgId, popupData?.roleId ?? ""); + + const { mutateAsync: createOrgRole } = useCreateOrgRole(); + const { mutateAsync: updateOrgRole } = useUpdateOrgRole(); + + 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 { + console.log("onFormSubmit args: ", { + name, + description, + slug + }); + + if (!orgId) return; + + if (role) { + // update + + await updateOrgRole({ + orgId, + id: role.id, + name, + description, + slug + }); + + handlePopUpToggle("role", false); + } else { + // create + + const newRole = await createOrgRole({ + orgId, + name, + description, + slug, + permissions: [] + }); + + handlePopUpToggle("role", false); + router.push(`/org/${orgId}/roles/${newRole.id}`); + } + + 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/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx new file mode 100644 index 000000000..ca4902eb3 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -0,0 +1,215 @@ +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/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils"; + +const PERMISSIONS = [ + { action: "read", label: "View" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Modify" }, + { action: "delete", label: "Remove" } +] as const; + +const SECRET_SCANNING_PERMISSIONS = [ + { action: "read", label: "View risks" }, + { action: "create", label: "Add integrations" }, + { action: "edit", label: "Edit risk status" }, + { action: "delete", label: "Remove integrations" } +] as const; + +const INCIDENT_CONTACTS_PERMISSIONS = [ + { action: "read", label: "View contacts" }, + { action: "create", label: "Add new contacts" }, + { action: "edit", label: "Edit contacts" }, + { action: "delete", label: "Remove contacts" } +] 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 BILLING_PERMISSIONS = [ + { action: "read", label: "View bills" }, + { action: "create", label: "Add payment methods" }, + { action: "edit", label: "Edit payments" }, + { action: "delete", label: "Remove payments" } +] as const; + +const getPermissionList = (option: string) => { + switch (option) { + case "secret-scanning": + return SECRET_SCANNING_PERMISSIONS; + case "billing": + return BILLING_PERMISSIONS; + case "incident-contact": + return INCIDENT_CONTACTS_PERMISSIONS; + case "member": + return MEMBERS_PERMISSIONS; + default: + return PERMISSIONS; + } +}; + +type Props = { + isEditable: boolean; + title: string; + formName: keyof Omit, "workspace">; + 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; + const totalActions = 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 (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 }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.${formName}.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx new file mode 100644 index 000000000..02422af41 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -0,0 +1,162 @@ +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 { useOrganization } from "@app/context"; +import { useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api"; +import { + formRolePermission2API, + formSchema, + rolePermission2Form, + TFormSchema +} from "@app/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils"; + +import { RolePermissionRow } from "./RolePermissionRow"; + +const SIMPLE_PERMISSION_OPTIONS = [ + { + title: "User management", + formName: "member" + }, + { + title: "Group management", + formName: "groups" + }, + { + title: "Machine identity management", + formName: "identity" + }, + { + title: "Billing & usage", + formName: "billing" + }, + { + title: "Role management", + formName: "role" + }, + { + title: "Incident Contacts", + formName: "incident-contact" + }, + { + title: "Organization profile", + formName: "settings" + }, + { + title: "Secret Scanning", + formName: "secret-scanning" + }, + { + title: "SSO", + formName: "sso" + }, + { + title: "LDAP", + formName: "ldap" + }, + { + title: "SCIM", + formName: "scim" + } +] as const; + +type Props = { + roleId: string; +}; + +export const RolePermissionsSection = ({ roleId }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: role } = useGetOrgRole(orgId, roleId); + + const { + setValue, + control, + handleSubmit, + formState: { isDirty, isSubmitting }, + reset + } = useForm({ + defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + resolver: zodResolver(formSchema) + }); + + const { mutateAsync: updateRole } = useUpdateOrgRole(); + + const onSubmit = async (el: TFormSchema) => { + try { + await updateRole({ + orgId, + id: roleId, + ...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", "no-access"].includes(role?.slug ?? ""); + + return ( +
+
+

Permissions

+ {isCustomRole && ( +
+ + +
+ )} +
+
+ + + + + + + + + + {SIMPLE_PERMISSION_OPTIONS.map((permission) => { + return ( + + ); + })} + +
+ ResourcePermission
+
+
+
+ ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/index.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/index.tsx new file mode 100644 index 000000000..104e2144e --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/index.tsx @@ -0,0 +1 @@ +export { RolePermissionsSection } from "./RolePermissionsSection"; diff --git a/frontend/src/views/Org/RolePage/components/index.tsx b/frontend/src/views/Org/RolePage/components/index.tsx new file mode 100644 index 000000000..3f57cd670 --- /dev/null +++ b/frontend/src/views/Org/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/Org/RolePage/index.tsx b/frontend/src/views/Org/RolePage/index.tsx new file mode 100644 index 000000000..71e7114fc --- /dev/null +++ b/frontend/src/views/Org/RolePage/index.tsx @@ -0,0 +1 @@ +export { RolePage } from "./RolePage"; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index ede481821..8cf1723cc 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -161,7 +161,6 @@ export const SecretOverviewTableRow = ({ secretPath={secretPath} getSecretByKey={getSecretByKey} /> -