feat(rbac): added new permission check for workspace in frontend

This commit is contained in:
Akhil Mohan
2023-09-08 21:22:36 +05:30
parent 549121f44e
commit 6671699867
48 changed files with 3446 additions and 2804 deletions
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useGetRoles } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
@@ -15,44 +16,50 @@ enum TabSections {
Roles = "roles"
}
export const MembersPage = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const orgId = currentWorkspace?.organization || "";
export const MembersPage = withProjectPermission(
() => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const orgId = currentWorkspace?.organization || "";
const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId,
workspaceId
});
const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId,
workspaceId
});
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">{t("settings.members.title")}</p>
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>Members</Tab>
{process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && (
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">
{t("settings.members.title")}
</p>
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>Members</Tab>
<Tab value={TabSections.Roles}>Roles</Tab>
)}
</TabList>
<TabPanel value={TabSections.Member}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MemberListTab roles={roles as TRole<string>[]} />
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Roles}>
<ProjectRoleListTab roles={roles as TRole<string>[]} isRolesLoading={isRolesLoading} />
</TabPanel>
</Tabs>
</TabList>
<TabPanel value={TabSections.Member}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MemberListTab roles={roles as TRole<string>[]} />
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Roles}>
<ProjectRoleListTab
roles={roles as TRole<string>[]}
isRolesLoading={isRolesLoading}
/>
</TabPanel>
</Tabs>
</div>
</div>
</div>
);
};
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Member }
);
@@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions";
import {
decryptAssymmetric,
encryptAssymmetric
@@ -36,6 +36,8 @@ import {
import {
GeneralPermissionActions,
OrgPermissionSubjects,
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useUser,
useWorkspace
@@ -240,7 +242,7 @@ export const MemberListTab = ({ roles = [] }: Props) => {
placeholder="Search members..."
/>
</div>
<OrgPermissionCan I={GeneralPermissionActions.Create} a={OrgPermissionSubjects.Member}>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
@@ -250,7 +252,7 @@ export const MemberListTab = ({ roles = [] }: Props) => {
Add Member
</Button>
)}
</OrgPermissionCan>
</ProjectPermissionCan>
</div>
<div>
<TableContainer>
@@ -276,9 +278,9 @@ export const MemberListTab = ({ roles = [] }: Props) => {
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
<OrgPermissionCan
I={GeneralPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<>
@@ -316,13 +318,13 @@ export const MemberListTab = ({ roles = [] }: Props) => {
)}
</>
)}
</OrgPermissionCan>
</ProjectPermissionCan>
</Td>
<Td>
{userId !== u?._id && (
<OrgPermissionCan
I={GeneralPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<IconButton
@@ -336,7 +338,7 @@ export const MemberListTab = ({ roles = [] }: Props) => {
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</OrgPermissionCan>
</ProjectPermissionCan>
)}
</Td>
</Tr>
@@ -1,5 +1,7 @@
import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { TRole } from "@app/hooks/api/roles/types";
@@ -11,35 +13,38 @@ type Props = {
isRolesLoading?: boolean;
};
export const ProjectRoleListTab = ({ roles = [], isRolesLoading }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
export const ProjectRoleListTab = withProjectPermission(
({ roles = [], isRolesLoading }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
return popUp.editRole.isOpen ? (
<motion.div
key="role-modify"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleModifySection
role={popUp.editRole.data as TRole<string>}
onGoBack={() => handlePopUpClose("editRole")}
/>
</motion.div>
) : (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<ProjectRoleList
roles={roles}
isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/>
</motion.div>
);
};
return popUp.editRole.isOpen ? (
<motion.div
key="role-modify"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleModifySection
role={popUp.editRole.data as TRole<string>}
onGoBack={() => handlePopUpClose("editRole")}
/>
</motion.div>
) : (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<ProjectRoleList
roles={roles}
isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/>
</motion.div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
);
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
@@ -19,7 +20,12 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
@@ -69,9 +75,17 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
placeholder="Search roles..."
/>
</div>
<Button leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => onSelectRole()}>
Add Role
</Button>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => onSelectRole()}
isDisabled={!isAllowed}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
</div>
<div>
<TableContainer>
@@ -99,27 +113,48 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
</Td>
<Td>
<div className="flex space-x-2 items-center">
<Tooltip content="Edit">
<IconButton
ariaLabel="edit"
onClick={() => onSelectRole(role)}
variant="plain"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
<Tooltip
content={isNonMutatable ? "Reserved roles are non-removable" : "Delete"}
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
<IconButton
ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain"
isDisabled={isNonMutatable}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
{(isAllowed) => (
<div>
<Tooltip content="Edit">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit"
onClick={() => onSelectRole(role)}
variant="plain"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<div>
<Tooltip
content={
isNonMutatable ? "Reserved roles are non-removable" : "Delete"
}
>
<IconButton
ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain"
isDisabled={isNonMutatable || !isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
@@ -34,6 +34,7 @@ import {
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SingleProjectPermission } from "./SingleProjectPermission";
import { WsProjectPermission } from "./WsProjectPermission";
const SINGLE_PERMISSION_LIST = [
{
@@ -271,6 +272,13 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
/>
</div>
))}
<div className="flex flex-col space-y-4" key="permission-ws">
<WsProjectPermission
control={control}
setValue={setValue}
isNonEditable={isNonEditable}
/>
</div>
</div>
<div className="flex items-center space-x-4 mt-12">
<Button
@@ -0,0 +1,127 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faPuzzlePiece } 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 "./ProjectRoleModifySection.utils";
type Props = {
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
const PERMISSIONS = [
{ action: "edit", label: "Update" },
{ action: "delete", label: "Remove" }
] as const;
export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => {
const rule = useWatch({
control,
name: "permissions.workspace"
});
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = 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;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
break;
case Permission.FullAccess:
setValue("permissions.workspace", { edit: true, delete: true }, { shouldDirty: true });
break;
default:
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
break;
}
};
return (
<div
className={twMerge(
"px-10 py-6 bg-mineshaft-800 rounded-md",
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Workspace</div>
<div className="text-xs font-light">Workspace control actions</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
>
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.workspace.${action}`}
key={`permissions.workspace.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);
};