From 51716336c2d3910ef83437e28ab3751d2ea06ea5 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 28 Mar 2024 15:30:36 +0530 Subject: [PATCH] feat(ui): updated ui with new role form for users --- .../projectUserAdditionalPrivilege/index.tsx | 2 +- .../mutation.tsx | 14 +- .../queries.tsx | 22 +- .../projectUserAdditionalPrivilege/types.tsx | 39 +- frontend/src/hooks/api/users/types.ts | 50 +- frontend/src/hooks/api/workspace/queries.tsx | 7 - .../AdditionalPrivilegeForm.tsx | 328 ------------ .../AdditionalPrivilegeSection.tsx | 201 ------- .../AdditionalPrivilegeTemporaryAccess.tsx | 219 -------- .../AdditionalPrivilegeSection/index.tsx | 1 - .../components/IdentityTab/IdentityTab.tsx | 139 ++--- .../MemberListTab/MemberListTab.tsx | 289 ++++++---- .../MemberRoleForm/MemberRbacSection.tsx | 338 ++++++++++++ .../MemberRoleForm/MemberRoleForm.tsx | 20 + .../SpecificPrivilegeSection.tsx | 497 ++++++++++++++++++ .../MemberListTab/MemberRoleForm/index.tsx | 1 + .../components/MemberListTab/MemberRoles.tsx | 471 ----------------- 17 files changed, 1145 insertions(+), 1493 deletions(-) delete mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx index 53512c52b..14a2c8980 100644 --- a/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx @@ -3,5 +3,5 @@ export { useDeleteProjectUserAdditionalPrivilege, useUpdateProjectUserAdditionalPrivilege } from "./mutation"; -export { useGetProjectUserPrivilegeDetails } from "./queries"; +export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries"; export type { TProjectUserPrivilege } from "./types"; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx index 135920e0e..6a07efdf4 100644 --- a/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { workspaceKeys } from "../workspace/queries"; +import { projectUserPrivilegeKeys } from "./queries"; import { TCreateProjectUserPrivilegeDTO, TDeleteProjectUserPrivilegeDTO, @@ -22,8 +22,8 @@ export const useCreateProjectUserAdditionalPrivilege = () => { }); return data.privilege; }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + onSuccess: (_, { projectMembershipId }) => { + queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId)); } }); }; @@ -39,8 +39,8 @@ export const useUpdateProjectUserAdditionalPrivilege = () => { ); return data.privilege; }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + onSuccess: (_, { projectMembershipId }) => { + queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId)); } }); }; @@ -55,8 +55,8 @@ export const useDeleteProjectUserAdditionalPrivilege = () => { ); return data.privilege; }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + onSuccess: (_, { projectMembershipId }) => { + queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId)); } }); }; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx index 1f814d411..41c9a0dcc 100644 --- a/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx @@ -7,7 +7,9 @@ import { TProjectPermission } from "../roles/types"; import { TProjectUserPrivilege } from "./types"; export const projectUserPrivilegeKeys = { - details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const + details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const, + list: (projectMembershipId: string) => + ["project-user-privileges", { projectMembershipId }] as const }; const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => { @@ -29,3 +31,21 @@ export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => { queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId) }); }; + +export const useListProjectUserPrivileges = (projectMembershipId: string) => { + return useQuery({ + enabled: Boolean(projectMembershipId), + queryKey: projectUserPrivilegeKeys.list(projectMembershipId), + queryFn: async () => { + const { + data: { privileges } + } = await apiRequest.get<{ + privileges: Array & { permissions: unknown }>; + }>("/api/v1/additional-privilege/users", { params: { projectMembershipId } }); + return privileges.map((el) => ({ + ...el, + permissions: unpackRules(el.permissions as PackRule[]) + })); + } + }); +}; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx index c8e78db43..b757a07ab 100644 --- a/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx @@ -7,25 +7,30 @@ export enum ProjectUserAdditionalPrivilegeTemporaryMode { export type TProjectUserPrivilege = { projectMembershipId: string; slug: string; - name: string; - isTemporary: boolean; id: string; createdAt: Date; updatedAt: Date; - description?: string | null | undefined; - temporaryMode?: string | null | undefined; - temporaryRange?: string | null | undefined; - temporaryAccessStartTime?: string | null | undefined; - temporaryAccessEndTime?: Date | null | undefined; permissions?: TProjectPermission[]; -}; +} & ( + | { + isTemporary: true; + temporaryMode: string; + temporaryRange: string; + temporaryAccessStartTime: string; + temporaryAccessEndTime?: string; + } + | { + isTemporary: false; + temporaryMode?: null; + temporaryRange?: null; + temporaryAccessStartTime?: null; + temporaryAccessEndTime?: null; + } + ); export type TCreateProjectUserPrivilegeDTO = { projectMembershipId: string; - slug: string; - name: string; - workspaceId: string; - description?: string; + slug?: string; isTemporary?: boolean; temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode; temporaryRange?: string; @@ -35,14 +40,18 @@ export type TCreateProjectUserPrivilegeDTO = { export type TUpdateProjectUserPrivlegeDTO = { privilegeId: string; - workspaceId: string; + projectMembershipId: string; } & Partial>; export type TDeleteProjectUserPrivilegeDTO = { privilegeId: string; - workspaceId: string; + projectMembershipId: string; }; -export type TGetProejctUserPrivilegeDetails = { +export type TGetProjectUserPrivilegeDetails = { privilegeId: string; }; + +export type TListProjectUserPrivileges = { + projectMembershipId: string; +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 326bb71c0..23f4dda4c 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -76,30 +76,32 @@ export type TWorkspaceUser = { }; inviteEmail: string; organization: string; - additionalPrivileges: { - id: string; - name: string; - description: string | null | undefined; - slug: string; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessEndTime: string | null | undefined; - temporaryAccessStartTime: string | null | undefined; - isTemporary: boolean; - createdAt: 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; - }[]; + roles: ( + | { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } + | { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: true; + temporaryRange: string; + temporaryMode: string; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } + )[]; status: "invited" | "accepted" | "verified" | "completed"; deniedPermissions: any[]; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 30e341a6a..001454233 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -315,13 +315,6 @@ export const useGetWorkspaceUsers = (workspaceId: string) => { ); return users; }, - select: (data) => - data.map((el) => ({ - ...el, - additionalPrivileges: el.additionalPrivileges.sort((a, b) => - a.createdAt.localeCompare(b.createdAt) - ) - })), enabled: true }); }; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx deleted file mode 100644 index 767b9a6f9..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { useForm } from "react-hook-form"; -import { faElementor } from "@fortawesome/free-brands-svg-icons"; -import { - faAnchorLock, - faArrowLeft, - faBook, - faCog, - faKey, - faLock, - faNetworkWired, - faPuzzlePiece, - faServer, - faShield, - faTags, - faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; - -import { createNotification } from "@app/components/notifications"; -import guidGenerator from "@app/components/utilities/randomId"; -import { Button, FormControl, Input, Spinner } from "@app/components/v2"; -import { ProjectPermissionSub } from "@app/context"; -import { - useCreateIdentityProjectAdditionalPrivilege, - useCreateProjectUserAdditionalPrivilege, - useGetIdentityProjectPrivilegeDetails, - useGetProjectUserPrivilegeDetails, - useUpdateIdentityProjectAdditionalPrivilege, - useUpdateProjectUserAdditionalPrivilege -} from "@app/hooks/api"; - -import { MultiEnvProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission"; -import { - formRolePermission2API, - formSchema, - rolePermission2Form, - TFormSchema -} from "../ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; -import { SecretRollbackPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission"; -import { SingleProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission"; -import { WsProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission"; - -const SINGLE_PERMISSION_LIST = [ - { - title: "Integrations", - subtitle: "Integration management control", - icon: faPuzzlePiece, - formName: "integrations" - }, - { - title: "Secret Protect policy", - subtitle: "Manage policies for secret protection for unauthorized secret changes", - icon: faShield, - formName: ProjectPermissionSub.SecretApproval - }, - { - title: "Roles", - subtitle: "Role management control", - icon: faUsers, - formName: "role" - }, - { - title: "Project Members", - subtitle: "Project members management control", - icon: faUser, - formName: "member" - }, - { - title: "Machine identity management", - subtitle: "Add, view, update and remove (machine) identities from the project", - icon: faServer, - formName: "identity" - }, - { - title: "Webhooks", - subtitle: "Webhook management control", - icon: faAnchorLock, - formName: "webhooks" - }, - { - title: "Service Tokens", - subtitle: "Token management control", - icon: faKey, - formName: "service-tokens" - }, - { - title: "Settings", - subtitle: "Settings control", - icon: faCog, - formName: "settings" - }, - { - title: "Environments", - subtitle: "Environment management control", - icon: faElementor, - formName: "environments" - }, - { - title: "Tags", - subtitle: "Tag management control", - icon: faTags, - formName: "tags" - }, - { - title: "Audit Logs", - subtitle: "Audit log management control", - icon: faBook, - formName: "audit-logs" - }, - { - title: "IP Allowlist", - subtitle: "IP allowlist management control", - icon: faNetworkWired, - formName: "ip-allowlist" - } -] as const; - -type Props = { - onGoBack: VoidFunction; - isIdentity?: boolean; - privilegeId?: string; - workspaceId: string; - // isIdentity true -> actorId is identity Id - // isIdentity false -> actorId is projectMembershipId - actorId: string; -}; - -export const AdditionalPrivilegeForm = ({ - onGoBack, - privilegeId, - actorId, - workspaceId, - isIdentity -}: Props) => { - - const isEdit = Boolean(privilegeId); - - const { data: projectUserPrivilegeDetails, isLoading: isProjectUserPrivilegeLoading } = - useGetProjectUserPrivilegeDetails(privilegeId && !isIdentity ? privilegeId : ""); - - const { data: identityProjectPrivilegeDetails, isLoading: isIdentityProjectPrivilegeLoading } = - useGetIdentityProjectPrivilegeDetails(isIdentity && privilegeId ? privilegeId : ""); - - const privileges = isIdentity ? identityProjectPrivilegeDetails : projectUserPrivilegeDetails; - const isLoading = isIdentity ? isIdentityProjectPrivilegeLoading : isProjectUserPrivilegeLoading; - - const { - handleSubmit, - register, - formState: { isSubmitting, isDirty, errors }, - setValue, - getValues, - control - } = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - slug: `privilege-${guidGenerator().slice(0, 4).toLowerCase()}` - }, - values: privileges && { - ...privileges, - description: privileges.description || "", - permissions: rolePermission2Form(privileges.permissions) - } - }); - - const createProjectUserAdditionalPrivilege = useCreateProjectUserAdditionalPrivilege(); - const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); - - const createIdentityProjectAdditionalPrivilege = useCreateIdentityProjectAdditionalPrivilege(); - const updateIdentityProjectAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); - - const handleRoleUpdate = async (el: TFormSchema) => { - try { - if (isIdentity) { - await updateIdentityProjectAdditionalPrivilege.mutateAsync({ - ...el, - permissions: formRolePermission2API(el.permissions), - privilegeId: privilegeId as string, - projectId: workspaceId - }); - } else { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - ...el, - permissions: formRolePermission2API(el.permissions), - privilegeId: privilegeId as string, - workspaceId - }); - } - createNotification({ type: "success", text: "Successfully update privilege" }); - onGoBack(); - } catch (err) { - console.log(err); - createNotification({ type: "error", text: "Failed to update privilege" }); - } - }; - - const handleFormSubmit = async (el: TFormSchema) => { - if (isEdit) { - await handleRoleUpdate(el); - return; - } - - try { - if (isIdentity) { - await createIdentityProjectAdditionalPrivilege.mutateAsync({ - ...el, - permissions: formRolePermission2API(el.permissions), - identityId: actorId, - projectId: workspaceId - }); - } else { - await createProjectUserAdditionalPrivilege.mutateAsync({ - ...el, - permissions: formRolePermission2API(el.permissions), - projectMembershipId: actorId, - workspaceId - }); - } - createNotification({ type: "success", text: "Created new privilege" }); - onGoBack(); - } catch (err) { - console.log(err); - createNotification({ type: "error", text: "Failed to create privilege" }); - } - }; - - if (isEdit && isLoading) { - return ( -
- -
- ); - } - - return ( -
-
-
-

- {!isEdit ? "New" : "Edit"} user additional privilege -

- -
-

- Select multiple privilege that can be granted to the user -

-
- - - - - - - - - -
-
-

Add Privilege

-
-
-
- -
-
- -
- {SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => ( -
- -
- ))} -
- -
-
-
- - -
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx deleted file mode 100644 index 581925ffd..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { - faArrowLeft, - faPencil, - faPlus, - faTrash, - faUserShield -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { motion } from "framer-motion"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DeleteActionModal, - EmptyState, - IconButton, - Tag, - Tooltip -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { - useDeleteIdentityProjectAdditionalPrivilege, - useDeleteProjectUserAdditionalPrivilege -} from "@app/hooks/api"; -import { TWorkspaceUser } from "@app/hooks/api/types"; - -import { AdditionalPrivilegeForm } from "./AdditionalPrivilegeForm"; -import { AdditionalPrivilegeTemporaryAccess } from "./AdditionalPrivilegeTemporaryAccess"; - -type Props = { - onGoBack: VoidFunction; - name: string; - isIdentity?: boolean; - // isIdentity id - identity id else projectMembershipId - actorId: string; - privileges: TWorkspaceUser["additionalPrivileges"]; -}; - -export const AdditionalPrivilegeSection = ({ - onGoBack, - privileges = [], - actorId, - name, - isIdentity -}: Props) => { - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "modifyPrivilege", - "deletePrivilege" - ] as const); - - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; - const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege(); - const deleteProjectIdentityAdditionalPrivilege = useDeleteIdentityProjectAdditionalPrivilege(); - - const onPrivilegeDelete = async (privilegeId: string) => { - try { - if (isIdentity) { - await deleteProjectIdentityAdditionalPrivilege.mutateAsync({ - privilegeId, - projectId: workspaceId - }); - } else { - await deleteProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId, - workspaceId - }); - } - handlePopUpClose("deletePrivilege"); - createNotification({ - type: "success", - text: "Successfully removed privilege" - }); - } catch (err) { - createNotification({ - type: "error", - text: "Failed to delete privilege" - }); - } - }; - - if (popUp.modifyPrivilege.isOpen) { - const privilegeDetails = popUp?.modifyPrivilege?.data as { - id: string; - }; - - return ( - - handlePopUpClose("modifyPrivilege")} - privilegeId={privilegeDetails?.id} - workspaceId={workspaceId} - isIdentity={isIdentity} - actorId={actorId} - /> - - ); - } - - return ( - -
-

- Additional Privileges - {name} -

-
- - -
-
-
- {privileges.length === 0 && ( - - )} - {privileges.map(({ id, name: privilegeName, description, slug, ...dto }) => ( -
-
-
- {privilegeName} - - {slug} - -
-
{description}
-
-
- - handlePopUpOpen("modifyPrivilege", { id })} - > - - - - - handlePopUpOpen("deletePrivilege", { name: privilegeName, id })} - > - - - - -
-
- ))} -
- handlePopUpToggle("deletePrivilege", isOpen)} - deleteKey="delete" - onDeleteApproved={async () => - onPrivilegeDelete((popUp?.deletePrivilege.data as { id: string }).id) - } - /> -
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx deleted file mode 100644 index d45e9139e..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { Controller, useForm } from "react-hook-form"; -import { faClock } 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 { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - IconButton, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Tag, - Tooltip -} from "@app/components/v2"; -import { usePopUp } from "@app/hooks"; -import { - useUpdateIdentityProjectAdditionalPrivilege, - useUpdateProjectUserAdditionalPrivilege -} from "@app/hooks/api"; -import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types"; -import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types"; - -const temporaryRoleFormSchema = z.object({ - temporaryRange: z.string().min(1, "Required") -}); - -type TTemporaryRoleFormSchema = z.infer; - -type TTemporaryRoleFormProps = { - privilegeId: string; - workspaceId: string; - isIdentity?: boolean; - temporaryConfig?: { - isTemporary?: boolean; - temporaryAccessEndTime?: string | null; - temporaryAccessStartTime?: string | null; - temporaryRange?: string | null; - }; -}; - -export const AdditionalPrivilegeTemporaryAccess = ({ - temporaryConfig: defaultValues = {}, - workspaceId, - privilegeId, - isIdentity -}: 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 || ""); - - const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); - const updateProjectIdentityAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); - - const handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => { - try { - if (isIdentity) { - await updateProjectIdentityAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - projectId: workspaceId, - isTemporary: true, - temporaryRange: el.temporaryRange, - temporaryAccessStartTime: new Date().toISOString(), - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative - }); - } else { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - workspaceId, - isTemporary: true, - temporaryRange: el.temporaryRange, - temporaryAccessStartTime: new Date().toISOString(), - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative - }); - } - createNotification({ type: "success", text: "Successfully updated access" }); - handlePopUpToggle("setTempRole"); - } catch (err) { - console.log(err); - createNotification({ type: "error", text: "Failed to update access" }); - } - }; - - const handleRevokeTemporaryAccess = async () => { - try { - if (isIdentity) { - await updateProjectIdentityAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - projectId: workspaceId, - isTemporary: false - }); - } else { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - workspaceId, - isTemporary: false - }); - } - createNotification({ type: "success", text: "Successfully updated access" }); - handlePopUpToggle("setTempRole"); - } catch (err) { - console.log(err); - createNotification({ type: "error", text: "Failed to update access" }); - } - }; - - return ( - { - handlePopUpToggle("setTempRole", isOpen); - }} - > - - - - - - - - -
-
- Configure timed access -
- {isExpired && Expired} - ( - - 1m, 2h, 3d.{" "} - - More - - - } - > - - - )} - /> -
- - {isTemporaryFieldValue && ( - - )} -
-
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx deleted file mode 100644 index 42c37441d..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AdditionalPrivilegeSection } from "./AdditionalPrivilegeSection"; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 58577f1ec..816d786ed 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -3,13 +3,11 @@ import { faArrowUpRightFromSquare, faPlus, faServer, - faUserShield, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { motion } from "framer-motion"; -import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -32,13 +30,10 @@ import { withProjectPermission } from "@app/hoc"; import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; import { IdentityModal } from "./components/IdentityModal"; -import { IdentityRoles } from "./components/IdentityRoles"; export const IdentityTab = withProjectPermission( () => { - const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id ?? ""; @@ -78,33 +73,6 @@ export const IdentityTab = withProjectPermission( } }; - if (popUp.additionalPrivilege.isOpen) { - const privilegeDetails = popUp?.additionalPrivilege?.data as { - name: string; - index: number; - identityId: string; - }; - - return ( - - handlePopUpClose("additionalPrivilege")} - privileges={data?.[privilegeDetails.index]?.additionalPrivileges || []} - name={privilegeDetails.name} - actorId={privilegeDetails.identityId} - /> - - ); - } - return ( 0 && - data.map( - ({ identity: { id, name }, roles, createdAt, additionalPrivileges }, index) => { - const hasAdditionalPrivilege = Boolean(additionalPrivileges.length); - return ( - - {name} - - - {(isAllowed) => ( - - )} - - - {format(new Date(createdAt), "yyyy-MM-dd")} - -
- { + return ( + + {name} + {format(new Date(createdAt), "yyyy-MM-dd")} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} > - {(isAllowed) => ( - - handlePopUpOpen("additionalPrivilege", { - name, - index, - identityId: id - }) - } - > - - - )} - - - {(isAllowed) => ( - { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name - }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} - > - - - )} - -
- - - ); - } - )} + + + )} + + + + ); + })} {!isLoading && data && data?.length === 0 && ( diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index b114aa17b..9f1711199 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -3,10 +3,11 @@ import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { + faClock, + faEdit, faMagnifyingGlass, faPlus, faUsers, - faUserShield, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -22,6 +23,9 @@ import { DeleteActionModal, EmptyState, FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, IconButton, Input, Modal, @@ -31,10 +35,12 @@ import { Table, TableContainer, TableSkeleton, + Tag, TBody, Td, Th, THead, + Tooltip, Tr, UpgradePlanModal } from "@app/components/v2"; @@ -54,10 +60,11 @@ import { useGetUserWsKey, useGetWorkspaceUsers } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; -import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; -import { MemberRoles } from "./MemberRoles"; +import { MemberRoleForm } from "./MemberRoleForm"; const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() @@ -65,8 +72,14 @@ const addMemberFormSchema = z.object({ type TAddMemberForm = z.infer; +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; +}; + export const MemberListTab = () => { - const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -87,7 +100,7 @@ export const MemberListTab = () => { "addMember", "removeMember", "upgradePlan", - "additionalPrivilege" + "updateRole" ] as const); const { @@ -195,32 +208,6 @@ export const MemberListTab = () => { ); }, [orgUsers, members]); - if (popUp.additionalPrivilege.isOpen) { - const privilegeDetails = popUp?.additionalPrivilege?.data as { - name: string; - index: number; - projectMembershipId: string; - }; - - return ( - - handlePopUpClose("additionalPrivilege")} - privileges={members?.[privilegeDetails.index]?.additionalPrivileges || []} - name={privilegeDetails.name} - actorId={privilegeDetails.projectMembershipId} - /> - - ); - } - return ( { {isMembersLoading && } {!isMembersLoading && - filterdUsers?.map( - ( - { user: u, inviteEmail, id: membershipId, roles, additionalPrivileges }, - index - ) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const email = u?.email || inviteEmail; - const hasAdditionalPrivilege = Boolean(additionalPrivileges.length); + filterdUsers?.map((projectMember, index) => { + const { user: u, inviteEmail, id: membershipId, roles } = projectMember; + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; - return ( - - {name} - {email} - - - {(isAllowed) => ( - - handlePopUpOpen("upgradePlan", { description }) - } - membershipId={membershipId} - /> + return ( + + {name} + {email} + +
+ {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 && ( +
+ + + +
+ )} +
+
+ ); + } )} - - - - {userId !== u?.id && ( -
- - {(isAllowed) => ( - - handlePopUpOpen("additionalPrivilege", { - name: `${user.firstName} ${user.lastName || ""}`, - index, - projectMembershipId: membershipId - }) + {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" + )} + /> + +
+ )} +
+
+ ); } - > - -
- )} -
- - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) - } - > - - - )} - -
+ )} + + )} - - - ); - } - )} + {userId !== u?.id && ( + + + handlePopUpOpen("updateRole", { ...projectMember, index }) + } + > + + + + )} +
+ + + {userId !== u?.id && ( +
+ + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { username: u.username }) + } + > + + + )} + +
+ )} + + + ); + })} {!isMembersLoading && filterdUsers?.length === 0 && ( @@ -418,6 +464,27 @@ export const MemberListTab = () => { )} + handlePopUpToggle("updateRole", state)} + > + + handlePopUpOpen("upgradePlan", { description })} + projectMember={ + filterdUsers?.[ + (popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index + ] as TWorkspaceUser + } + /> + + ; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRbacSection = ({ 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" }); + roleForm.reset(undefined, { keepValues: true }); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {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) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx new file mode 100644 index 000000000..da6243281 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx @@ -0,0 +1,20 @@ +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { MemberRbacSection } from "./MemberRbacSection"; +import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection"; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..8aaf22142 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,497 @@ +import { Controller, useForm } from "react-hook-form"; +import { faCancel, faCaretDown, faClock, faClose, faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { 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, + Checkbox, + DeleteActionModal, + FormControl, + FormLabel, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + TProjectUserPrivilege, + useCreateProjectUserAdditionalPrivilege, + useDeleteProjectUserAdditionalPrivilege, + useListProjectUserPrivileges, + useUpdateProjectUserAdditionalPrivilege +} from "@app/hooks/api"; + +const secretPermissionSchema = z.object({ + secretPath: z.string().optional(), + environmentSlug: z.string(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + 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) + }) + ]) +}); +type TSecretPermissionForm = z.infer; +const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => { + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege" + ] as const); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege(); + const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); + + const privilegeForm = useForm({ + resolver: zodResolver(secretPermissionSchema), + values: { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + }); + + const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const isTemporary = temporaryAccessField?.isTemporary; + const isExpired = + temporaryAccessField.isTemporary && + new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); + + const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (updateUserPrivilege.isLoading) return; + try { + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await updateUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + ...data.temporaryAccess, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })), + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully updated privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update privilege" + }); + } + }; + + const handleDeletePrivilege = async () => { + if (deleteUserPrivilege.isLoading) return; + try { + await deleteUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + const getAccessLabel = () => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {privilegeForm.formState.isDirty ? ( + <> + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + privilegeForm.reset()} + > + + + + + ) : ( + + handlePopUpOpen("deletePrivilege")} + > + + + + )} +
+
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; + +type Props = { + membershipId: string; +}; + +export const SpecificPrivilegeSection = ({ membershipId }: Props) => { + const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId); + const { currentWorkspace } = useWorkspace(); + + const createUserPrivilege = useCreateProjectUserAdditionalPrivilege(); + + const handleCreatePrivilege = async () => { + if (createUserPrivilege.isLoading) return; + try { + await createUserPrivilege.mutateAsync({ + permissions: [ + { + action: ProjectPermissionActions.Read, + subject: [ProjectPermissionSub.Secrets], + conditions: { + environment: currentWorkspace?.environments?.[0].slug + } + } + ], + projectMembershipId: membershipId + }); + createNotification({ + type: "success", + text: "Successfully created privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create privilege" + }); + } + }; + + return ( +
+
+ Additional Privileges + {isLoading && } +
+

+ Select individual privileges to associate with the user +

+
+ {userPrivileges + ?.filter(({ permissions }) => + permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) + ) + ?.map((privilege) => ( + + ))} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx new file mode 100644 index 000000000..765b4061c --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx @@ -0,0 +1 @@ +export { MemberRoleForm } from "./MemberRoleForm"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx deleted file mode 100644 index 50d8cd0d9..000000000 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx +++ /dev/null @@ -1,471 +0,0 @@ -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 { createNotification } from "@app/components/notifications"; -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); - }} - > - - - - - - - - -
-
- Configure timed access -
- {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 { 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.." - /> -
-
- -
-
-
- )} -
-
-
-
- ); -};