From fa41b8bb476dcd7c58bb32cdc164baad1f865f0b Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 11 Mar 2024 15:09:15 +0530 Subject: [PATCH] feat(ui): completed ui user multi role with temporary access --- .../src/components/v2/Popoverv2/Popoverv2.tsx | 4 +- frontend/src/hooks/api/users/types.ts | 27 +- frontend/src/hooks/api/workspace/queries.tsx | 17 +- frontend/src/hooks/api/workspace/types.ts | 22 + frontend/src/lib/fn/array.ts | 15 + .../MemberListTab/MemberListTab.tsx | 165 ++---- .../components/MemberListTab/MemberRoles.tsx | 473 ++++++++++++++++++ 7 files changed, 595 insertions(+), 128 deletions(-) create mode 100644 frontend/src/lib/fn/array.ts create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx diff --git a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx index 2fea00206..d78ede201 100644 --- a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx +++ b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx @@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root; export type PopoverContentProps = { children?: ReactNode; + arrowClassName?: string; hideCloseBtn?: boolean; } & PopoverPrimitive.PopoverContentProps; @@ -19,6 +20,7 @@ export const PopoverContent = ({ children, className, hideCloseBtn, + arrowClassName, ...props }: PopoverContentProps) => ( @@ -48,7 +50,7 @@ export const PopoverContent = ({ )} - + ); diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 65a603698..540ade9c1 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -64,7 +64,32 @@ export type TProjectMembership = { roleId: string; }; -export type TWorkspaceUser = OrgUser; +export type TWorkspaceUser = { + id: string; + user: { + email: string; + firstName: string; + lastName: string; + id: string; + publicKey: string; + }; + inviteEmail: string; + organization: string; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; + status: "invited" | "accepted" | "verified" | "completed"; + deniedPermissions: any[]; +}; export type AddUserToWsDTOE2EE = { workspaceId: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 2d28be501..bf07b8f18 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -16,6 +16,7 @@ import { RenameWorkspaceDTO, TGetUpgradeProjectStatusDTO, ToggleAutoCapitalizationDTO, + TUpdateWorkspaceUserRoleDTO, UpdateEnvironmentDTO, Workspace } from "./types"; @@ -340,27 +341,19 @@ export const useDeleteUserFromWorkspace = () => { export const useUpdateUserWorkspaceRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - membershipId, - role, - workspaceId - }: { - membershipId: string; - role: string; - workspaceId: string; - }) => { + mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => { const { data: { membership } } = await apiRequest.patch<{ membership: { projectId: string } }>( `/api/v1/workspace/${workspaceId}/memberships/${membershipId}`, { - role + roles } ); return membership; }, - onSuccess: (res) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId)); + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); } }); }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index c4aada862..856d70d0d 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -3,6 +3,10 @@ export enum ProjectVersion { V2 = 2 } +export enum ProjectUserMembershipTemporaryMode { + Relative = "relative" +} + export type Workspace = { __v: number; id: string; @@ -72,3 +76,21 @@ export type UpdateEnvironmentDTO = { }; export type DeleteEnvironmentDTO = { workspaceId: string; id: string }; + +export type TUpdateWorkspaceUserRoleDTO = { + membershipId: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; diff --git a/frontend/src/lib/fn/array.ts b/frontend/src/lib/fn/array.ts new file mode 100644 index 000000000..33409653a --- /dev/null +++ b/frontend/src/lib/fn/array.ts @@ -0,0 +1,15 @@ +/** + * Sorts an array of items into groups. The return value is a map where the keys are + * the group ids the given getGroupId function produced and the value is an array of + * each item in that group. + */ +export const groupBy = ( + array: readonly T[], + getGroupId: (item: T) => Key +): Record => + array.reduce((acc, item) => { + const groupId = getGroupId(item); + if (!acc[groupId]) acc[groupId] = []; + acc[groupId].push(item); + return acc; + }, {} as Record); diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index bae397f67..6f0e31367 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; @@ -34,7 +34,6 @@ import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, - useSubscription, useUser, useWorkspace } from "@app/context"; @@ -44,14 +43,13 @@ import { useAddUserToWsNonE2EE, useDeleteUserFromWorkspace, useGetOrgUsers, - useGetProjectRoles, useGetUserWsKey, - useGetWorkspaceUsers, - useUpdateUserWorkspaceRole + useGetWorkspaceUsers } from "@app/hooks/api"; -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { MemberRoles } from "./MemberRoles"; + const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() }); @@ -60,7 +58,6 @@ type TAddMemberForm = z.infer; export const MemberListTab = () => { const { createNotification } = useNotificationContext(); - const { subscription } = useSubscription(); const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -71,8 +68,6 @@ export const MemberListTab = () => { const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; - const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: orgUsers } = useGetOrgUsers(orgId); @@ -95,7 +90,6 @@ export const MemberListTab = () => { const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE(); const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); - const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole(); const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => { if (!currentWorkspace) return; @@ -167,47 +161,6 @@ export const MemberListTab = () => { handlePopUpClose("removeMember"); }; - const isIamOwner = useMemo( - () => members?.find(({ user: u }) => userId === u?.id)?.role === "owner", - [userId, members] - ); - - const findRoleFromId = useCallback( - (roleId: string) => { - return (roles || []).find(({ id }) => id === roleId); - }, - [roles] - ); - - const onRoleChange = async (membershipId: string, role: string) => { - if (!currentOrg?.id) return; - - try { - const isCustomRole = !Object.values(ProjectMembershipRole).includes( - role as ProjectMembershipRole - ); - - if (isCustomRole && subscription && !subscription?.rbac) { - handlePopUpOpen("upgradePlan", { - description: "You can assign custom roles to members if you upgrade your Infisical plan." - }); - return; - } - - await updateUserWorkspaceRole({ membershipId, role, workspaceId }); - createNotification({ - text: "Successfully updated user role", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update user role", - type: "error" - }); - } - }; - const filterdUsers = useMemo( () => members?.filter( @@ -231,8 +184,6 @@ export const MemberListTab = () => { ); }, [orgUsers, members]); - const isLoading = isMembersLoading || isRolesLoading; - return (
@@ -269,75 +220,61 @@ export const MemberListTab = () => { - {isLoading && } - {!isLoading && - filterdUsers?.map( - ({ user: u, id: membershipId, roleId, role }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const username = u?.username ?? "-"; - return ( - - {name} - {username} - + {isMembersLoading && } + {!isMembersLoading && + filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + + return ( + + {name} + {email} + + + {(isAllowed) => ( + + handlePopUpOpen("upgradePlan", { description }) + } + membershipId={membershipId} + /> + )} + + + + {userId !== u?.id && ( {(isAllowed) => ( - + handlePopUpOpen("removeMember", { email: u.email })} + > + + )} - - - {userId !== u?.id && ( - - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) - } - > - - - )} - - )} - - - ); - } - )} + )} + + + ); + })} - {!isLoading && filterdUsers?.length === 0 && ( + {!isMembersLoading && filterdUsers?.length === 0 && ( )} diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx new file mode 100644 index 000000000..107b97cc2 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx @@ -0,0 +1,473 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useSubscription, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +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"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const TemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Set Role Temporarily +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + membershipId: string; + onOpenUpgradeModal: (description: string) => void; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const MemberRoles = ({ + roles = [], + disableEdit = false, + membershipId, + onOpenUpgradeModal +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + const { subscription } = useSubscription(); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = selectedRoles.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, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + console.log(data); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +};