From 5ef81cd9357d26bea8820cde160e3f03584c8153 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 21 Oct 2024 14:36:24 +0530 Subject: [PATCH] feat: added role modify popup --- frontend/src/helpers/roles.ts | 12 + frontend/src/hooks/api/workspace/queries.tsx | 5 +- .../MemberDetailsPage/MemberDetailPage.tsx | 2 +- .../MemberRoleDetailsSection.tsx | 147 ++++++-- .../MemberRoleModify.tsx | 346 ++++++++++++++++++ 5 files changed, 472 insertions(+), 40 deletions(-) diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index afb9e1274..e1c3556cc 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -4,5 +4,17 @@ enum OrgMembershipRole { NoAccess = "no-access" } +enum ProjectMemberRole { + Admin = "admin", + Member = "member", + Viewer = "viewer", + NoAccess = "no-access" +} + export const isCustomOrgRole = (slug: string) => !Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole); + +export const formatProjectRoleName = (name: string) => { + if (name === ProjectMemberRole.Member) return "developer"; + return name; +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index af6524fe0..391b2ab6a 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -420,8 +420,11 @@ export const useUpdateUserWorkspaceRole = () => { ); return membership; }, - onSuccess: (_, { workspaceId }) => { + onSuccess: (_, { workspaceId, membershipId }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + queryClient.invalidateQueries( + workspaceKeys.getWorkspaceUserDetails(workspaceId, membershipId) + ); } }); }; diff --git a/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx b/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx index 509b34e0f..258988d71 100644 --- a/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx +++ b/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx @@ -40,7 +40,7 @@ export const MemberDetailsPage = withProjectPermission(

Project User Management

diff --git a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx index d4f488dcf..3f498aa8b 100644 --- a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx +++ b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx @@ -1,4 +1,5 @@ import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { DeleteActionModal, IconButton, @@ -11,20 +12,27 @@ import { TableSkeleton, EmptyState, TBody, - Tooltip + Tooltip, + Modal, + ModalContent, + Tag } from "@app/components/v2"; -import { useUser, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; import { - useDeleteProjectRole, - useGetWorkspaceUserDetails, - useUpdateUserWorkspaceRole -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useUser, + useWorkspace +} from "@app/context"; +import { formatProjectRoleName } from "@app/helpers/roles"; +import { usePopUp } from "@app/hooks"; +import { useUpdateUserWorkspaceRole } from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; import { TWorkspaceUser } from "@app/hooks/api/types"; -import { faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useRouter } from "next/router"; +import { format, formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; +import { MemberRoleModify } from "./MemberRoleModify"; type Props = { membershipDetails: TWorkspaceUser; @@ -35,11 +43,11 @@ export const MemberRoleDetailsSection = ({ membershipDetails, isMembershipDetailsLoading }: Props) => { - const router = useRouter(); const { user } = useUser(); const { currentWorkspace } = useWorkspace(); const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "deleteRole" + "deleteRole", + "modifyRole" ] as const); const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole(); @@ -67,14 +75,26 @@ export const MemberRoleDetailsSection = ({

Project Roles

{userId !== membershipDetails?.user?.id && membershipDetails?.status !== "invited" && ( - {}} + - - + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed} + > + + + )} + )}
@@ -93,33 +113,76 @@ export const MemberRoleDetailsSection = ({ )} {!isMembershipDetailsLoading && membershipDetails?.roles?.map((roleDetails) => { + const isTemporary = roleDetails?.isTemporary; + const isExpired = + roleDetails.isTemporary && + new Date() > new Date(roleDetails.temporaryAccessEndTime || ""); + + let text = "Permanent"; + let toolTipText = "Non expiry access"; + if (roleDetails.isTemporary) { + if (isExpired) { + text = "Access Expired"; + toolTipText = "Timed Access Expired"; + } else { + text = formatDistance( + new Date(roleDetails.temporaryAccessEndTime || ""), + new Date() + ); + toolTipText = `Until ${format( + new Date(roleDetails.temporaryAccessEndTime || ""), + "yyyy-MM-dd hh:mm:ss aaa" + )}`; + } + } + return ( - + {roleDetails.role === "custom" ? roleDetails.customRoleName - : roleDetails.role} + : formatProjectRoleName(roleDetails.role)} + + + + + {text} + + - {!roleDetails.isTemporary ? "permanent" : "temporary"}
- - { - e.stopPropagation(); - handlePopUpOpen("deleteRole", { - id: roleDetails?.id, - slug: roleDetails?.customRoleSlug || roleDetails?.role - }); - }} - > - - - + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", { + id: roleDetails?.id, + slug: roleDetails?.customRoleName || roleDetails?.role + }); + }} + > + + + )} +
@@ -139,6 +202,14 @@ export const MemberRoleDetailsSection = ({ onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)} onDeleteApproved={() => handleRoleDelete()} /> + handlePopUpToggle("modifyRole", isOpen)} + > + + {}} /> + +
); }; diff --git a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx index e69de29bb..973e2ac13 100644 --- a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx +++ b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx @@ -0,0 +1,346 @@ +/* eslint-disable no-nested-ternary */ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format, formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; + +const roleFormSchema = z.object({ + roles: z + .object({ + slug: z.string(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) + }) + .array() +}); +type TRoleForm = z.infer; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; + +export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isLoading) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + membershipId: projectMember.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + +
+ + + +
+
+ +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+ ); +};