Merged in workflow-permissions (pull request #146)

Workflow permissions

Approved-by: Tiago Ribeiro
This commit is contained in:
João Correia
2025-02-07 15:43:34 +00:00
committed by Tiago Ribeiro
10 changed files with 416 additions and 368 deletions

View File

@@ -17,7 +17,7 @@ async function put(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }

View File

@@ -3,6 +3,8 @@ import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { deleteApprovalWorkflow, getApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be"; import { deleteApprovalWorkflow, getApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { getEntityWithRoles } from "@/utils/entities.be";
import { doesEntityAllow } from "@/utils/permissions";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@@ -19,20 +21,30 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }
const { id } = req.query as { id?: string }; const { id } = req.query as { id: string };
const workflow = await getApprovalWorkflow("active-workflows", id);
if (id) return res.status(200).json(await deleteApprovalWorkflow("active-workflows", id)); if (!workflow) return res.status(404).json({ ok: false });
const entity = await getEntityWithRoles(workflow.entityId);
if (!entity) return res.status(404).json({ ok: false });
if (!doesEntityAllow(user, entity, "delete_workflow") && !["admin", "developer"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
return res.status(200).json(await deleteApprovalWorkflow("active-workflows", id));
} }
async function put(req: NextApiRequest, res: NextApiResponse) { async function put(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }
@@ -50,7 +62,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }

View File

@@ -22,7 +22,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }

View File

@@ -15,7 +15,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "corporate", "mastercorporate"].includes(user.type)) { if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }

View File

