From f1f18e81cd04ed9daf9bc447300aa95fc1b010d2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 11 Mar 2024 20:24:03 +0530 Subject: [PATCH] feat(ui): added identity multi project role and temporary in ui --- frontend/src/hooks/api/identities/types.ts | 17 +- frontend/src/hooks/api/workspace/queries.tsx | 11 +- frontend/src/hooks/api/workspace/types.ts | 18 + .../IdentitySection/IdentityRoles.tsx | 459 ++++++++++++++++++ .../IdentitySection/IdentityTable.tsx | 68 +-- .../MemberListTab/MemberListTab.tsx | 2 +- 6 files changed, 502 insertions(+), 73 deletions(-) create mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7a8e207e2..fd3e88614 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,4 +1,4 @@ -import { TOrgRole, TProjectRole } from "../roles/types"; +import { TOrgRole } from "../roles/types"; import { IdentityAuthMethod } from "./enums"; export type IdentityTrustedIp = { @@ -29,9 +29,18 @@ export type IdentityMembershipOrg = { export type IdentityMembership = { id: string; identity: Identity; - organization: string; - role: "admin" | "member" | "viewer" | "no-access" | "custom"; - customRole?: TProjectRole; + 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; + }[]; createdAt: string; updatedAt: string; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index bf07b8f18..37e512ac8 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, + TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, UpdateEnvironmentDTO, Workspace @@ -393,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => { mutationFn: async ({ identityId, workspaceId, - role - }: { - identityId: string; - workspaceId: string; - role?: string; - }) => { + roles + }:TUpdateWorkspaceIdentityRoleDTO)=> { const { data: { identityMembership } } = await apiRequest.patch( `/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, { - role + roles } ); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 856d70d0d..828a7a890 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -94,3 +94,21 @@ export type TUpdateWorkspaceUserRoleDTO = { } )[]; }; + +export type TUpdateWorkspaceIdentityRoleDTO = { + identityId: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx new file mode 100644 index 000000000..1f046f586 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx @@ -0,0 +1,459 @@ +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 { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } 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 IdentityTemporaryRoleForm = ({ + 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; + identityId: string; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const IdentityRoles = ({ + roles = [], + disableEdit = false, + identityId +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + + 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 updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole(); + + 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 + }; + }); + + try { + await updateIdentityWorkspaceRole.mutateAsync({ + workspaceId, + identityId, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated identity role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update identity 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.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx index eb7320b89..24197e9fe 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx @@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, - Select, - SelectItem, Table, TableContainer, TableSkeleton, @@ -19,13 +16,11 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { - useGetProjectRoles, - useGetWorkspaceIdentityMemberships, - useUpdateIdentityWorkspaceRole -} from "@app/hooks/api"; +import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { IdentityRoles } from "./IdentityRoles"; + type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, @@ -37,39 +32,9 @@ type Props = { }; export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { createNotification } = useNotificationContext(); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); - const { data: roles } = useGetProjectRoles(workspaceId); - - const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole(); - - const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => { - try { - await updateMutateAsync({ - identityId, - workspaceId, - role - }); - - createNotification({ - text: "Successfully updated identity role", - type: "success" - }); - } catch (err) { - console.error(err); - const error = err as any; - const text = error?.response?.data?.message ?? "Failed to update identity role"; - - createNotification({ - text, - type: "error" - }); - } - }; - return ( @@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { {!isLoading && data && data.length > 0 && - data.map(({ identity: { id, name }, role, customRole, createdAt }) => { + data.map(({ identity: { id, name }, roles, createdAt }) => { return ( @@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity} > - {(isAllowed) => { - return ( - - ); - }} + {(isAllowed) => ( + + )} diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 6f0e31367..d892d42ae 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -238,7 +238,7 @@ export const MemberListTab = () => { {(isAllowed) => ( handlePopUpOpen("upgradePlan", { description }) }
{name}{format(new Date(createdAt), "yyyy-MM-dd")}