From 96437fd1b7c639bc9e561d05412968b367eb83be Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 15 Aug 2023 16:55:48 +0530 Subject: [PATCH] feat(rbac): added rest of permissions in ui and backend api for org level --- backend/src/controllers/v1/roleController.ts | 3 + backend/src/services/RoleService.ts | 53 +++++- backend/src/validation/role.ts | 4 +- frontend/package-lock.json | 16 +- frontend/package.json | 3 +- .../BillingPermission.tsx | 147 +++++++++++++++++ .../IncidentContactPermission.tsx | 151 ++++++++++++++++++ .../OrgRoleModifySection/MemberPermission.tsx | 146 +++++++++++++++++ .../OrgRoleModifySection.tsx | 52 +++++- .../OrgRoleModifySection.utils.ts | 91 +++++++---- .../OrgRoleModifySection/RolePermission.tsx | 150 +++++++++++++++++ .../ServiceAccountPermission.tsx | 150 +++++++++++++++++ .../SettingsPermission.tsx | 150 +++++++++++++++++ .../OrgRoleModifySection/SsoPermission.tsx | 150 +++++++++++++++++ ...Permission.tsx => WorkspacePermission.tsx} | 43 ++--- .../OrgRoleTabSection/OrgRoleTable.tsx | 2 +- 16 files changed, 1253 insertions(+), 58 deletions(-) create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx rename frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/{OrgRoleWorkspacePermission.tsx => WorkspacePermission.tsx} (84%) diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index 9402a5091..026139ce9 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -135,18 +135,21 @@ export const getRoles = async (req: Request, res: Response) => { data: { roles: [ { + _id: "owner", name: "Owner", slug: "owner", description: "Complete administration access over the organization.", permissions: adminPermissions.rules }, { + _id: "admin", name: "Admin", slug: "admin", description: "Complete administration access over the organization", permissions: adminPermissions.rules }, { + _id: "member", name: "Member", slug: "member", description: "Non-administrative role in an organization", diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts index 228827c38..7274c437f 100644 --- a/backend/src/services/RoleService.ts +++ b/backend/src/services/RoleService.ts @@ -12,12 +12,24 @@ export enum GeneralPermissionActions { export enum OrgPermissionSubjects { Workspace = "workspace", - Role = "role" + Role = "role", + Member = "member", + Settings = "settings", + ServiceAccount = "service-account", + IncidentAccount = "incident-contact", + Sso = "sso", + Billing = "billing" } export type OrgPermissionSet = | [GeneralPermissionActions, OrgPermissionSubjects.Workspace] - | [GeneralPermissionActions, OrgPermissionSubjects.Role]; + | [GeneralPermissionActions, OrgPermissionSubjects.Role] + | [GeneralPermissionActions, OrgPermissionSubjects.Member] + | [GeneralPermissionActions, OrgPermissionSubjects.Settings] + | [GeneralPermissionActions, OrgPermissionSubjects.ServiceAccount] + | [GeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [GeneralPermissionActions, OrgPermissionSubjects.Sso] + | [GeneralPermissionActions, OrgPermissionSubjects.Billing]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -32,6 +44,36 @@ const buildAdminPermission = () => { can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role); can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Member); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Settings); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.ServiceAccount); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Sso); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Billing); + return build(); }; @@ -40,9 +82,14 @@ export const adminPermissions = buildAdminPermission(); const buildMemberPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); - // ws permissions can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.ServiceAccount); return build(); }; diff --git a/backend/src/validation/role.ts b/backend/src/validation/role.ts index 3ed732046..2e6623d57 100644 --- a/backend/src/validation/role.ts +++ b/backend/src/validation/role.ts @@ -11,7 +11,7 @@ export const CreateRoleSchema = z.object({ .object({ subject: z.string(), action: z.string(), - condition: z.record(z.union([z.string(), z.number()])) + condition: z.record(z.union([z.string(), z.number()])).optional() }) .array() }) @@ -31,7 +31,7 @@ export const UpdateRoleSchema = z.object({ .object({ subject: z.string(), action: z.string(), - condition: z.record(z.union([z.string(), z.number()])) + condition: z.record(z.union([z.string(), z.number()])).optional() }) .array() .optional() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11d27b3d5..262051bcf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -90,7 +90,8 @@ "uuid": "^8.3.2", "uuidv4": "^6.2.13", "yaml": "^2.2.2", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", @@ -23634,6 +23635,14 @@ "engines": { "node": ">=10" } + }, + "node_modules/zod": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", + "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -40759,6 +40768,11 @@ "property-expr": "^2.0.4", "toposort": "^2.0.2" } + }, + "zod": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", + "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==" } } } diff --git a/frontend/package.json b/frontend/package.json index ad83a25f3..1e73e4c7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -98,7 +98,8 @@ "uuid": "^8.3.2", "uuidv4": "^6.2.13", "yaml": "^2.2.2", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx new file mode 100644 index 000000000..6fbeac9fb --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx @@ -0,0 +1,147 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faMoneyBill } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const BillingPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.billing" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + switch (val) { + case Permission.NoAccess: + setValue( + "permissions.billing", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + "permissions.billing", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.billing", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + "permissions.billing", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Billing
+
Billing management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx new file mode 100644 index 000000000..2d92f5100 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faContactCard } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const IncidentContactPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.incident-contact" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.incident-contact", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.incident-contact", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.incident-contact", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.incident-contact", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Incident Contact
+
Incident Contacts management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx new file mode 100644 index 000000000..f924e52db --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx @@ -0,0 +1,146 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Invite" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const MemberPermission = ({ isNonEditable, setValue, control }: Props) => { + const memberRule = useWatch({ + control, + name: "permissions.member" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(memberRule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += memberRule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && memberRule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [memberRule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setValue( + "permissions.member", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + "permissions.member", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.member", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + "permissions.member", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Members
+
Project member management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index d761706b4..9143b1f10 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { faArrowLeft, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, FormControl, Input } from "@app/components/v2"; @@ -10,13 +10,20 @@ import { useOrganization } from "@app/context"; import { useCreateRole, useUpdateRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; +import { BillingPermission } from "./BillingPermission"; +import { IncidentContactPermission } from "./IncidentContactPermission"; +import { MemberPermission } from "./MemberPermission"; import { formRolePermission2API, formSchema, rolePermission2Form, TFormSchema } from "./OrgRoleModifySection.utils"; -import { OrgRoleWorkspacePermission } from "./OrgRoleWorkspacePermission"; +import { RolePermission } from "./RolePermission"; +import { ServiceAccountPermission } from "./ServiceAccountPermission"; +import { SettingsPermission } from "./SettingsPermission"; +import { SsoPermission } from "./SsoPermission"; +import { WorkspacePermission } from "./WorkspacePermission"; type Props = { role?: TRole; @@ -40,7 +47,7 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { control } = useForm({ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, - resolver: yupResolver(formSchema) + resolver: zodResolver(formSchema) }); const { mutateAsync: createRole } = useCreateRole(); @@ -142,7 +149,44 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
- +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ - yup.object( - Object.fromEntries( - Object.entries(val || {}).map(([k]) => [ - k, - yup.object({ - read: yup.bool(), - edit: yup.bool(), - delete: yup.bool(), - create: yup.bool() - }) - ]) - ) - ) - ) +const generalPermissionSchema = z.object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional() +}); + +export const formSchema = z.object({ + name: z.string(), + description: z.string().optional(), + slug: z.string(), + permissions: z.object({ + workspace: z.record(generalPermissionSchema), + member: generalPermissionSchema, + role: generalPermissionSchema, + settings: generalPermissionSchema, + "service-account": generalPermissionSchema, + "incident-contact": generalPermissionSchema, + sso: generalPermissionSchema, + billing: generalPermissionSchema }) }); -export type TFormSchema = yup.InferType; +export type TFormSchema = z.infer; const api2FormWorkspace = ( formVal: TFormSchema["permissions"]["workspace"], @@ -36,24 +36,43 @@ const api2FormWorkspace = ( ) => { if (permission.subject !== "workspace") return; const isCustomRule = Boolean(permission?.condition?.id); + // full access if (isCustomRule && !formVal?.custom) { formVal.custom = { read: true, edit: true, delete: true, create: true }; } const workspaceId = permission?.condition?.id || "all"; - if (!formVal?.[workspaceId]) + // initalize + if (!formVal?.[workspaceId]) { formVal[workspaceId] = { read: false, edit: false, create: false, delete: false }; + } formVal[workspaceId][permission.action] = true; }; // convert role permission to form compatiable data structure export const rolePermission2Form = (permissions: TPermission[] = []) => { const formVal: TFormSchema["permissions"] = { - workspace: {} + workspace: {}, + billing: {}, + settings: {}, + role: {}, + sso: {}, + member: {}, + "service-account": {}, + "incident-contact": {} }; permissions.forEach((permission) => { - api2FormWorkspace(formVal?.workspace, permission); + switch (permission.subject) { + case "workspace": + api2FormWorkspace(formVal?.workspace, permission); + break; + default: + // everything else follows same pattern + // formVal[settings][read | write] = true + formVal[permission.subject as keyof TFormSchema["permissions"]][permission.action] = true; + break; + } }); return formVal; @@ -64,7 +83,7 @@ const form2ApiWorkspace = ( workspace: TFormSchema["permissions"]["workspace"] ) => { const isFullAccess = PERMISSION_ACTIONS.every((action) => workspace?.all?.[action]); - + // if any of them is set in all push it without any condition PERMISSION_ACTIONS.forEach((action) => { if (workspace?.all?.[action]) permissions.push({ action, subject: "workspace" }); }); @@ -86,8 +105,24 @@ const form2ApiWorkspace = ( export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { const permissions: TPermission[] = []; - // easy deep copy - if (formVal?.workspace) + if (formVal?.workspace) { + // easy deep copy form2ApiWorkspace(permissions, JSON.parse(JSON.stringify(formVal.workspace))); + } + // other than workspace everything else follows same + // if in future there is a different follow the above on how workspace is done + const { workspace, ...rules } = formVal; + (Object.keys(rules) as Array).forEach((rule) => { + // all these type annotations are due to Object.keys of ts cannot infer and put it just a string[] + // quite annoying i know + const actions = Object.keys(rules[rule]) as Array< + keyof z.infer + >; + actions.forEach((action) => { + if (rules[rule][action]) { + permissions.push({ action, subject: rule }); + } + }); + }); return permissions; }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx new file mode 100644 index 000000000..342d87732 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faUserCog } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const RolePermission = ({ isNonEditable, setValue, control }: Props) => { + const roleRule = useWatch({ + control, + name: "permissions.role" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(roleRule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += roleRule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && roleRule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [roleRule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.role", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.role", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.role", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.role", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Role
+
Project role management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx new file mode 100644 index 000000000..37e52dccf --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faLaptopCode } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.service-account" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.service-account", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.service-account", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.service-account", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.service-account", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Service Accounts
+
Service Account management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx new file mode 100644 index 000000000..0bfc821e7 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faCog } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.settings" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.settings", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.settings", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.settings", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.settings", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Settings
+
Settings management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx new file mode 100644 index 000000000..25ae2438b --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faSignIn } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.sso" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.sso", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.sso", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.sso", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.sso", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
SSO
+
SSO management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx similarity index 84% rename from frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx rename to frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx index deb1bed20..daac5a523 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx @@ -3,6 +3,7 @@ import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form" import { faClipboardList } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { Checkbox, @@ -26,14 +27,14 @@ type Props = { control: Control; }; -enum WorkspacePermission { +enum Permission { NoAccess = "no-access", ReadOnly = "read-only", FullAccess = "full-acess", Custom = "custom" } -export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { +export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { const { workspaces } = useWorkspace(); const customWorkspaceRule = useWatch({ @@ -43,26 +44,26 @@ export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: const isCustom = Boolean(customWorkspaceRule); const allWorkspaceRule = useWatch({ control, name: "permissions.workspace.all" }); - const selectedWsTopVal = useMemo(() => { + const selectedPermissionCategory = useMemo(() => { const { read, delete: del, edit, create } = allWorkspaceRule || {}; - if (read && del && edit && create) return WorkspacePermission.FullAccess; - if (read) return WorkspacePermission.ReadOnly; - return WorkspacePermission.NoAccess; + if (read && del && edit && create) return Permission.FullAccess; + if (read) return Permission.ReadOnly; + return Permission.NoAccess; }, [allWorkspaceRule]); - const handleTopLevelPermissionChange = (val: WorkspacePermission) => { + const handlePermissionChange = (val: Permission) => { switch (val) { - case WorkspacePermission.NoAccess: + case Permission.NoAccess: setValue("permissions.workspace", {}, { shouldDirty: true }); break; - case WorkspacePermission.FullAccess: + case Permission.FullAccess: setValue( "permissions.workspace", { all: { read: true, edit: true, create: true, delete: true } }, { shouldDirty: true } ); break; - case WorkspacePermission.ReadOnly: + case Permission.ReadOnly: setValue( "permissions.workspace", { all: { read: true, edit: false, create: false, delete: false } }, @@ -80,7 +81,13 @@ export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: }; return ( -
+
@@ -91,15 +98,15 @@ export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }:
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx index 2bc09cb89..1fbe3a957 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -88,7 +88,7 @@ export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props const isNonMutatable = ["owner", "admin", "member"].includes(slug); return ( - + {name} {slug}