From bf00d16c800bef84b37392a8a2cad3ecfee91514 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 25 Jul 2024 14:45:02 -0700 Subject: [PATCH] Continue progress on project role page --- .../roles/{[roleId] => [roleSlug]}/index.tsx | 2 +- .../src/views/Project/RolePage/RolePage.tsx | 36 +-- .../components/RoleDetailsSection.tsx | 99 ++++++++ .../RolePermissionRow.tsx | 232 ++++++++++++++++++ .../RolePermissionSecretsRow.tsx | 110 +++++++++ .../RolePermissionsSection.tsx | 199 +++++++++++++++ .../RolePermissionsSection/index.tsx | 1 + .../Project/RolePage/components/index.tsx | 2 + 8 files changed, 657 insertions(+), 24 deletions(-) rename frontend/src/pages/project/[id]/roles/{[roleId] => [roleSlug]}/index.tsx (83%) create mode 100644 frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx create mode 100644 frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx create mode 100644 frontend/src/views/Project/RolePage/components/index.tsx diff --git a/frontend/src/pages/project/[id]/roles/[roleId]/index.tsx b/frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx similarity index 83% rename from frontend/src/pages/project/[id]/roles/[roleId]/index.tsx rename to frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx index 13a6278bc..17c854cee 100644 --- a/frontend/src/pages/project/[id]/roles/[roleId]/index.tsx +++ b/frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx @@ -9,7 +9,7 @@ export default function Role() { return ( <> - {t("common.head-title", { title: t("settings.org.title") })} + {t("common.head-title", { title: "Project Settings" })} diff --git a/frontend/src/views/Project/RolePage/RolePage.tsx b/frontend/src/views/Project/RolePage/RolePage.tsx index 4294a018b..235c28974 100644 --- a/frontend/src/views/Project/RolePage/RolePage.tsx +++ b/frontend/src/views/Project/RolePage/RolePage.tsx @@ -5,8 +5,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -// import { OrgPermissionCan } from "@app/components/permissions"; -// import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, @@ -18,11 +16,12 @@ import { Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -// import { withPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc"; -import { useDeleteOrgRole, useGetOrgRole, useGetProjectRoleBySlug } from "@app/hooks/api"; +import { useGetProjectRoleBySlug } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; +import { RoleDetailsSection, RolePermissionsSection } from "./components"; + // import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components"; export const RolePage = withProjectPermission( @@ -32,24 +31,17 @@ export const RolePage = withProjectPermission( const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace?.id || ""; - console.log("RolePage currentWorkspace: ", currentWorkspace); - - // const { currentOrg } = useOrganization(); - // const orgId = currentOrg?.id || ""; - const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); - console.log("useGetProjectRoleBySlug data: ", data); - // const { data } = useGetOrgRole(orgId, roleId); // TODO: get project role - const { mutateAsync: deleteOrgRole } = useDeleteOrgRole(); + // const { mutateAsync: deleteOrgRole } = useDeleteOrgRole(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "role", - "deleteOrgRole" + "deleteRole" ] as const); - const onDeleteOrgRoleSubmit = async () => { + const onDeleteRoleSubmit = async () => { try { // if (!orgId || !roleId) return; // await deleteOrgRole({ @@ -65,7 +57,7 @@ export const RolePage = withProjectPermission( } catch (err) { console.error(err); const error = err as any; - const text = error?.response?.data?.message ?? "Failed to delete organization role"; + const text = error?.response?.data?.message ?? "Failed to delete project role"; createNotification({ text, @@ -136,7 +128,7 @@ export const RolePage = withProjectPermission( : "pointer-events-none cursor-not-allowed opacity-50" )} onClick={async () => { - handlePopUpOpen("deleteOrgRole"); + handlePopUpOpen("deleteRole"); }} disabled={!isAllowed} > @@ -150,21 +142,19 @@ export const RolePage = withProjectPermission(
- RoleDetailsSection here - {/* */} +
- RolePermissionsSection here - {/* */} +
)} {/* */} handlePopUpToggle("deleteOrgRole", isOpen)} + onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)} deleteKey="confirm" - onDeleteApproved={() => onDeleteOrgRoleSubmit()} + onDeleteApproved={() => onDeleteRoleSubmit()} /> ); diff --git a/frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx b/frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx new file mode 100644 index 000000000..1fb9e7767 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RoleDetailsSection.tsx @@ -0,0 +1,99 @@ +import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetProjectRoleBySlug } from "@app/hooks/api"; +// import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + roleSlug: string; + // handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: {}) => void; +}; + +export const RoleDetailsSection = ({ + roleSlug +}: // handlePopUpOpen +Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { currentWorkspace } = useWorkspace(); + const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return data ? ( +
+
+

