feat(rbac): base ui for org rbac management

This commit is contained in:
Akhil Mohan
2023-09-08 21:20:29 +05:30
parent 1167b1bc60
commit c5f76b1e6f
17 changed files with 1182 additions and 1000 deletions
@@ -0,0 +1,167 @@
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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useCreateRole, useUpdateRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "./OrgRoleModifySection.utils";
import { OrgRoleWorkspacePermission } from "./OrgRoleWorkspacePermission";
type Props = {
role?: TRole;
onGoBack: VoidFunction;
};
export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
const [searchPermission, setSearchPermission] = useState("");
const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || "");
const isNewRole = !role?.slug;
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const orgId = currentOrg?._id || "";
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, errors },
setValue,
control
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
resolver: yupResolver(formSchema)
});
const { mutateAsync: createRole } = useCreateRole();
const { mutateAsync: updateRole } = useUpdateRole();
const handleRoleUpdate = async (el: TFormSchema) => {
if (!role?._id) return;
try {
await updateRole({
orgId,
id: role?._id,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const handleFormSubmit = async (el: TFormSchema) => {
if (!isNewRole) {
await handleRoleUpdate(el);
return;
}
try {
await createRole({
orgId,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Created new role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
return (
<div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex justify-between mb-2 items-center">
<h1 className="text-xl font-semibold text-mineshaft-100">
{isNewRole ? "New" : "Edit"} Role
</h1>
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
>
Go back
</Button>
</div>
<p className="mb-8 text-gray-400">
Roles are used to grant access to particular resources in your organization
</p>
<div className="flex flex-col space-y-6">
<FormControl
label="Name"
isRequired
className="mb-0"
isError={Boolean(errors?.name)}
errorText={errors?.name?.message}
>
<Input {...register("name")} placeholder="Billing Team" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Slug"
isRequired
isError={Boolean(errors?.slug)}
errorText={errors?.slug?.message}
>
<Input {...register("slug")} placeholder="biller" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Description"
helperText="A short description about this role"
isError={Boolean(errors?.description)}
errorText={errors?.description?.message}
>
<Input {...register("description")} isReadOnly={isNonEditable} />
</FormControl>
<div className="flex justify-between items-center pt-4 border-t border-t-mineshaft-800">
<div>
<h2 className="text-xl font-medium">Add Permission</h2>
</div>
<div className="flex-1 max-w-md">
<Input
value={searchPermission}
onChange={(e) => setSearchPermission(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search permissions..."
/>
</div>
</div>
<div className="flex flex-col space-y-4">
<OrgRoleWorkspacePermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
/>
</div>
</div>
<div className="flex items-center space-x-4 mt-12">
<Button
type="submit"
isDisabled={isSubmitting || isNonEditable || !isDirty}
isLoading={isSubmitting}
>
{isNewRole ? "Create Role" : "Save Role"}
</Button>
<Button onClick={onGoBack} variant="outline_bg">
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,93 @@
/* eslint-disable no-param-reassign */
import * as yup from "yup";
import { TPermission } from "@app/hooks/api/roles/types";
const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const;
export const formSchema = yup.object({
name: yup.string().required().label("Name"),
description: yup.string(),
slug: yup.string().required().label("Slug"),
permissions: yup.object({
workspace: yup.lazy((val) =>
yup.object(
Object.fromEntries(
Object.entries(val || {}).map(([k]) => [
k,
yup.object({
read: yup.bool(),
edit: yup.bool(),
delete: yup.bool(),
create: yup.bool()
})
])
)
)
)
})
});
export type TFormSchema = yup.InferType<typeof formSchema>;
const api2FormWorkspace = (
formVal: TFormSchema["permissions"]["workspace"],
permission: TPermission
) => {
if (permission.subject !== "workspace") return;
const isCustomRule = Boolean(permission?.condition?.id);
if (isCustomRule && !formVal?.custom) {
formVal.custom = { read: true, edit: true, delete: true, create: true };
}
const workspaceId = permission?.condition?.id || "all";
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: {}
};
permissions.forEach((permission) => {
api2FormWorkspace(formVal?.workspace, permission);
});
return formVal;
};
const form2ApiWorkspace = (
permissions: TPermission[],
workspace: TFormSchema["permissions"]["workspace"]
) => {
const isFullAccess = PERMISSION_ACTIONS.every((action) => workspace?.all?.[action]);
PERMISSION_ACTIONS.forEach((action) => {
if (workspace?.all?.[action]) permissions.push({ action, subject: "workspace" });
});
if (!isFullAccess) {
Object.keys(workspace)
.filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter
.forEach((workspaceId) => {
const actions = Object.keys(workspace[workspaceId]) as ["read", "edit", "create", "delete"];
actions.forEach((action) => {
// if not full access for an action
if (!workspace?.all?.[action] && workspace[workspaceId][action]) {
permissions.push({ action, subject: "workspace", condition: { id: workspaceId } });
}
});
});
}
};
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TPermission[] = [];
// easy deep copy
if (formVal?.workspace)
form2ApiWorkspace(permissions, JSON.parse(JSON.stringify(formVal.workspace)));
return permissions;
};
@@ -0,0 +1,205 @@
import { useMemo } from "react";
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 {
Checkbox,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TFormSchema } from "./OrgRoleModifySection.utils";
type Props = {
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum WorkspacePermission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: Props) => {
const { workspaces } = useWorkspace();
const customWorkspaceRule = useWatch({
control,
name: "permissions.workspace.custom"
});
const isCustom = Boolean(customWorkspaceRule);
const allWorkspaceRule = useWatch({ control, name: "permissions.workspace.all" });
const selectedWsTopVal = useMemo(() => {
const { read, delete: del, edit, create } = allWorkspaceRule || {};
if (read && del && edit && create) return WorkspacePermission.FullAccess;
if (read) return WorkspacePermission.ReadOnly;
return WorkspacePermission.NoAccess;
}, [allWorkspaceRule]);
const handleTopLevelPermissionChange = (val: WorkspacePermission) => {
switch (val) {
case WorkspacePermission.NoAccess:
setValue("permissions.workspace", {}, { shouldDirty: true });
break;
case WorkspacePermission.FullAccess:
setValue(
"permissions.workspace",
{ all: { read: true, edit: true, create: true, delete: true } },
{ shouldDirty: true }
);
break;
case WorkspacePermission.ReadOnly:
setValue(
"permissions.workspace",
{ all: { read: true, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
default:
setValue(
"permissions.workspace",
{ custom: { read: false, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
}
};
return (
<div className="px-10 py-6 bg-mineshaft-800 rounded-md">
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faClipboardList} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Projects</div>
<div className="text-xs font-light">User project access control</div>
</div>
<div>
<Select
defaultValue={WorkspacePermission.NoAccess}
isDisabled={isNonEditable}
value={isCustom ? WorkspacePermission.Custom : selectedWsTopVal}
onValueChange={handleTopLevelPermissionChange}
>
<SelectItem value={WorkspacePermission.NoAccess}>No Access</SelectItem>
<SelectItem value={WorkspacePermission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={WorkspacePermission.FullAccess}>Full Access</SelectItem>
<SelectItem value={WorkspacePermission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "auto" : 0 }}
className="overflow-hidden"
>
<TableContainer className="border-mineshaft-500 mt-6">
<Table>
<THead>
<Tr>
<Th />
<Th className="text-center">Read</Th>
<Th className="text-center">Create</Th>
<Th className="text-center">Edit</Th>
<Th className="text-center">Delete</Th>
</Tr>
</THead>
<TBody>
{isCustom &&
workspaces?.map(({ name, _id: id }) => (
<Tr key={`custom-role-ws-${name}`}>
<Td>{name}</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.read`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${id}.read`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.create`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.workspace.${id}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.edit`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.workspace.${id}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
defaultValue={false}
name={`permissions.workspace.${id}.delete`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${id}.delete`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</motion.div>
</div>
);
};
@@ -0,0 +1 @@
export { OrgRoleModifySection } from "./OrgRoleModifySection";
@@ -0,0 +1,46 @@
import { motion } from "framer-motion";
import { usePopUp } from "@app/hooks";
import { TRole } from "~/hooks/api/roles/types";
import { OrgRoleModifySection } from "./OrgRoleModifySection";
import { OrgRoleTable } from "./OrgRoleTable";
type Props = {
roles?: TRole[];
isRolesLoading?: boolean;
};
export const OrgRoleTabSection = ({ 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 }}
>
<OrgRoleModifySection
role={popUp.editRole.data as TRole}
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 }}
>
<OrgRoleTable
roles={roles}
isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/>
</motion.div>
);
};
@@ -0,0 +1,140 @@
import { useState } from "react";
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import {
Button,
DeleteActionModal,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useDeleteRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider";
import { usePopUp } from "~/hooks/usePopUp";
type Props = {
isRolesLoading?: boolean;
roles?: TRole[];
onSelectRole: (role?: TRole) => void;
};
export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props) => {
const [searchRoles, setSearchRoles] = useState("");
const { currentOrg } = useOrganization();
const orgId = currentOrg?._id || "";
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
const { mutateAsync: deleteRole } = useDeleteRole();
const handleRoleDelete = async () => {
const { _id: id } = popUp?.deleteRole?.data as TRole;
try {
await deleteRole({
orgId,
id
});
createNotification({ type: "success", text: "Successfully removed the role" });
handlePopUpClose("deleteRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchRoles}
onChange={(e) => setSearchRoles(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search roles..."
/>
</div>
<Button leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => onSelectRole()}>
Add Role
</Button>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>Created At</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
const { _id: id, name, createdAt, slug } = role;
const isNonMutatable = ["owner", "admin", "member"].includes(slug);
return (
<Tr key={id}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
{createdAt ? format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa") : "-"}
</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"}
>
<IconButton
ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain"
isDisabled={isNonMutatable}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteRole?.data as TRole)?.name || " "
} role?`}
deleteKey={(popUp?.deleteRole?.data as TRole)?.slug || ""}
onClose={() => handlePopUpClose("deleteRole")}
onDeleteApproved={handleRoleDelete}
/>
</div>
);
};
@@ -0,0 +1 @@
export { OrgRoleTabSection } from "./OrgRoleTabSection";