@@ -6,10 +6,12 @@ import Layout from "@/components/High/Layout";
import { ApprovalWorkflow, EditableApprovalWorkflow, EditableWorkflowStep, getUserTypeLabelShort } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow, EditableApprovalWorkflow, EditableWorkflowStep, getUserTypeLabelShort } from "@/interfaces/approval.workflow";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user"; import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils"; import { findBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be"; import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { getEntityWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow } from "@/utils/permissions";
import { getEntityUsers } from "@/utils/users.be"; import { getEntityUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import { LayoutGroup, motion } from "framer-motion"; import { LayoutGroup, motion } from "framer-motion";
@@ -30,9 +32,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const { id } = params as { id: string }; const { id } = params as { id: string };
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id); const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id);
if (!workflow) return redirect("/approval-workflows");
if (!workflow) const entityWithRole = await getEntityWithRoles(workflow.entityId);
return redirect("/approval-workflows") if (!entityWithRole) return redirect("/approval-workflows");
if (!doesEntityAllow(user, entityWithRole, "edit_workflow")) return redirect("/approval-workflows");
return { return {
props: serialize({ props: serialize({

View File

@@ -14,8 +14,10 @@ import useExamStore from "@/stores/exam";
import { redirect, serialize } from "@/utils"; import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getApprovalWorkflow } from "@/utils/approval.workflows.be"; import { getApprovalWorkflow } from "@/utils/approval.workflows.be";
import { getEntityWithRoles } from "@/utils/entities.be";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow } from "@/utils/permissions";
import { getSpecificUsers, getUser } from "@/utils/users.be"; import { getSpecificUsers, getUser } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
@@ -46,8 +48,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id); const workflow: ApprovalWorkflow | null = await getApprovalWorkflow("active-workflows", id);
if (!workflow) if (!workflow) return redirect("/approval-workflows")
return redirect("/approval-workflows")
const entityWithRole = await getEntityWithRoles(workflow.entityId);
if (!entityWithRole) return redirect("/approval-workflows");
if (!doesEntityAllow(user, entityWithRole, "view_workflows")) return redirect("/approval-workflows");
const allAssigneeIds: string[] = [ const allAssigneeIds: string[] = [
...new Set( ...new Set(

View File

@@ -1,6 +1,5 @@
import Tip from "@/components/ApprovalWorkflows/Tip"; import Tip from "@/components/ApprovalWorkflows/Tip";
import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm"; import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; 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";
@@ -8,11 +7,13 @@ import { ApprovalWorkflow, EditableApprovalWorkflow } from "@/interfaces/approva
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user"; import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be"; import { getApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
import { getEntities } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { findAllowedEntities } from "@/utils/permissions";
import { isAdmin } from "@/utils/users";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
@@ -31,10 +32,12 @@ 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", "corporate", "mastercorporate"].includes(user.type)) if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/") return redirect("/")
const userEntitiesWithLabel = await getEntities(user.entities.map(entity => entity.id)); const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const userEntitiesWithLabel = findAllowedEntities(user, entities, "configure_workflows");
const allConfiguredWorkflows = await getApprovalWorkflowsByEntities("configured-workflows", userEntitiesWithLabel.map(entity => entity.id)); const allConfiguredWorkflows = await getApprovalWorkflowsByEntities("configured-workflows", userEntitiesWithLabel.map(entity => entity.id));

View File

@@ -4,16 +4,19 @@ 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 { useAllowedEntities, useAllowedEntitiesSomePermissions, useEntityPermission } from "@/hooks/useEntityPermissions";
import { Module, ModuleTypeLabels } from "@/interfaces"; import { Module, ModuleTypeLabels } from "@/interfaces";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be"; import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { getEntities } from "@/utils/entities.be"; import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
import { isAdmin } from "@/utils/users";
import { getSpecificUsers } from "@/utils/users.be"; import { getSpecificUsers } from "@/utils/users.be";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
@@ -21,397 +24,378 @@ 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 { useRouter } from "next/router";
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>();
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
return redirect("/")
const workflows = await getApprovalWorkflows("active-workflows");
const allAssigneeIds: string[] = [
...new Set(
workflows
.map(workflow => workflow.steps
.map(step => step.assignees)
.flat()
).flat()
)
];
return {
props: serialize({
user,
initialWorkflows: workflows,
workflowsAssignees: await getSpecificUsers(allAssigneeIds),
userEntitiesWithLabel: await getEntities(user.entities.map(entity => entity.id)),
}),
};
}, 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;
type CustomEntity = EntityWithRoles["id"] | undefined; type CustomEntity = EntityWithRoles["id"] | undefined;
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ {
label: "Approved", label: "Approved",
value: "approved", value: "approved",
filter: (x: ApprovalWorkflow) => x.status === "approved", filter: (x: ApprovalWorkflow) => x.status === "approved",
}, },
{ {
label: "Pending", label: "Pending",
value: "pending", value: "pending",
filter: (x: ApprovalWorkflow) => x.status === "pending", filter: (x: ApprovalWorkflow) => x.status === "pending",
}, },
{ {
label: "Rejected", label: "Rejected",
value: "rejected", value: "rejected",
filter: (x: ApprovalWorkflow) => x.status === "rejected", filter: (x: ApprovalWorkflow) => x.status === "rejected",
}, },
]; ];
const columnHelper = createColumnHelper<ApprovalWorkflow>();
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) return redirect("/");
const workflows = await getApprovalWorkflows("active-workflows");
const allAssigneeIds: string[] = [
...new Set(
workflows
.map(workflow => workflow.steps
.map(step => step.assignees)
.flat()
).flat()
)
];
const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
return {
props: serialize({
user,
initialWorkflows: workflows,
workflowsAssignees: await getSpecificUsers(allAssigneeIds),
userEntitiesWithLabel: allowedEntities,
}),
};
}, sessionOptions);
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 currentWorkflows = workflows || initialWorkflows;
const { workflows, reload } = useApprovalWorkflows(); const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
const currentWorkflows = workflows || initialWorkflows;
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]); const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined);
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined); const router = useRouter();
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>("");
const ENTITY_OPTIONS = [ /* const allowedEntities = useAllowedEntities(user, userEntitiesWithLabel, "view_workflows");
...userEntitiesWithLabel const allowedSomeEntities = useAllowedEntitiesSomePermissions(user, userEntitiesWithLabel, ["view_workflows", "create_workflow"]); */
.map(entity => ({
label: entity.label,
value: entity.id,
filter: (x: ApprovalWorkflow) => x.entityId === entity.id,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
];
useEffect(() => { const ENTITY_OPTIONS = [
const filters: Array<(workflow: ApprovalWorkflow) => boolean> = []; ...userEntitiesWithLabel
.map((entity) => ({
label: entity.label,
value: entity.id,
filter: (x: ApprovalWorkflow) => x.entityId === entity.id,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
];
if (statusFilter && statusFilter !== undefined) { useEffect(() => {
const statusOption = STATUS_OPTIONS.find((x) => x.value === statusFilter); const filters: Array<(workflow: ApprovalWorkflow) => boolean> = [];
if (statusOption && statusOption.filter) {
filters.push(statusOption.filter);
}
}
if (entityFilter && entityFilter !== undefined) { if (statusFilter && statusFilter !== undefined) {
const entityOption = ENTITY_OPTIONS.find((x) => x.value === entityFilter); const statusOption = STATUS_OPTIONS.find((x) => x.value === statusFilter);
if (entityOption && entityOption.filter) { if (statusOption && statusOption.filter) {
filters.push(entityOption.filter); filters.push(statusOption.filter);
} }
} }
if (nameFilter.trim() !== "") { if (entityFilter && entityFilter !== undefined) {
const nameFilterFunction = (workflow: ApprovalWorkflow) => const entityOption = ENTITY_OPTIONS.find((x) => x.value === entityFilter);
workflow.name.toLowerCase().includes(nameFilter.toLowerCase()); if (entityOption && entityOption.filter) {
filters.push(nameFilterFunction); filters.push(entityOption.filter);
} }
}
// Apply all filters if (nameFilter.trim() !== "") {
const filtered = currentWorkflows.filter(workflow => filters.every(filterFn => filterFn(workflow))); const nameFilterFunction = (workflow: ApprovalWorkflow) => workflow.name.toLowerCase().includes(nameFilter.toLowerCase());
setFilteredWorkflows(filtered); filters.push(nameFilterFunction);
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [currentWorkflows, statusFilter, entityFilter, nameFilter]);
// Apply all filters
const filtered = currentWorkflows.filter((workflow) => filters.every((filterFn) => filterFn(workflow)));
setFilteredWorkflows(filtered);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWorkflows, statusFilter, entityFilter, nameFilter]);
const handleNameFilterChange = (name: ApprovalWorkflow["name"]) => { const handleNameFilterChange = (name: ApprovalWorkflow["name"]) => {
setNameFilter(name); setNameFilter(name);
}; };
const deleteApprovalWorkflow = (id: string | undefined, name: string) => { const deleteApprovalWorkflow = (id: string | undefined, name: string) => {
if (id === undefined) return; if (id === undefined) return;
if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return; if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
axios axios
.delete(`/api/approval-workflows/${id}`) .delete(`/api/approval-workflows/${id}`)
.then(() => { .then(() => {
toast.success(`Successfully deleted ${name} Approval Workflow.`); toast.success(`Successfully deleted ${name} Approval Workflow.`);
reload(); reload();
}) })
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 403) {
toast.error("Approval Workflow not found!"); toast.error("You do not have permission to delete this Approval Workflow!");
} else if (reason.response.status === 403) { } else {
toast.error("You do not have permission to delete an Approval Workflow!"); toast.error("Something went wrong, please try again later.");
} else { }
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()} columnHelper.accessor("modules", {
</span> header: "MODULES",
), cell: (info) => (
}), <div className="flex flex-wrap gap-2">
columnHelper.accessor("modules", { {info.getValue().map((module: Module, index: number) => (
header: "MODULES", <span
cell: (info) => ( key={index}
<div className="flex flex-wrap gap-2"> className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900">
{info.getValue().map((module: Module, index: number) => ( {ModuleTypeLabels[module]}
<span </span>
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" </div>
> ),
{ModuleTypeLabels[module]} }),
</span> columnHelper.accessor("status", {
))} header: "STATUS",
</div> cell: (info) => (
), <span
}), className={clsx(
columnHelper.accessor("status", { "inline-block rounded-full px-3 py-1 text-sm font-medium text-left w-[110px]",
header: "STATUS", StatusClassNames[info.getValue()],
cell: (info) => ( )}>
<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) => <span className="font-medium">{userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label}</span>,
cell: (info) => ( }),
<span className="font-medium"> columnHelper.accessor("steps", {
{userEntitiesWithLabel.find((entity) => entity.id === info.getValue())?.label} id: "currentAssignees",
</span> header: "CURRENT ASSIGNEES",
), cell: (info) => {
}), const steps = info.row.original.steps;
columnHelper.accessor("steps", { const currentStep = steps.find((step) => !step.completed);
id: "currentAssignees", const rejected = steps.find((step) => step.rejected);
header: "CURRENT ASSIGNEES",
cell: (info) => {
const steps = info.row.original.steps;
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
if (rejected) return ""; if (rejected) return "";
const assignees = currentStep?.assignees.map((assigneeId) => { const assignees = currentStep?.assignees.map((assigneeId) => {
const assignee = workflowsAssignees.find((user) => user.id === assigneeId); const assignee = workflowsAssignees.find((user) => user.id === assigneeId);
return assignee?.name || "Unknown Assignee"; return assignee?.name || "Unknown Assignee";
}); });
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{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> ))}
))} </div>
</div> );
); },
}, }),
}), columnHelper.accessor("steps", {
columnHelper.accessor("steps", { id: "currentStep",
id: "currentStep", header: "CURRENT STEP",
header: "CURRENT STEP", cell: (info) => {
cell: (info) => { const steps = info.row.original.steps;
const steps = info.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);
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]}` </span>
: "Completed"} );
</span> },
); }),
}, columnHelper.accessor("steps", {
}), header: "ACTIONS",
columnHelper.accessor("steps", { id: "actions",
header: "ACTIONS", cell: ({ row }) => {
id: "actions", const steps = row.original.steps;
cell: ({ row }) => { const currentStep = steps.find((step) => !step.completed);
const steps = row.original.steps; const rejected = steps.find((step) => step.rejected);
const currentStep = steps.find((step) => !step.completed);
const rejected = steps.find((step) => step.rejected);
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
data-tip="Delete" data-tip="Delete"
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
onClick={(e) => { disabled={!doesEntityAllow(user, userEntitiesWithLabel.find(entity => entity.id === row.original.entityId)!, "delete_workflow")}
e.stopPropagation(); onClick={(e) => {
deleteApprovalWorkflow(row.original._id?.toString(), row.original.name); e.stopPropagation();
}} 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>
{currentStep && !rejected && ( {currentStep && !rejected && (
<Link <button
onClick={(e) => e.stopPropagation()} data-tip="Edit"
data-tip="Edit" className="cursor-pointer tooltip"
href={`/approval-workflows/${row.original._id?.toString()}/edit`} disabled={!doesEntityAllow(user, userEntitiesWithLabel.find(entity => entity.id === row.original.entityId)!, "edit_workflow")}
className="cursor-pointer tooltip" onClick={(e) => {
> e.stopPropagation();
<FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" /> router.push(`/approval-workflows/${row.original._id?.toString()}/edit`);
</Link> }}>
)} <FaRegEdit className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </button>
); )}
}, </div>
}) );
]; },
}),
];
const table = useReactTable({ const table = useReactTable({
data: filteredWorkflows, data: filteredWorkflows,
columns: columns, columns: columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
return ( return (
<> <>
<Head> <Head>
<title>Approval Workflows Panel | EnCoach</title> <title>Approval Workflows Panel | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<h1 className="text-2xl font-semibold">Approval Workflows</h1> <h1 className="text-2xl font-semibold">Approval Workflows</h1>
<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" <IoIosAddCircleOutline className="size-6" />
variant="solid" Configure Workflows
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left" </Button>
> </Link>
<IoIosAddCircleOutline className="size-6" /> </div>
Configure Workflows
</Button>
</Link>
</div>
<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" </div>
type="text" <div className="flex w-full flex-col gap-3">
value={nameFilter} <label className="text-mti-gray-dim text-base font-normal">Status</label>
onChange={handleNameFilterChange} <Select
placeholder="Filter by name..." options={STATUS_OPTIONS}
/> value={STATUS_OPTIONS.find((x) => x.value === statusFilter)}
</div> onChange={(value) => setStatusFilter((value?.value as ApprovalWorkflowStatus) ?? undefined)}
<div className="flex w-full flex-col gap-3"> isClearable
<label className="text-mti-gray-dim text-base font-normal">Status</label> placeholder="Filter by status..."
<Select />
options={STATUS_OPTIONS} </div>
value={STATUS_OPTIONS.find((x) => x.value === statusFilter)} <div className="flex w-full flex-col gap-3">
onChange={(value) => setStatusFilter((value?.value as ApprovalWorkflowStatus) ?? undefined)} <label className="text-mti-gray-dim text-base font-normal">Entity</label>
isClearable <Select
placeholder="Filter by status..." options={ENTITY_OPTIONS}
/> value={ENTITY_OPTIONS.find((x) => x.value === entityFilter)}
</div> onChange={(value) => setEntityFilter((value?.value as CustomEntity) ?? undefined)}
<div className="flex w-full flex-col gap-3"> isClearable
<label className="text-mti-gray-dim text-base font-normal">Entity</label> placeholder="Filter by entity..."
<Select />
options={ENTITY_OPTIONS} </div>
value={ENTITY_OPTIONS.find((x) => x.value === entityFilter)} </div>
onChange={(value) => setEntityFilter((value?.value as CustomEntity) ?? undefined)}
isClearable
placeholder="Filter by entity..."
/>
</div>
</div>
<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" <thead>
style={{ tableLayout: "auto" }} {table.getHeaderGroups().map((headerGroup) => (
> <tr key={headerGroup.id}>
<thead> {headerGroup.headers.map((header) => (
{table.getHeaderGroups().map((headerGroup) => ( <th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark">
<tr key={headerGroup.id}> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{headerGroup.headers.map((header) => ( </th>
<th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark"> ))}
{header.isPlaceholder </tr>
? null ))}
: flexRender( </thead>
header.column.columnDef.header, <tbody>
header.getContext() {table.getRowModel().rows.map((row) => (
)} <tr
</th> key={row.id}
))} onClick={() => (window.location.href = `/approval-workflows/${row.original._id?.toString()}`)}
</tr> style={{ cursor: "pointer" }}
))} className="bg-purple-50">
</thead> {row.getVisibleCells().map((cell, cellIndex) => {
<tbody> const lastCellIndex = row.getVisibleCells().length - 1;
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
onClick={() => window.location.href = `/approval-workflows/${row.original._id?.toString()}`}
style={{ cursor: "pointer" }}
className="bg-purple-50"
>
{row.getVisibleCells().map((cell, cellIndex) => {
const lastCellIndex = row.getVisibleCells().length - 1;
let cellClasses = "pl-3 pr-4 py-2 border-y-2 border-mti-purple-light border-opacity-60"; let cellClasses = "pl-3 pr-4 py-2 border-y-2 border-mti-purple-light border-opacity-60";
if (cellIndex === 0) { if (cellIndex === 0) {
cellClasses += " border-l-2 rounded-l-2xl"; cellClasses += " border-l-2 rounded-l-2xl";
} }
if (cellIndex === lastCellIndex) { if (cellIndex === lastCellIndex) {
cellClasses += " border-r-2 rounded-r-2xl"; cellClasses += " border-r-2 rounded-r-2xl";
} }
return ( return (
<td key={cellIndex} className={cellClasses}> <td key={cellIndex} className={cellClasses}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
); );
})} })}
</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: "Configure Workflows", key: "configure_workflows"},
{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"
| "configure_workflows"
| "edit_workflow"
| "delete_workflow";
export const DEFAULT_PERMISSIONS: RolePermission[] = [ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students", "view_students",
@@ -77,7 +81,6 @@ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_entity_roles", "view_entity_roles",
"view_statistics", "view_statistics",
"download_statistics_report", "download_statistics_report",
"view_approval_workflows",
]; ];
export const ADMIN_PERMISSIONS: RolePermission[] = [ export const ADMIN_PERMISSIONS: RolePermission[] = [
@@ -149,4 +152,8 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
"pay_entity", "pay_entity",
"view_payment_record", "view_payment_record",
"update_exam_privacy", "update_exam_privacy",
"configure_workflows",
"view_workflows",
"edit_workflow",
"delete_workflow",
]; ];