Details

+ {isCustomRole && ( + + {(isAllowed) => { + return ( + + { + // TODO + // handlePopUpOpen("role", { + // roleId + // }) + }} + > + + + + ); + }} + + )} +
+
+
+

Role ID

+
+

{data.id}

+
+ + { + navigator.clipboard.writeText(data.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Name

+

{data.name}

+
+
+

Slug

+

{data.slug}

+
+
+

Description

+

+ {data.description?.length ? data.description : "-"} +

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx new file mode 100644 index 000000000..163f3a839 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { TFormSchema } from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; + +const GENERAL_PERMISSIONS = [ + { action: "read", label: "View" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Modify" }, + { action: "delete", label: "Remove" } +] as const; + +const WORKSPACE_PERMISSIONS = [ + { action: "edit", label: "Update project details" }, + { action: "delete", label: "Delete projects" } +] as const; + +const MEMBERS_PERMISSIONS = [ + { action: "read", label: "View all members" }, + { action: "create", label: "Invite members" }, + { action: "edit", label: "Edit members" }, + { action: "delete", label: "Remove members" } +] as const; + +const SECRET_ROLLBACK_PERMISSIONS = [ + { action: "create", label: "Perform Rollback" }, + { action: "read", label: "View" } +] as const; + +const getPermissionList = (option: Props["formName"]) => { + switch (option) { + case "workspace": + return WORKSPACE_PERMISSIONS; + case "member": + return MEMBERS_PERMISSIONS; + case "secret-rollback": + return SECRET_ROLLBACK_PERMISSIONS; + default: + return GENERAL_PERMISSIONS; + } +}; + +type PermissionName = + | `permissions.workspace.${"edit" | "delete"}` + | `permissions.secret-rollback.${"create" | "read"}` + | `permissions.${Exclude< + keyof NonNullable, + "workspace" | "secret-rollback" | "secrets" + >}.${"read" | "create" | "edit" | "delete"}`; + +type Props = { + isEditable: boolean; + title: string; + formName: keyof Omit, "secrets">; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +export const RolePermissionRow = ({ isEditable, title, formName, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: `permissions.${formName}` + }); + + const selectedPermissionCategory = useMemo(() => { + // TODO: re-check + const actions = Object.keys(rule || {}) as Array; + + switch (formName) { + case "secret-rollback": + case "workspace": { + const totalActions = WORKSPACE_PERMISSIONS.length; + const score = actions + .map((key) => (rule?.[key] ? 1 : 0)) + .reduce((a, b) => a + b, 0 as number); + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + + return Permission.Custom; // TODO: update + } + default: { + const totalActions = GENERAL_PERMISSIONS.length; + const score = actions + .map((key) => (rule?.[key] ? 1 : 0)) + .reduce((a, b) => a + b, 0 as number); + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (rule && "read" in rule) { + if (score === 1 && rule?.read) return Permission.ReadOnly; + } + + return Permission.Custom; + } + } + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + switch (val) { + case Permission.NoAccess: + setValue( + `permissions.${formName}`, + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + `permissions.${formName}`, + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + `permissions.${formName}`, + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + `permissions.${formName}`, + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + {title} + + + + + {isRowExpanded && ( + + +
+ {getPermissionList(formName).map(({ action, label }) => { + const permissionName = `permissions.${formName}.${action}` as PermissionName; + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={permissionName} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx new file mode 100644 index 000000000..d36be71ae --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import { + Control, + // Controller, + UseFormGetValues, + UseFormSetValue, + useWatch +} from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Select, SelectItem,Td, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { TFormSchema } from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; + +type Props = { + title: string; + formName: "secrets"; + isEditable: boolean; + setValue: UseFormSetValue; + getValue: UseFormGetValues; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +export const RowPermissionSecretsRow = ({ + title, + formName, + isEditable, + setValue, + getValue, + control +}: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + // const [isCustom, setIsCustom] = useToggle(); + + const allRule = useWatch({ control, name: `permissions.${formName}.all` }); + + const selectedPermissionCategory = useMemo(() => { + const { read, delete: del, edit, create } = allRule || {}; + if (read && del && edit && create) return Permission.FullAccess; + if (read) return Permission.ReadOnly; + return Permission.NoAccess; + }, [allRule]); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + switch (val) { + case Permission.NoAccess: { + const permissions = getValue("permissions"); + if (permissions) delete permissions[formName]; + setValue("permissions", permissions, { shouldDirty: true }); + break; + } + case Permission.FullAccess: + setValue( + `permissions.${formName}`, + { all: { read: true, edit: true, create: true, delete: true } }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + `permissions.${formName}`, + { all: { read: true, edit: false, create: false, delete: false } }, + { shouldDirty: true } + ); + break; + default: + setValue( + `permissions.${formName}`, + { custom: { read: false, edit: false, create: false, delete: false } }, + { shouldDirty: true } + ); + break; + } + }; + + return ( + setIsRowExpanded.toggle()} + > + + + + {title} + + + + + ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx new file mode 100644 index 000000000..3097520bc --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -0,0 +1,199 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Table, + TableContainer, + TBody, + Th, + THead, + Tr + // Td +} from "@app/components/v2"; +import { ProjectPermissionSub,useWorkspace } from "@app/context"; +import { + // useUpdateOrgRole, + useGetProjectRoleBySlug +} from "@app/hooks/api"; +import { + formSchema, + rolePermission2Form, + TFormSchema} from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; + +import { RolePermissionRow } from "./RolePermissionRow"; + +const SINGLE_PERMISSION_LIST = [ + { + title: "Project", + formName: "workspace" + }, + { + title: "Integrations", + formName: "integrations" + }, + { + title: "Secret Protect policy", + formName: ProjectPermissionSub.SecretApproval + }, + { + title: "Roles", + formName: "role" + }, + { + title: "User management", + formName: "member" + }, + { + title: "Group management", + formName: "groups" + }, + { + title: "Machine identity management", + formName: "identity" + }, + { + title: "Webhooks", + formName: "webhooks" + }, + { + title: "Service Tokens", + formName: "service-tokens" + }, + { + title: "Settings", + formName: "settings" + }, + { + title: "Environments", + formName: "environments" + }, + { + title: "Tags", + formName: "tags" + }, + { + title: "Audit Logs", + formName: "audit-logs" + }, + { + title: "IP Allowlist", + formName: "ip-allowlist" + }, + { + title: "Certificate Authorities", + formName: "certificate-authorities" + }, + { + title: "Certificates", + formName: "certificates" + }, + { + title: "Secret Rollback", + formName: "secret-rollback" + } +] as const; + +type Props = { + roleSlug: string; +}; + +// TODO: fill table + +export const RolePermissionsSection = ({ roleSlug }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: role } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string); + + console.log("RolePermissionsSection role data: ", role); + + const { + setValue, + control, + handleSubmit, + formState: { isDirty, isSubmitting }, + reset + } = useForm({ + defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + resolver: zodResolver(formSchema) + }); + + // const { mutateAsync: updateRole } = useUpdateOrgRole(); + + const onSubmit = async () => { + try { + // await updateRole({ + // orgId, + // id: roleId, + // ...el, + // permissions: formRolePermission2API(el.permissions) + // }); + createNotification({ type: "success", text: "Successfully updated role" }); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update role" }); + } + }; + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? ""); + + return ( +
+
+

Permissions

+ {isCustomRole && ( +
+ + +
+ )} +
+
+ + + + + + + + + + {SINGLE_PERMISSION_LIST.map((permission) => { + console.log("permission: ", permission); + return ( + + ); + })} + +
+ ResourcePermission
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx new file mode 100644 index 000000000..104e2144e --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/index.tsx @@ -0,0 +1 @@ +export { RolePermissionsSection } from "./RolePermissionsSection"; diff --git a/frontend/src/views/Project/RolePage/components/index.tsx b/frontend/src/views/Project/RolePage/components/index.tsx new file mode 100644 index 000000000..dadfb93af --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/index.tsx @@ -0,0 +1,2 @@ +export { RoleDetailsSection } from "./RoleDetailsSection"; +export { RolePermissionsSection } from "./RolePermissionsSection";