From 81cf19cb4ad1bec8e00be18b2ac0b53f340df24b Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 15:56:16 +0530 Subject: [PATCH] feat(ui): completed ui for user additional privilege --- frontend/src/components/v2/Tag/Tag.tsx | 1 + .../views/Project/MembersPage/MembersPage.tsx | 10 +- .../AdditionalPrivilegeForm.tsx | 283 ++++++++++++++++++ .../AdditionalPrivilegeSection.tsx | 181 +++++++++++ .../AdditionalPrivilegeTemporaryAccess.tsx | 193 ++++++++++++ .../AdditionalPrivilegeSection/index.tsx | 1 + .../MemberListTab/MemberListTab.tsx | 171 ++++++++--- .../MultiEnvProjectPermission.tsx | 13 +- .../ProjectRoleModifySection.tsx | 2 + 9 files changed, 794 insertions(+), 61 deletions(-) create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx diff --git a/frontend/src/components/v2/Tag/Tag.tsx b/frontend/src/components/v2/Tag/Tag.tsx index 5f3771f62..7210e388b 100644 --- a/frontend/src/components/v2/Tag/Tag.tsx +++ b/frontend/src/components/v2/Tag/Tag.tsx @@ -20,6 +20,7 @@ const tagVariants = cva( green: "bg-primary-800 text-white" }, size: { + xs: "text-xs px-1 py-0.5", sm: "px-2 py-0.5" } } diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index b565d426c..66c01f773 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -32,15 +32,7 @@ export const MembersPage = withProjectPermission( Project Roles - - - + diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx new file mode 100644 index 000000000..c0d649942 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx @@ -0,0 +1,283 @@ +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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { + useCreateProjectUserAdditionalPrivilege, + useGetProjectUserPrivilegeDetails, + useUpdateProjectUserAdditionalPrivilege +} from "@app/hooks/api"; + +import { MultiEnvProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission"; +import { + formRolePermission2API, + formSchema, + rolePermission2Form, + // 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 }: Props) => { + const { createNotification } = useNotificationContext(); + const isNewRole = !privilegeId; + + const { data: projectUserPrivilegeDetails } = useGetProjectUserPrivilegeDetails( + privilegeId || "" + ); + + const { + handleSubmit, + register, + formState: { isSubmitting, isDirty, errors }, + setValue, + getValues, + control + } = useForm({ + resolver: zodResolver(formSchema), + values: projectUserPrivilegeDetails && { + ...projectUserPrivilegeDetails, + description: projectUserPrivilegeDetails.description || "", + permissions: rolePermission2Form(projectUserPrivilegeDetails.permissions) + } + }); + + const createProjectUserAdditionalPrivilege = useCreateProjectUserAdditionalPrivilege(); + const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); + + const handleRoleUpdate = async (el: TFormSchema) => { + try { + 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 (!isNewRole) { + await handleRoleUpdate(el); + return; + } + + try { + 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" }); + } + }; + + return ( +
+
+
+

+ {isNewRole ? "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 new file mode 100644 index 000000000..c13af0b98 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx @@ -0,0 +1,181 @@ +import { + faArrowLeft, + faPencil, + faPlus, + faTrash, + faUserShield +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + IconButton, + Tag, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { 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; + projectMembershipId: string; + privileges: TWorkspaceUser["additionalPrivileges"]; +}; + +export const AdditionalPrivilegeSection = ({ + onGoBack, + privileges = [], + projectMembershipId, + name +}: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "modifyPrivilege", + "deletePrivilege" + ] as const); + const { createNotification } = useNotificationContext(); + const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + + const onPrivilegeDelete = async (privilegeId: string) => { + try { + await deleteProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId, + workspaceId + }); + handlePopUpClose("deletePrivilege"); + createNotification({ + type: "success", + text: "Successfully removed user privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete user privilege" + }); + } + }; + + if (popUp.modifyPrivilege.isOpen) { + const privilegeDetails = popUp?.modifyPrivilege?.data as { + id: string; + }; + + return ( + + handlePopUpClose("modifyPrivilege")} + privilegeId={privilegeDetails?.id} + workspaceId={workspaceId} + actorId={projectMembershipId} + /> + + ); + } + + 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 new file mode 100644 index 000000000..718a7de55 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx @@ -0,0 +1,193 @@ +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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Tag, + Tooltip +} from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { useUpdateProjectUserAdditionalPrivilege } from "@app/hooks/api"; +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; + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; +}; + +export const AdditionalPrivilegeTemporaryAccess = ({ + temporaryConfig: defaultValues = {}, + workspaceId, + privilegeId +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { createNotification } = useNotificationContext(); + 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 handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => { + try { + 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 { + 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 new file mode 100644 index 000000000..42c37441d --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx @@ -0,0 +1 @@ +export { AdditionalPrivilegeSection } from "./AdditionalPrivilegeSection"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 342b3b920..fdf5a7309 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -2,9 +2,17 @@ import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faMagnifyingGlass, + faPlus, + faUsers, + faUserShield, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -48,6 +56,7 @@ import { } from "@app/hooks/api"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; import { MemberRoles } from "./MemberRoles"; const addMemberFormSchema = z.object({ @@ -77,7 +86,8 @@ export const MemberListTab = () => { const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addMember", "removeMember", - "upgradePlan" + "upgradePlan", + "additionalPrivilege" ] as const); const { @@ -185,8 +195,41 @@ 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} + projectMembershipId={privilegeDetails.projectMembershipId} + /> + + ); + } + return ( -
+

Members

@@ -223,58 +266,90 @@ export const MemberListTab = () => { {isMembersLoading && } {!isMembersLoading && - filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const email = u?.email || inviteEmail; + 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); - return ( - - {name} - {email} - - - {(isAllowed) => ( - - handlePopUpOpen("upgradePlan", { description }) - } - membershipId={membershipId} - /> - )} - - - - {userId !== u?.id && ( + return ( + + {name} + {email} + {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) + + handlePopUpOpen("upgradePlan", { description }) } - > - - + membershipId={membershipId} + /> )} - )} - - - ); - })} + + + {userId !== u?.id && ( +
+ + {(isAllowed) => ( + + handlePopUpOpen("additionalPrivilege", { + name: `${user.firstName} ${user.lastName || ""}`, + index, + projectMembershipId: membershipId + }) + } + > + + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { username: u.username }) + } + > + + + )} + +
+ )} + + + ); + } + )} {!isMembersLoading && filterdUsers?.length === 0 && ( @@ -355,6 +430,6 @@ export const MemberListTab = () => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text={(popUp.upgradePlan?.data as { description: string })?.description} /> -
+
); }; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx index db18822f4..188d81c65 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; @@ -28,6 +28,7 @@ type Props = { formName: "secrets"; isNonEditable?: boolean; setValue: UseFormSetValue; + getValue: UseFormGetValues; control: Control; title: string; subtitle: string; @@ -44,6 +45,7 @@ enum Permission { export const MultiEnvProjectPermission = ({ isNonEditable, setValue, + getValue, control, formName, title, @@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({ const handlePermissionChange = (val: Permission) => { switch (val) { - case Permission.NoAccess: - setValue(`permissions.${formName}`, undefined, { shouldDirty: true }); + case Permission.NoAccess: { + const permissions = getValue("permissions"); + if (permissions) delete permissions[formName]; + setValue("permissions", permissions, { shouldDirty: true }); break; + } case Permission.FullAccess: setValue( `permissions.${formName}`, @@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({ className={twMerge( "rounded-md bg-mineshaft-800 px-10 py-6", (selectedPermissionCategory !== Permission.NoAccess || isCustom) && - "border-l-2 border-primary-600" + "border-l-2 border-primary-600" )} >
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index cf2e7e8b9..4349b0913 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -128,6 +128,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { register, formState: { isSubmitting, isDirty, errors }, setValue, + getValues, control } = useForm({ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, @@ -226,6 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {