Created the new permissions as an example

This commit is contained in:
Tiago Ribeiro
2025-02-06 18:48:31 +00:00
parent 63604b68e2
commit 36afde8aa4
3 changed files with 368 additions and 360 deletions

View File

@@ -4,64 +4,63 @@ import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows"; import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { Module, ModuleTypeLabels } from "@/interfaces"; import {useAllowedEntities, useAllowedEntitiesSomePermissions, useEntityPermission} from "@/hooks/useEntityPermissions";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow"; import {Module, ModuleTypeLabels} from "@/interfaces";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import {ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel} from "@/interfaces/approval.workflow";
import { User } from "@/interfaces/user"; import {Entity, EntityWithRoles} from "@/interfaces/entity";
import { sessionOptions } from "@/lib/session"; import {User} from "@/interfaces/user";
import { redirect, serialize } from "@/utils"; import {sessionOptions} from "@/lib/session";
import { requestUser } from "@/utils/api"; import {redirect, serialize} from "@/utils";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be"; import {requestUser} from "@/utils/api";
import { getEntities } from "@/utils/entities.be"; import {getApprovalWorkflows} from "@/utils/approval.workflows.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
import { getSpecificUsers } from "@/utils/users.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import {findAllowedEntities} from "@/utils/permissions";
import {getSpecificUsers} from "@/utils/users.be";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { BsTrash } from "react-icons/bs"; import {BsTrash} from "react-icons/bs";
import { FaRegEdit } from "react-icons/fa"; import {FaRegEdit} from "react-icons/fa";
import { IoIosAddCircleOutline } from "react-icons/io"; import {IoIosAddCircleOutline} from "react-icons/io";
import { toast, ToastContainer } from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
const columnHelper = createColumnHelper<ApprovalWorkflow>(); const columnHelper = createColumnHelper<ApprovalWorkflow>();
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) return redirect("/");
return redirect("/")
const workflows = await getApprovalWorkflows("active-workflows"); const workflows = await getApprovalWorkflows("active-workflows");
const allAssigneeIds: string[] = [ const allAssigneeIds: string[] = [...new Set(workflows.map((workflow) => workflow.steps.map((step) => step.assignees).flat()).flat())];
...new Set(
workflows const entities = await getEntitiesWithRoles(user.entities.map((entity) => entity.id));
.map(workflow => workflow.steps const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
.map(step => step.assignees)
.flat()
).flat()
)
];
return { return {
props: serialize({ props: serialize({
user, user,
initialWorkflows: workflows, initialWorkflows: workflows,
workflowsAssignees: await getSpecificUsers(allAssigneeIds), workflowsAssignees: await getSpecificUsers(allAssigneeIds),
userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)), userEntitiesWithLabel: entities,
}), }),
}; };
}, sessionOptions); }, sessionOptions);
const StatusClassNames: { [key in ApprovalWorkflowStatus]: string } = { const StatusClassNames: {[key in ApprovalWorkflowStatus]: string} = {
approved: "bg-green-100 text-green-800 border border-green-300 before:content-[''] before:w-2 before:h-2 before:bg-green-500 before:rounded-full before:inline-block before:mr-2", approved:
pending: "bg-orange-100 text-orange-800 border border-orange-300 before:content-[''] before:w-2 before:h-2 before:bg-orange-500 before:rounded-full before:inline-block before:mr-2", "bg-green-100 text-green-800 border border-green-300 before:content-[''] before:w-2 before:h-2 before:bg-green-500 before:rounded-full before:inline-block before:mr-2",
rejected: "bg-red-100 text-red-800 border border-red-300 before:content-[''] before:w-2 before:h-2 before:bg-red-500 before:rounded-full before:inline-block before:mr-2", pending:
"bg-orange-100 text-orange-800 border border-orange-300 before:content-[''] before:w-2 before:h-2 before:bg-orange-500 before:rounded-full before:inline-block before:mr-2",
rejected:
"bg-red-100 text-red-800 border border-red-300 before:content-[''] before:w-2 before:h-2 before:bg-red-500 before:rounded-full before:inline-block before:mr-2",
}; };
type CustomStatus = ApprovalWorkflowStatus | undefined; type CustomStatus = ApprovalWorkflowStatus | undefined;
@@ -86,15 +85,14 @@ const STATUS_OPTIONS = [
]; ];
interface Props { interface Props {
user: User, user: User;
initialWorkflows: ApprovalWorkflow[], initialWorkflows: ApprovalWorkflow[];
workflowsAssignees: User[], workflowsAssignees: User[];
userEntitiesWithLabel: Entity[], userEntitiesWithLabel: EntityWithRoles[];
} }
export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) { export default function ApprovalWorkflows({user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel}: Props) {
const {workflows, reload} = useApprovalWorkflows();
const { workflows, reload } = useApprovalWorkflows();
const currentWorkflows = workflows || initialWorkflows; const currentWorkflows = workflows || initialWorkflows;
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]); const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
@@ -103,9 +101,12 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined); const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>(""); const [nameFilter, setNameFilter] = useState<string>("");
const allowedEntities = useAllowedEntities(user, userEntitiesWithLabel, "view_workflows");
const allowedSomeEntities = useAllowedEntitiesSomePermissions(user, userEntitiesWithLabel, ["view_workflows", "create_workflow"]);
const ENTITY_OPTIONS = [ const ENTITY_OPTIONS = [
...userEntitiesWithLabel ...userEntitiesWithLabel
.map(entity => ({ .map((entity) => ({
label: entity.label, label: entity.label,
value: entity.id, value: entity.id,
filter: (x: ApprovalWorkflow) => x.entityId === entity.id, filter: (x: ApprovalWorkflow) => x.entityId === entity.id,
@@ -131,18 +132,16 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
} }
if (nameFilter.trim() !== "") { if (nameFilter.trim() !== "") {
const nameFilterFunction = (workflow: ApprovalWorkflow) => const nameFilterFunction = (workflow: ApprovalWorkflow) => workflow.name.toLowerCase().includes(nameFilter.toLowerCase());
workflow.name.toLowerCase().includes(nameFilter.toLowerCase());
filters.push(nameFilterFunction); filters.push(nameFilterFunction);
} }
// Apply all filters // Apply all filters
const filtered = currentWorkflows.filter(workflow => filters.every(filterFn => filterFn(workflow))); const filtered = currentWorkflows.filter((workflow) => filters.every((filterFn) => filterFn(workflow)));
setFilteredWorkflows(filtered); setFilteredWorkflows(filtered);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWorkflows, statusFilter, entityFilter, nameFilter]); }, [currentWorkflows, statusFilter, entityFilter, nameFilter]);
const handleNameFilterChange = (name: ApprovalWorkflow["name"]) => { const handleNameFilterChange = (name: ApprovalWorkflow["name"]) => {
setNameFilter(name); setNameFilter(name);
}; };
@@ -166,17 +165,13 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
} }
return; return;
}) });
}; };
const columns = [ const columns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: "EXAM NAME", header: "EXAM NAME",
cell: (info) => ( cell: (info) => <span className="font-medium">{info.getValue()}</span>,
<span className="font-medium">
{info.getValue()}
</span>
),
}), }),
columnHelper.accessor("modules", { columnHelper.accessor("modules", {
header: "MODULES", header: "MODULES",
@@ -185,8 +180,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
{info.getValue().map((module: Module, index: number) => ( {info.getValue().map((module: Module, index: number) => (
<span <span
key={index} key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900" className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900">
>
{ModuleTypeLabels[module]} {ModuleTypeLabels[module]}
</span> </span>
))} ))}
@@ -196,18 +190,18 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
columnHelper.accessor("status", { columnHelper.accessor("status", {
header: "STATUS", header: "STATUS",
cell: (info) => ( cell: (info) => (
<span className={clsx("inline-block rounded-full px-3 py-1 text-sm font-medium text-left w-[110px]", StatusClassNames[info.getValue()])}> <span
className={clsx(
"inline-block rounded-full px-3 py-1 text-sm font-medium text-left w-[110px]",
StatusClassNames[info.getValue()],
)}>
{ApprovalWorkflowStatusLabel[info.getValue()]} {ApprovalWorkflowStatusLabel[info.getValue()]}
</span> </span>
), ),
}), }),
columnHelper.accessor("entityId", { columnHelper.accessor("entityId", {
header: "ENTITY", header: "ENTITY",
cell: (info) => ( cell: (info) => <span className="font-medium">{userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label}</span>,
<span className="font-medium">
{userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label}
</span>
),
}), }),
columnHelper.accessor("steps", { columnHelper.accessor("steps", {
id: "currentAssignees", id: "currentAssignees",
@@ -229,8 +223,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
{assignees?.map((assigneeName: string, index: number) => ( {assignees?.map((assigneeName: string, index: number) => (
<span <span
key={index} key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-gray-100 border border-gray-300 text-gray-900" className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-gray-100 border border-gray-300 text-gray-900">
>
{assigneeName} {assigneeName}
</span> </span>
))} ))}
@@ -248,9 +241,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
return ( return (
<span className="font-medium"> <span className="font-medium">
{currentStep && !rejected {currentStep && !rejected ? `Step ${currentStep.stepNumber}: ${StepTypeLabel[currentStep.stepType]}` : "Completed"}
? `Step ${currentStep.stepNumber}: ${StepTypeLabel[currentStep.stepType]}`
: "Completed"}
</span> </span>
); );
}, },
@@ -258,7 +249,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
columnHelper.accessor("steps", { columnHelper.accessor("steps", {
header: "ACTIONS", header: "ACTIONS",
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({row}) => {
const steps = row.original.steps; const steps = row.original.steps;
const currentStep = steps.find((step) => !step.completed); const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected); const rejected = steps.find((step) => step.rejected);
@@ -271,8 +262,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteApprovalWorkflow(row.original._id?.toString(), row.original.name); deleteApprovalWorkflow(row.original._id?.toString(), row.original.name);
}} }}>
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button> </button>
@@ -281,15 +271,14 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
data-tip="Edit" data-tip="Edit"
href={`/approval-workflows/${row.original._id?.toString()}/edit`} href={`/approval-workflows/${row.original._id?.toString()}/edit`}
className="cursor-pointer tooltip" className="cursor-pointer tooltip">
>
<FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</Link> </Link>
)} )}
</div> </div>
); );
}, },
}) }),
]; ];
const table = useReactTable({ const table = useReactTable({
@@ -314,11 +303,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
<div className="flex flex-row"> <div className="flex flex-row">
<Link href={"/approval-workflows/create"}> <Link href={"/approval-workflows/create"}>
<Button <Button color="purple" variant="solid" className="min-w-fit text-lg font-medium flex items-center gap-2 text-left">
color="purple"
variant="solid"
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
<IoIosAddCircleOutline className="size-6" /> <IoIosAddCircleOutline className="size-6" />
Configure Workflows Configure Workflows
</Button> </Button>
@@ -328,13 +313,7 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Name</label> <label className="text-mti-gray-dim text-base font-normal">Name</label>
<Input <Input name="nameFilter" type="text" value={nameFilter} onChange={handleNameFilterChange} placeholder="Filter by name..." />
name="nameFilter"
type="text"
value={nameFilter}
onChange={handleNameFilterChange}
placeholder="Filter by name..."
/>
</div> </div>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Status</label> <label className="text-mti-gray-dim text-base font-normal">Status</label>
@@ -361,21 +340,13 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
<Tip text="An exam submission will instantiate the approval workflow configured for the exam author. The exam will be valid only when all the steps of the workflow have been approved."></Tip> <Tip text="An exam submission will instantiate the approval workflow configured for the exam author. The exam will be valid only when all the steps of the workflow have been approved."></Tip>
<div className="px-6 pb-4 bg-mti-purple-ultralight rounded-2xl border-2 border-mti-purple-light border-opacity-40"> <div className="px-6 pb-4 bg-mti-purple-ultralight rounded-2xl border-2 border-mti-purple-light border-opacity-40">
<table <table className="w-full table-auto border-separate border-spacing-y-2" style={{tableLayout: "auto"}}>
className="w-full table-auto border-separate border-spacing-y-2"
style={{ tableLayout: "auto" }}
>
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark"> <th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark">
{header.isPlaceholder {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -385,10 +356,9 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr <tr
key={row.id} key={row.id}
onClick={() => window.location.href = `/approval-workflows/${row.original._id?.toString()}`} onClick={() => (window.location.href = `/approval-workflows/${row.original._id?.toString()}`)}
style={{ cursor: "pointer" }} style={{cursor: "pointer"}}
className="bg-purple-50" className="bg-purple-50">
>
{row.getVisibleCells().map((cell, cellIndex) => { {row.getVisibleCells().map((cell, cellIndex) => {
const lastCellIndex = row.getVisibleCells().length - 1; const lastCellIndex = row.getVisibleCells().length - 1;
@@ -409,7 +379,6 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</> </>

View File

@@ -107,6 +107,13 @@ const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
{label: "Archive Assignments", key: "archive_assignment"}, {label: "Archive Assignments", key: "archive_assignment"},
]; ];
const WORKFLOW_MANAGEMENT: PermissionLayout[] = [
{label: "View Workflows", key: "view_workflows"},
{label: "Create Workflow", key: "create_workflow"},
{label: "Edit Workflow", key: "edit_workflow"},
{label: "Delete Workflow", key: "delete_workflow"},
];
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return redirect("/login"); if (!user) return redirect("/login");
@@ -399,6 +406,30 @@ export default function EntityRole({user, entity, role, userCount, disableEdit}:
))} ))}
</div> </div>
</div> </div>
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<b>Workflow Management</b>
<Checkbox
disabled={!canEditPermissions || disableEdit}
isChecked={mapBy(WORKFLOW_MANAGEMENT, "key").every((k) => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(WORKFLOW_MANAGEMENT, "key").filter(enableCheckbox))}>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{WORKFLOW_MANAGEMENT.map(({label, key}) => (
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
</div>
</div>
</section> </section>
</section> </section>
</> </>

View File

@@ -67,7 +67,11 @@ export type RolePermission =
| "pay_entity" | "pay_entity"
| "view_payment_record" | "view_payment_record"
| "view_approval_workflows" | "view_approval_workflows"
| "update_exam_privacy"; | "update_exam_privacy"
| "view_workflows"
| "create_workflow"
| "edit_workflow"
| "delete_workflow";
export const DEFAULT_PERMISSIONS: RolePermission[] = [ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students", "view_students",
@@ -149,4 +153,8 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
"pay_entity", "pay_entity",
"view_payment_record", "view_payment_record",
"update_exam_privacy", "update_exam_privacy",
"create_workflow",
"view_workflows",
"edit_workflow",
"delete_workflow",
]; ];