Merged develop into feature/ai-detection

This commit is contained in:
Tiago Ribeiro
2024-07-25 21:00:40 +00:00
35 changed files with 6709 additions and 4067 deletions

View File

@@ -33,8 +33,7 @@ export default function Layout({user, children, className, navDisabled = false,
focusMode={focusMode} focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden" className="-md:hidden"
userType={user.type} user={user}
userId={user.id}
/> />
<div <div
className={clsx( className={clsx(

View File

@@ -16,6 +16,7 @@ import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { checkAccess } from "@/utils/permissions";
interface Props { interface Props {
user: User; user: User;
@@ -137,13 +138,13 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
options={[ options={[
{ value: "me", label: "Assign to me" }, { value: "me", label: "Assign to me" },
...users ...users
.filter((x) => ["admin", "developer", "agent"].includes(x.type)) .filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({ .map((u) => ({
value: u.id, value: u.id,
label: `${u.name} - ${u.email}`, label: `${u.name} - ${u.email}`,
})), })),
]} ]}
disabled={user.type === "agent"} disabled={checkAccess(user, ["agent"])}
value={ value={
assignedTo assignedTo
? { ? {

View File

@@ -1,160 +1,215 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {Dialog, Transition} from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Fragment} from "react"; import { Fragment } from "react";
import {BsXLg} from "react-icons/bs"; import { BsXLg } from "react-icons/bs";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
path: string; path: string;
user: User; user: User;
disableNavigation?: boolean; disableNavigation?: boolean;
} }
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) { export default function MobileMenu({
const router = useRouter(); isOpen,
onClose,
path,
user,
disableNavigation,
}: Props) {
const router = useRouter();
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}> <Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0"
<div className="fixed inset-0 bg-black bg-opacity-25" /> >
</Transition.Child> <div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center text-center"> <div className="flex min-h-full items-center justify-center text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95"
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all"> >
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"> <Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
<Link href={disableNavigation ? "" : "/"}> <Dialog.Title
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} /> as="header"
</Link> className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
<div className="cursor-pointer" onClick={onClose} tabIndex={0}> >
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} /> <Link href={disableNavigation ? "" : "/"}>
</div> <Image
</Dialog.Title> src="/logo_title.png"
<div className="flex h-full flex-col gap-6 px-8 text-lg"> alt="EnCoach logo"
<Link width={69}
href={disableNavigation ? "" : "/"} height={69}
className={clsx( />
"w-fit transition duration-300 ease-in-out", </Link>
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <div
)}> className="cursor-pointer"
Dashboard onClick={onClose}
</Link> tabIndex={0}
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && ( >
<> <BsXLg
<Link className="text-mti-purple-light text-2xl"
href={disableNavigation ? "" : "/exam"} onClick={onClose}
className={clsx( />
"w-fit transition duration-300 ease-in-out", </div>
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", </Dialog.Title>
)}> <div className="flex h-full flex-col gap-6 px-8 text-lg">
Exams <Link
</Link> href={disableNavigation ? "" : "/"}
<Link className={clsx(
href={disableNavigation ? "" : "/exercises"} "w-fit transition duration-300 ease-in-out",
className={clsx( path === "/" &&
"w-fit transition duration-300 ease-in-out", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
path === "/exercises" && )}
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", >
)}> Dashboard
Exercises </Link>
</Link> {checkAccess(user, ["student", "teacher", "developer"]) && (
</> <>
)} <Link
<Link href={disableNavigation ? "" : "/exam"}
href={disableNavigation ? "" : "/stats"} className={clsx(
className={clsx( "w-fit transition duration-300 ease-in-out",
"w-fit transition duration-300 ease-in-out", path === "/exam" &&
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}> )}
Stats >
</Link> Exams
<Link </Link>
href={disableNavigation ? "" : "/record"} <Link
className={clsx( href={disableNavigation ? "" : "/exercises"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)}> path === "/exercises" &&
Record "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
</Link> )}
{["admin", "developer", "agent", "corporate"].includes(user.type) && ( >
<Link Exercises
href={disableNavigation ? "" : "/payment-record"} </Link>
className={clsx( </>
"w-fit transition duration-300 ease-in-out", )}
path === "/payment-record" && <Link
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", href={disableNavigation ? "" : "/stats"}
)}> className={clsx(
Payment Record "w-fit transition duration-300 ease-in-out",
</Link> path === "/stats" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
{["admin", "developer", "corporate", "teacher"].includes(user.type) && ( )}
<Link >
href={disableNavigation ? "" : "/settings"} Stats
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", <Link
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", href={disableNavigation ? "" : "/record"}
)}> className={clsx(
Settings "w-fit transition duration-300 ease-in-out",
</Link> path === "/record" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
{["admin", "developer", "agent"].includes(user.type) && ( )}
<Link >
href={disableNavigation ? "" : "/tickets"} Record
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", {checkAccess(user, [
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "admin",
)}> "developer",
Tickets "agent",
</Link> "corporate",
)} "mastercorporate",
<Link ]) && (
href={disableNavigation ? "" : "/profile"} <Link
className={clsx( href={disableNavigation ? "" : "/payment-record"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)}> path === "/payment-record" &&
Profile "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
</Link> )}
>
Payment Record
</Link>
)}
{checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
]) && (
<Link
href={disableNavigation ? "" : "/settings"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/settings" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Settings
</Link>
)}
{checkAccess(user, ["admin", "developer", "agent"]) && (
<Link
href={disableNavigation ? "" : "/tickets"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/tickets" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Tickets
</Link>
)}
<Link
href={disableNavigation ? "" : "/profile"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/profile" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Profile
</Link>
<span <span
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")} className={clsx(
onClick={logout}> "w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
Logout )}
</span> onClick={logout}
</div> >
</Dialog.Panel> Logout
</Transition.Child> </span>
</div> </div>
</div> </Dialog.Panel>
</Dialog> </Transition.Child>
</Transition> </div>
); </div>
</Dialog>
</Transition>
);
} }

View File

@@ -1,212 +1,382 @@
import clsx from "clsx"; import clsx from "clsx";
import {IconType} from "react-icons"; import { IconType } from "react-icons";
import {MdSpaceDashboard} from "react-icons/md"; import { MdSpaceDashboard } from "react-icons/md";
import { import {
BsFileEarmarkText, BsFileEarmarkText,
BsClockHistory, BsClockHistory,
BsPencil, BsPencil,
BsGraphUp, BsGraphUp,
BsChevronBarRight, BsChevronBarRight,
BsChevronBarLeft, BsChevronBarLeft,
BsShieldFill, BsShieldFill,
BsCloudFill, BsCloudFill,
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock,
} from "react-icons/bs"; } from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri"; import { RiLogoutBoxFill } from "react-icons/ri";
import {SlPencil} from "react-icons/sl"; import { SlPencil } from "react-icons/sl";
import {FaAward} from "react-icons/fa"; import { FaAward } from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; user: User;
userId?: string;
} }
interface NavProps { interface NavProps {
Icon: IconType; Icon: IconType;
label: string; label: string;
path: string; path: string;
keyPath: string; keyPath: string;
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean; isMinimized?: boolean;
badge?: number; badge?: number;
} }
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => { const Nav = ({
return ( Icon,
<Link label,
href={!disabled ? keyPath : ""} path,
className={clsx( keyPath,
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", disabled = false,
"transition-all duration-300 ease-in-out relative", isMinimized = false,
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer", badge,
path === keyPath && "bg-mti-purple-light text-white", }: NavProps) => {
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", return (
)}> <Link
<Icon size={24} /> href={!disabled ? keyPath : ""}
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} className={clsx(
{!!badge && badge > 0 && ( "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
<div "transition-all duration-300 ease-in-out relative",
className={clsx( disabled
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white", ? "hover:bg-mti-gray-dim cursor-not-allowed"
"transition ease-in-out duration-300", : "hover:bg-mti-purple-light cursor-pointer",
isMinimized && "absolute right-0 top-0", path === keyPath && "bg-mti-purple-light text-white",
)}> isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]"
{badge} )}
</div> >
)} <Icon size={24} />
</Link> {!isMinimized && <span className="text-lg font-semibold">{label}</span>}
); {!!badge && badge > 0 && (
<div
className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0"
)}
>
{badge}
</div>
)}
</Link>
);
}; };
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) { export default function Sidebar({
const router = useRouter(); path,
navDisabled = false,
focusMode = false,
user,
onFocusLayerMouseEnter,
className,
}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [
state.isSidebarMinimized,
state.toggleSidebarMinimized,
]);
const {totalAssignedTickets} = useTicketsListener(userId); const { totalAssignedTickets } = useTicketsListener(user.id);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
return ( return (
<section <section
className={clsx( className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", "relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6", isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className, className
)}> )}
<div className="-xl:hidden flex-col gap-3 xl:flex"> >
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} /> <div className="-xl:hidden flex-col gap-3 xl:flex">
{(userType === "student" || userType === "teacher" || userType === "developer") && ( <Nav
<> disabled={disableNavigation}
<Nav Icon={MdSpaceDashboard}
disabled={disableNavigation} label="Dashboard"
Icon={BsFileEarmarkText} path={path}
label="Exams" keyPath="/"
path={path} isMinimized={isMinimized}
keyPath="/exam" />
isMinimized={isMinimized} {checkAccess(
/> user,
<Nav ["student", "teacher", "developer"],
disabled={disableNavigation} "viewExams"
Icon={BsPencil} ) && (
label="Exercises" <Nav
path={path} disabled={disableNavigation}
keyPath="/exercises" Icon={BsFileEarmarkText}
isMinimized={isMinimized} label="Exams"
/> path={path}
</> keyPath="/exam"
)} isMinimized={isMinimized}
{(userType || "") !== 'agent' && ( />
<> )}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> {checkAccess(
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> user,
</> ["student", "teacher", "developer"],
)} "viewExercises"
{["admin", "developer", "agent", "corporate"].includes(userType || "") && ( ) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsPencil}
label="Payment Record" label="Exercises"
path={path} path={path}
keyPath="/payment-record" keyPath="/exercises"
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && ( {checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsGraphUp}
label="Settings" label="Stats"
path={path} path={path}
keyPath="/settings" keyPath="/stats"
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "agent"].includes(userType || "") && ( {checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsClipboardData} Icon={BsClockHistory}
label="Tickets" label="Record"
path={path} path={path}
keyPath="/tickets" keyPath="/record"
isMinimized={isMinimized} isMinimized={isMinimized}
badge={totalAssignedTickets} />
/> )}
)} {checkAccess(
{userType === "developer" && ( user,
<Nav ["admin", "developer", "agent", "corporate", "mastercorporate"],
disabled={disableNavigation} "viewPaymentRecords"
Icon={BsCloudFill} ) && (
label="Generation" <Nav
path={path} disabled={disableNavigation}
keyPath="/generation" Icon={BsCurrencyDollar}
isMinimized={isMinimized} label="Payment Record"
/> path={path}
)} keyPath="/payment-record"
</div> isMinimized={isMinimized}
<div className="-xl:flex flex-col gap-3 xl:hidden"> />
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} /> )}
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} /> {checkAccess(user, [
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} /> "admin",
{(userType || "") !== 'agent' && ( "developer",
<> "corporate",
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} /> "teacher",
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} /> "mastercorporate",
</> ]) && (
)} <Nav
{userType !== "student" && ( disabled={disableNavigation}
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} /> Icon={BsShieldFill}
)} label="Settings"
{userType === "developer" && ( path={path}
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} /> keyPath="/settings"
)} isMinimized={isMinimized}
</div> />
)}
{checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
]) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
</>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav
disabled={disableNavigation}
Icon={MdSpaceDashboard}
label="Dashboard"
path={path}
keyPath="/"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={true}
/>
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
<Nav
disabled={disableNavigation}
Icon={BsGraphUp}
label="Stats"
path={path}
keyPath="/stats"
isMinimized={true}
/>
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
<Nav
disabled={disableNavigation}
Icon={BsClockHistory}
label="Record"
path={path}
keyPath="/record"
isMinimized={true}
/>
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={true}
/>
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)}
</div>
<div className="fixed bottom-12 flex flex-col gap-0"> <div className="fixed bottom-12 flex flex-col gap-0">
<div <div
role="button" role="button"
tabIndex={1} tabIndex={1}
onClick={toggleMinimize} onClick={toggleMinimize}
className={clsx( className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", "hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
)}> )}
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />} >
{!isMinimized && <span className="text-lg font-medium">Minimize</span>} {isMinimized ? (
</div> <BsChevronBarRight size={24} />
<div ) : (
role="button" <BsChevronBarLeft size={24} />
tabIndex={1} )}
onClick={focusMode ? () => {} : logout} {!isMinimized && (
className={clsx( <span className="text-lg font-medium">Minimize</span>
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", )}
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", </div>
)}> <div
<RiLogoutBoxFill size={24} /> role="button"
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>} tabIndex={1}
</div> onClick={focusMode ? () => {} : logout}
</div> className={clsx(
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
</section> isMinimized ? "w-fit" : "w-full min-w-[250px] px-8"
); )}
>
<RiLogoutBoxFill size={24} />
{!isMinimized && (
<span className="-xl:hidden text-lg font-medium">Log Out</span>
)}
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</section>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,91 @@
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
export const PERMISSIONS = { export const PERMISSIONS = {
generateCode: { generateCode: {
student: ["corporate", "developer", "admin"], student: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["corporate", "developer", "admin"], teacher: ["corporate", "developer", "admin", "mastercorporate"],
corporate: ["admin", "developer"], corporate: ["admin", "developer"],
admin: ["developer", "admin"], mastercorporate: ["admin", "developer"],
agent: ["developer", "admin"],
developer: ["developer"], admin: ["developer", "admin"],
}, agent: ["developer", "admin"],
deleteUser: { developer: ["developer"],
student: ["corporate", "developer", "admin"], },
teacher: ["corporate", "developer", "admin"], deleteUser: {
corporate: ["admin", "developer"], student: {
admin: ["developer", "admin"], perm: "deleteStudent",
agent: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
developer: ["developer"], },
}, teacher: {
updateUser: { perm: "deleteTeacher",
student: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"], corporate: {
admin: ["developer", "admin"], perm: "deleteCorporate",
agent: ["developer", "admin"], list: ["admin", "developer"],
developer: ["developer"], },
}, mastercorporate: {
updateExpiryDate: { perm: undefined,
student: ["developer", "admin"], list: ["admin", "developer"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"],
admin: ["developer", "admin"], admin: {
agent: ["developer", "admin"], perm: "deleteAdmin",
developer: ["developer"], list: ["developer", "admin"],
}, },
examManagement: { agent: {
delete: ["developer", "admin"], perm: "deleteCountryManager",
}, list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateUser: {
student: {
perm: "editStudent",
list: ["developer", "admin"],
},
teacher: {
perm: "editTeacher",
list: ["developer", "admin"],
},
corporate: {
perm: "editCorporate",
list: ["admin", "developer"],
},
mastercorporate: {
perm: undefined,
list: ["admin", "developer"],
},
admin: {
perm: "editAdmin",
list: ["developer", "admin"],
},
agent: {
perm: "editCountryManager",
list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateExpiryDate: {
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
mastercorporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "admin"],
},
}; };

View File

@@ -35,6 +35,7 @@ import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import { getUserCorporate } from "@/utils/groups";
interface Props { interface Props {
user: CorporateUser; user: CorporateUser;
@@ -44,6 +45,8 @@ export default function CorporateDashboard({ user }: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const { stats } = useStats(); const { stats } = useStats();
const { users, reload } = useUsers(); const { users, reload } = useUsers();
@@ -57,6 +60,11 @@ export default function CorporateDashboard({ user }: Props) {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
useEffect(() => {
// in this case it fetches the master corporate account
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const studentFilter = (user: User) => const studentFilter = (user: User) =>
user.type === "student" && user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id); groups.flatMap((g) => g.participants).includes(user.id);
@@ -200,6 +208,15 @@ export default function CorporateDashboard({ user }: Props) {
const DefaultDashboard = () => ( const DefaultDashboard = () => (
<> <>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to:{" "}
<b>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div>
)}
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center"> <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard <IconCard
onClick={() => setPage("students")} onClick={() => setPage("students")}

View File

@@ -0,0 +1,424 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import { Group, MasterCorporateUser, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils";
import moment from "moment";
import { useEffect, useState } from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClock,
BsPaperclip,
BsPersonFill,
BsPencilSquare,
BsPersonCheck,
BsPeople,
BsBank,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes";
interface Props {
user: MasterCorporateUser;
}
export default function MasterCorporateDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const { stats } = useStats();
const { users, reload } = useUsers();
const { codes } = useCodes(user.id);
const { groups } = useGroups(user.id, user.type);
const masterCorporateUserGroups = [
...new Set(
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
),
];
const corporateUserGroups = [
...new Set(groups.flatMap((g) => g.participants)),
];
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const studentFilter = (user: User) =>
user.type === "student" && corporateUserGroups.includes(user.id);
const teacherFilter = (user: User) =>
user.type === "teacher" && corporateUserGroups.includes(user.id);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" &&
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
)}
/>
);
};
const corporateUserFilter = (x: User) =>
x.type === "corporate" &&
(!!selectedUser
? masterCorporateUserGroups.includes(x.id) || false
: masterCorporateUserGroups.includes(x.id));
const CorporateList = () => {
return (
<UserList
user={user}
filters={[corporateUserFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporates ({total})</h2>
</div>
)}
/>
);
};
const GroupsList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Groups ({groups.length})
</h2>
</div>
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("teachers")}
Icon={BsPencilSquare}
label="Teachers"
value={users.filter(teacherFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
Icon={BsBank}
label="Corporate"
value={masterCorporateUserGroups.length}
color="purple"
onClick={() => setPage("corporate")}
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(teacherFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />}
{page === "corporate" && <CorporateList />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -2,16 +2,23 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useGroups(admin?: string) { export default function useGroups(admin?: string, userType?: string) {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith('master');
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
axios axios
.get<Group[]>("/api/groups") .get<Group[]>(url)
.then((response) => { .then((response) => {
if(isMasterType) {
return setGroups(response.data);
}
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || ""); const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data; const filteredGroups = admin ? response.data.filter(filter) : response.data;
@@ -20,7 +27,7 @@ export default function useGroups(admin?: string) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [admin]); useEffect(getData, [admin, isMasterType]);
return {groups, isLoading, isError, reload: getData}; return {groups, isLoading, isError, reload: getData};
} }

View File

@@ -0,0 +1,49 @@
export const markets = ["au", "br", "de"] as const;
export const permissions = [
// generate codes are basicly invites
"createCodeStudent",
"createCodeTeacher",
"createCodeCorporate",
"createCodeCountryManager",
"createCodeAdmin",
// exams
"createReadingExam",
"createListeningExam",
"createWritingExam",
"createSpeakingExam",
"createLevelExam",
// view pages
"viewExams",
"viewExercises",
"viewRecords",
"viewStats",
"viewTickets",
"viewPaymentRecords",
// view data
"viewStudent",
"viewTeacher",
"viewCorporate",
"viewCountryManager",
"viewAdmin",
// edit data
"editStudent",
"editTeacher",
"editCorporate",
"editCountryManager",
"editAdmin",
// delete data
"deleteStudent",
"deleteTeacher",
"deleteCorporate",
"deleteCountryManager",
"deleteAdmin",
] as const;
export type PermissionType = (typeof permissions)[keyof typeof permissions];
export interface Permission {
id: string;
type: PermissionType;
users: string[];
}

View File

@@ -1,152 +1,189 @@
import {Module} from "."; import { Module } from ".";
import {InstructorGender} from "./exam"; import { InstructorGender } from "./exam";
import { PermissionType } from "./permissions";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser; export type User =
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
name: string; name: string;
profilePicture: string; profilePicture: string;
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: {[key in Module]: number}; levels: { [key in Module]: number };
desiredLevels: {[key in Module]: number}; desiredLevels: { [key in Module]: number };
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
permissions: PermissionType[],
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
type: "teacher"; type: "teacher";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface CorporateUser extends BasicUser { export interface CorporateUser extends BasicUser {
type: "corporate"; type: "corporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
}
export interface MasterCorporateUser extends BasicUser {
type: "mastercorporate";
corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation;
} }
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface AdminUser extends BasicUser { export interface AdminUser extends BasicUser {
type: "admin"; type: "admin";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
companyInformation: CompanyInformation; companyInformation: CompanyInformation;
monthlyDuration: number; monthlyDuration: number;
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number; commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string; companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
name: string; name: string;
userAmount: number; userAmount: number;
} }
export interface DemographicInformation { export interface DemographicInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
employment: EmploymentStatus; employment: EmploymentStatus;
passport_id?: string; passport_id?: string;
timezone?: string; timezone?: string;
} }
export interface DemographicCorporateInformation { export interface DemographicCorporateInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
position: string; position: string;
timezone?: string; timezone?: string;
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other"; export type EmploymentStatus =
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [ | "employed"
{status: "student", label: "Student"}, | "student"
{status: "employed", label: "Employed"}, | "self-employed"
{status: "unemployed", label: "Unemployed"}, | "unemployed"
{status: "self-employed", label: "Self-employed"}, | "retired"
{status: "retired", label: "Retired"}, | "other";
{status: "other", label: "Other"}, export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
]; [
{ status: "student", label: "Student" },
{ status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" },
{ status: "self-employed", label: "Self-employed" },
{ status: "retired", label: "Retired" },
{ status: "other", label: "Other" },
];
export interface Stat { export interface Stat {
id: string; id: string;
user: string; user: string;
exam: string; exam: string;
exercise: string; exercise: string;
session: string; session: string;
date: number; date: number;
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
inactivity?: number; inactivity?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
}; };
isDisabled?: boolean; isDisabled?: boolean;
} }
export interface Group { export interface Group {
admin: string; admin: string;
name: string; name: string;
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code { export interface Code {
code: string; code: string;
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;
type: Type; type: Type;
creationDate?: string; creationDate?: string;
userId?: string; userId?: string;
email?: string; email?: string;
name?: string; name?: string;
passport_id?: string; passport_id?: string;
} }
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent"; export type Type =
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"]; | "student"
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent"
| "mastercorporate";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
"mastercorporate",
];

View File

@@ -1,248 +1,366 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Type, User} from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import {useFilePicker} from "use-file-picker"; import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs"; import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
import { checkAccess } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
);
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { } = {
student: [], student: {
teacher: [], perm: "createCodeStudent",
agent: [], list: [],
corporate: ["student", "teacher"], },
admin: ["student", "teacher", "agent", "corporate", "admin"], teacher: {
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
export default function BatchCodeGenerator({user}: {user: User}) { export default function BatchCodeGenerator({ user }: { user: User }) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); const [infos, setInfos] = useState<
const [isLoading, setIsLoading] = useState(false); { email: string; name: string; passport_id: string }[]
const [expiryDate, setExpiryDate] = useState<Date | null>( >([]);
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, const [isLoading, setIsLoading] = useState(false);
); const [expiryDate, setExpiryDate] = useState<Date | null>(
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); user?.subscriptionExpirationDate
const [type, setType] = useState<Type>("student"); ? moment(user.subscriptionExpirationDate).toDate()
const [showHelp, setShowHelp] = useState(false); : null
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers(); const { users } = useUsers();
const {openFilePicker, filesContent, clear} = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(file.content).then((rows) => {
try { try {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[]; const [
return EMAIL_REGEX.test(email.toString().trim()) firstName,
? { lastName,
email: email.toString().trim().toLowerCase(), country,
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), passport_id,
passport_id: passport_id?.toString().trim() || undefined, email,
} ...phone
: undefined; ] = row as string[];
}) return EMAIL_REGEX.test(email.toString().trim())
.filter((x) => !!x) as typeof infos, ? {
(x) => x.email, email: email.toString().trim().toLowerCase(),
); name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email
);
if (information.length === 0) { if (information.length === 0) {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
); );
return clear(); return clear();
} }
setInfos(information); setInfos(information);
} catch { } catch {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
); );
return clear(); return clear();
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
const generateAndInvite = async () => { const generateAndInvite = async () => {
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email)); const newUsers = infos.filter(
const existingUsers = infos (x) => !users.map((u) => u.email).includes(x.email)
.filter((x) => users.map((u) => u.email).includes(x.email)) );
.map((i) => users.find((u) => u.email === i.email)) const existingUsers = infos
.filter((x) => !!x && x.type === "student") as User[]; .filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[];
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; const newUsersSentence =
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined; newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
if ( const existingUsersSentence =
!confirm( existingUsers.length > 0
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`, ? `invite ${existingUsers.length} registered student(s)`
) : undefined;
) if (
return; !confirm(
`You are about to ${[newUsersSentence, existingUsersSentence]
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
)
)
return;
setIsLoading(true); setIsLoading(true);
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id}))) Promise.all(
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`)) existingUsers.map(
.finally(() => { async (u) =>
if (newUsers.length === 0) setIsLoading(false); await axios.post(`/api/invites`, { to: u.id, from: user.id })
}); )
)
.then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`
)
)
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (newUsers.length > 0) generateCode(type, newUsers); if (newUsers.length > 0) generateCode(type, newUsers);
setInfos([]); setInfos([]);
}; };
const generateCode = (type: Type, informations: typeof infos) => { const generateCode = (type: Type, informations: typeof infos) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const codes = informations.map(() => uid.randomUUID(6)); const codes = informations.map(() => uid.randomUUID(6));
setIsLoading(true); setIsLoading(true);
axios axios
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", { .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
type, type,
codes, codes,
infos: informations, infos: informations,
expiryDate, expiryDate,
}) })
.then(({data, status}) => { .then(({ data, status }) => {
if (data.ok) { if (data.ok) {
toast.success( toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize( `Successfully generated${
type, data.valid ? ` ${data.valid}/${informations.length}` : ""
)} codes and they have been notified by e-mail!`, } ${capitalize(type)} codes and they have been notified by e-mail!`,
{toastId: "success"}, { toastId: "success" }
); );
return; return;
} }
if (status === 403) { if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"}); toast.error(data.reason, { toastId: "forbidden" });
} }
}) })
.catch(({response: {status, data}}) => { .catch(({ response: { status, data } }) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"}); toast.error(data.reason, { toastId: "forbidden" });
return; return;
} }
toast.error(`Something went wrong, please try again later!`, { toast.error(`Something went wrong, please try again later!`, {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
return clear(); return clear();
}); });
}; };
return ( return (
<> <>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format"> <Modal
<div className="mt-4 flex flex-col gap-2"> isOpen={showHelp}
<span>Please upload an Excel file with the following format:</span> onClose={() => setShowHelp(false)}
<table className="w-full"> title="Excel File Format"
<thead> >
<tr> <div className="mt-4 flex flex-col gap-2">
<th className="border border-neutral-200 px-2 py-1">First Name</th> <span>Please upload an Excel file with the following format:</span>
<th className="border border-neutral-200 px-2 py-1">Last Name</th> <table className="w-full">
<th className="border border-neutral-200 px-2 py-1">Country</th> <thead>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> <tr>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">
<th className="border border-neutral-200 px-2 py-1">Phone Number</th> First Name
</tr> </th>
</thead> <th className="border border-neutral-200 px-2 py-1">
</table> Last Name
<span className="mt-4"> </th>
<b>Notes:</b> <th className="border border-neutral-200 px-2 py-1">Country</th>
<ul> <th className="border border-neutral-200 px-2 py-1">
<li>- All incorrect e-mails will be ignored;</li> Passport/National ID
<li>- All already registered e-mails will be ignored;</li> </th>
<li>- You may have a header row with the format above, however, it is not necessary;</li> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li> <th className="border border-neutral-200 px-2 py-1">
</ul> Phone Number
</span> </th>
</div> </tr>
</Modal> </thead>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> </table>
<div className="flex items-end justify-between"> <span className="mt-4">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label> <b>Notes:</b>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}> <ul>
<BsQuestionCircleFill /> <li>- All incorrect e-mails will be ignored;</li>
</div> <li>- All already registered e-mails will be ignored;</li>
</div> <li>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> - You may have a header row with the format above, however, it
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} is not necessary;
</Button> </li>
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && ( <li>
<> - All of the e-mails in the file will receive an e-mail to join
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> EnCoach with the role selected below.
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> </li>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> </ul>
Enabled </span>
</Checkbox> </div>
</div> </Modal>
{isExpiryDateEnabled && ( <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<ReactDatePicker <div className="flex items-end justify-between">
className={clsx( <label className="text-mti-gray-dim text-base font-normal">
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", Choose an Excel file
"hover:border-mti-purple tooltip", </label>
"transition duration-300 ease-in-out", <div
)} className="tooltip cursor-pointer"
filterDate={(date) => data-tip="Excel File Format"
moment(date).isAfter(new Date()) && onClick={() => setShowHelp(true)}
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) >
} <BsQuestionCircleFill />
dateFormat="dd/MM/yyyy" </div>
selected={expiryDate} </div>
onChange={(date) => setExpiryDate(date)} <Button
/> onClick={openFilePicker}
)} isLoading={isLoading}
</> disabled={isLoading}
)} >
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label> {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
{user && ( </Button>
<select {user &&
defaultValue="student" checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
onChange={(e) => setType(e.target.value as typeof user.type)} <>
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
{Object.keys(USER_TYPE_LABELS) <label className="text-mti-gray-dim text-base font-normal">
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) Expiry Date
.map((type) => ( </label>
<option key={type} value={type}> <Checkbox
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} isChecked={isExpiryDateEnabled}
</option> onChange={setIsExpiryDateEnabled}
))} disabled={!!user.subscriptionExpirationDate}
</select> >
)} Enabled
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> </Checkbox>
Generate & Send </div>
</Button> {isExpiryDateEnabled && (
</div> <ReactDatePicker
</> className={clsx(
); "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send
</Button>
</div>
</>
);
} }

View File

@@ -1,125 +1,197 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import {Type, User} from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { checkAccess } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { const USER_TYPE_PERMISSIONS: {
student: [], [key in Type]: { perm: PermissionType | undefined; list: Type[] };
teacher: [], } = {
agent: [], student: {
corporate: ["student", "teacher"], perm: "createCodeStudent",
admin: ["student", "teacher", "agent", "corporate", "admin"], list: [],
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], },
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
export default function CodeGenerator({user}: {user: User}) { export default function CodeGenerator({ user }: { user: User }) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate
); ? moment(user.subscriptionExpirationDate).toDate()
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); : null
const [type, setType] = useState<Type>("student"); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const generateCode = (type: Type) => { const generateCode = (type: Type) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const code = uid.randomUUID(6); const code = uid.randomUUID(6);
axios axios
.post("/api/code", {type, codes: [code], expiryDate}) .post("/api/code", { type, codes: [code], expiryDate })
.then(({data, status}) => { .then(({ data, status }) => {
if (data.ok) { if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"}); toast.success(`Successfully generated a ${capitalize(type)} code!`, {
setGeneratedCode(code); toastId: "success",
return; });
} setGeneratedCode(code);
return;
}
if (status === 403) { if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"}); toast.error(data.reason, { toastId: "forbidden" });
} }
}) })
.catch(({response: {status, data}}) => { .catch(({ response: { status, data } }) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"}); toast.error(data.reason, { toastId: "forbidden" });
return; return;
} }
toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); toast.error(`Something went wrong, please try again later!`, {
}); toastId: "error",
}; });
});
};
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label> <label className="font-normal text-base text-mti-gray-dim">
{user && ( User Code Generator
<select </label>
defaultValue="student" {user && (
onChange={(e) => setType(e.target.value as typeof user.type)} <select
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> defaultValue="student"
{Object.keys(USER_TYPE_LABELS) onChange={(e) => setType(e.target.value as typeof user.type)}
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
.map((type) => ( >
<option key={type} value={type}> {Object.keys(USER_TYPE_LABELS)
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} .filter((x) => {
</option> const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
))} return checkAccess(user, list, perm);
</select> })
)} .map((type) => (
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && ( <option key={type} value={type}>
<> {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> </option>
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> ))}
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}> </select>
Enabled )}
</Checkbox> {user &&
</div> checkAccess(user, ["developer", "admin", "corporate"]) && (
{isExpiryDateEnabled && ( <>
<ReactDatePicker <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
className={clsx( <label className="text-mti-gray-dim text-base font-normal">
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", Expiry Date
"hover:border-mti-purple tooltip", </label>
"transition duration-300 ease-in-out", <Checkbox
)} isChecked={isExpiryDateEnabled}
filterDate={(date) => onChange={setIsExpiryDateEnabled}
moment(date).isAfter(new Date()) && disabled={!!user.subscriptionExpirationDate}
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true) >
} Enabled
dateFormat="dd/MM/yyyy" </Checkbox>
selected={expiryDate} </div>
onChange={(date) => setExpiryDate(date)} {isExpiryDateEnabled && (
/> <ReactDatePicker
)} className={clsx(
</> "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
)} "hover:border-mti-purple tooltip",
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}> "transition duration-300 ease-in-out"
Generate )}
</Button> filterDate={(date) =>
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label> moment(date).isAfter(new Date()) &&
<div (user.subscriptionExpirationDate
className={clsx( ? moment(date).isBefore(user.subscriptionExpirationDate)
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", : true)
"hover:border-mti-purple tooltip", }
"transition duration-300 ease-in-out", dateFormat="dd/MM/yyyy"
)} selected={expiryDate}
data-tip="Click to copy" onChange={(date) => setExpiryDate(date)}
onClick={() => { />
if (generatedCode) navigator.clipboard.writeText(generatedCode); )}
}}> </>
{generatedCode} )}
</div> <Button
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>} onClick={() => generateCode(type)}
</div> disabled={isExpiryDateEnabled ? !expiryDate : false}
); >
Generate
</Button>
<label className="font-normal text-base text-mti-gray-dim">
Generated Code:
</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}
>
{generatedCode}
</div>
{generatedCode && (
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div>
);
} }

View File

@@ -86,7 +86,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter( const filteredUsers = emailUsers.filter(
(x) => (x) =>
((user.type === "developer" || user.type === "admin" || user.type === "corporate") && ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
(x?.type === "student" || x?.type === "teacher")) || (x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"), (user.type === "teacher" && x?.type === "student"),
); );
@@ -189,7 +189,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
); );
}; };
const filterTypes = ["corporate", "teacher"]; const filterTypes = ["corporate", "teacher", "mastercorporate"];
export default function GroupList({user}: {user: User}) { export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
@@ -197,10 +197,10 @@ export default function GroupList({user}: {user: User}) {
const [filterByUser, setFilterByUser] = useState(false); const [filterByUser, setFilterByUser] = useState(false);
const {users} = useUsers(); const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined); const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
useEffect(() => { useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) { if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
setFilterByUser(true); setFilterByUser(true);
} }
}, [user]); }, [user]);

File diff suppressed because it is too large Load Diff

View File

@@ -30,29 +30,74 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") await post(req, res); if (req.method === "POST") await post(req, res);
} }
const getGroupsForUser = async (admin: string, participant: string) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { admin, participant } = req.query as { const { admin, participant } = req.query as {
admin: string; admin: string;
participant: string; participant: string;
}; };
const queryConstraints = [ if (req.session?.user?.type === "mastercorporate") {
...(admin ? [where("admin", "==", admin)] : []), try {
...(participant const masterCorporateGroups = await getGroupsForUser(admin, participant);
? [where("participants", "array-contains", participant)] const corporatesFromMaster = masterCorporateGroups
: []), .filter((g) => g.name === "Corporate")
]; .flatMap((g) => g.participants);
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups"),
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
res.status(200).json(groups); if (corporatesFromMaster.length === 0) {
res.status(200).json([]);
return;
}
Promise.all(
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
)
.then((groups) => {
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
return;
})
.catch((e) => {
console.error(e);
res.status(500).json({ ok: false });
return;
});
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
return;
}
return;
}
try {
const groups = await getGroupsForUser(admin, participant);
res.status(200).json(groups);
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
}
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
@@ -60,8 +105,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
await Promise.all( await Promise.all(
body.participants.map( body.participants.map(
async (p) => await updateExpiryDateOnGroup(p, body.admin), async (p) => await updateExpiryDateOnGroup(p, body.admin)
), )
); );
await setDoc(doc(db, "groups", v4()), { await setDoc(doc(db, "groups", v4()), {

View File

@@ -0,0 +1,30 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import { getFirestore, doc, setDoc } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "PATCH") return patch(req, res);
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const { users } = req.body;
try {
await setDoc(doc(db, "permissions", id), { users }, { merge: true });
return res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
return res.status(500).json({ ok: false });
}
}

View File

@@ -0,0 +1,43 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
doc,
setDoc,
addDoc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Permission } from "@/interfaces/permissions";
import { bootstrap } from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
console.log("Boostrap");
try {
await bootstrap();
return res.status(200).json({ ok: true });
} catch (err) {
console.error("Failed to update permissions", err);
return res.status(500).json({ ok: false });
}
}

View File

@@ -0,0 +1,36 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
query,
where,
doc,
setDoc,
addDoc,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Permission } from "@/interfaces/permissions";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const docs = await getPermissionDocs();
res.status(200).json(docs);
}

View File

@@ -143,9 +143,18 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
disableEditing: true, disableEditing: true,
}; };
const defaultCorporateGroup: Group = {
admin: userId,
id: v4(),
name: "Corporate",
participants: [],
disableEditing: true,
};
await setDoc(doc(db, "users", userId), user); await setDoc(doc(db, "users", userId), user);
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup); await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup); await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
req.session.user = {...user, id: userId}; req.session.user = {...user, id: userId};
await req.session.save(); await req.session.save();

View File

@@ -1,11 +1,22 @@
import {PERMISSIONS} from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import {app, adminApp} from "@/firebase"; import { app, adminApp } from "@/firebase";
import {Group, User} from "@/interfaces/user"; import { Group, User } from "@/interfaces/user";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore"; import {
import {getAuth} from "firebase-admin/auth"; collection,
import {withIronSessionApiRoute} from "iron-session/next"; deleteDoc,
import {NextApiRequest, NextApiResponse} from "next"; doc,
getDoc,
getDocs,
getFirestore,
query,
setDoc,
where,
} from "firebase/firestore";
import { getAuth } from "firebase-admin/auth";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
const db = getFirestore(app); const db = getFirestore(app);
const auth = getAuth(adminApp); const auth = getAuth(adminApp);
@@ -13,89 +24,132 @@ const auth = getAuth(adminApp);
export default withIronSessionApiRoute(user, sessionOptions); export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) { async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res); if (req.method === "DELETE") return del(req, res);
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {id} = req.query as {id: string}; const { id } = req.query as { id: string };
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
const docTargetUser = await getDoc(doc(db, "users", id)); const docTargetUser = await getDoc(doc(db, "users", id));
if (!docTargetUser.exists()) { if (!docTargetUser.exists()) {
res.status(404).json({ok: false}); res.status(404).json({ ok: false });
return; return;
} }
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User; const targetUser = { ...docTargetUser.data(), id: docTargetUser.id } as User;
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) { if (
res.json({ok: true}); user.type === "corporate" &&
(targetUser.type === "student" || targetUser.type === "teacher")
) {
res.json({ ok: true });
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id))); const userParticipantGroup = await getDocs(
await Promise.all([ query(
...userParticipantGroup.docs collection(db, "groups"),
.filter((x) => (x.data() as Group).admin === user.id) where("participants", "array-contains", id)
.map(async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true})), )
]); );
await Promise.all([
...userParticipantGroup.docs
.filter((x) => (x.data() as Group).admin === user.id)
.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x
.data()
.participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
return; return;
} }
const permission = PERMISSIONS.deleteUser[targetUser.type]; const permission = PERMISSIONS.deleteUser[targetUser.type];
if (!permission.includes(user.type)) { if (!permission.list.includes(user.type)) {
res.status(403).json({ok: false}); res.status(403).json({ ok: false });
return; return;
} }
res.json({ok: true}); res.json({ ok: true });
await auth.deleteUser(id); await auth.deleteUser(id);
await deleteDoc(doc(db, "users", id)); await deleteDoc(doc(db, "users", id));
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id))); const userCodeDocs = await getDocs(
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id))); query(collection(db, "codes"), where("userId", "==", id))
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id))); );
const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id))); const userParticipantGroup = await getDocs(
query(collection(db, "groups"), where("participants", "array-contains", id))
);
const userGroupAdminDocs = await getDocs(
query(collection(db, "groups"), where("admin", "==", id))
);
const userStatsDocs = await getDocs(
query(collection(db, "stats"), where("user", "==", id))
);
await Promise.all([ await Promise.all([
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userParticipantGroup.docs.map( ...userParticipantGroup.docs.map(
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}), async (x) =>
), await setDoc(
]); x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json(undefined); res.status(401).json(undefined);
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
req.session.user = {...user, id: req.session.user.id}; const permissionDocs = await getPermissionDocs();
await req.session.save();
res.json({...user, id: req.session.user.id}); const userWithPermissions = {
} else { ...user,
res.status(401).json(undefined); permissions: getPermissions(req.session.user.id, permissionDocs),
} };
req.session.user = {
...userWithPermissions,
id: req.session.user.id,
};
await req.session.save();
res.json({ ...userWithPermissions, id: req.session.user.id });
} else {
res.status(401).json(undefined);
}
} }

View File

@@ -1,19 +1,19 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {toast, ToastContainer} from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {useState} from "react"; import { useState } from "react";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {RadioGroup, Tab} from "@headlessui/react"; import { RadioGroup, Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {Exercise, ReadingPart} from "@/interfaces/exam"; import { Exercise, ReadingPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import axios from "axios"; import axios from "axios";
import ReadingGeneration from "./(generation)/ReadingGeneration"; import ReadingGeneration from "./(generation)/ReadingGeneration";
@@ -21,101 +21,109 @@ import ListeningGeneration from "./(generation)/ListeningGeneration";
import WritingGeneration from "./(generation)/WritingGeneration"; import WritingGeneration from "./(generation)/WritingGeneration";
import LevelGeneration from "./(generation)/LevelGeneration"; import LevelGeneration from "./(generation)/LevelGeneration";
import SpeakingGeneration from "./(generation)/SpeakingGeneration"; import SpeakingGeneration from "./(generation)/SpeakingGeneration";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
} },
}; };
} }
if (shouldRedirectHome(user) || user.type !== "developer") { if (
return { shouldRedirectHome(user) ||
redirect: { checkAccess(user, getTypesOfUser(["developer"]))
destination: "/", ) {
permanent: false, return {
} redirect: {
}; destination: "/",
} permanent: false,
},
};
}
return { return {
props: {user: req.session.user}, props: { user: req.session.user },
}; };
}, sessionOptions); }, sessionOptions);
export default function Generation() { export default function Generation() {
const [module, setModule] = useState<Module>("reading"); const [module, setModule] = useState<Module>("reading");
const {user} = useUser({redirectTo: "/login"}); const { user } = useUser({ redirectTo: "/login" });
return ( return (
<> <>
<Head> <Head>
<title>Exam Generation | EnCoach</title> <title>Exam Generation | 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 />
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1> <h1 className="text-2xl font-semibold">Exam Generation</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Module</label> <label className="font-normal text-base text-mti-gray-dim">
<RadioGroup Module
value={module} </label>
onChange={setModule} <RadioGroup
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> value={module}
{[...MODULE_ARRAY].map((x) => ( onChange={setModule}
<RadioGroup.Option value={x} key={x}> className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"
{({checked}) => ( >
<span {[...MODULE_ARRAY].map((x) => (
className={clsx( <RadioGroup.Option value={x} key={x}>
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", {({ checked }) => (
"transition duration-300 ease-in-out", <span
x === "reading" && className={clsx(
(!checked "px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
? "bg-white border-mti-gray-platinum" "transition duration-300 ease-in-out",
: "bg-ielts-reading/70 border-ielts-reading text-white"), x === "reading" &&
x === "listening" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white"),
: "bg-ielts-listening/70 border-ielts-listening text-white"), x === "listening" &&
x === "writing" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-listening/70 border-ielts-listening text-white"),
: "bg-ielts-writing/70 border-ielts-writing text-white"), x === "writing" &&
x === "speaking" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-writing/70 border-ielts-writing text-white"),
: "bg-ielts-speaking/70 border-ielts-speaking text-white"), x === "speaking" &&
x === "level" && (!checked
(!checked ? "bg-white border-mti-gray-platinum"
? "bg-white border-mti-gray-platinum" : "bg-ielts-speaking/70 border-ielts-speaking text-white"),
: "bg-ielts-level/70 border-ielts-level text-white"), x === "level" &&
)}> (!checked
{capitalize(x)} ? "bg-white border-mti-gray-platinum"
</span> : "bg-ielts-level/70 border-ielts-level text-white")
)} )}
</RadioGroup.Option> >
))} {capitalize(x)}
</RadioGroup> </span>
</div> )}
{module === "reading" && <ReadingGeneration />} </RadioGroup.Option>
{module === "listening" && <ListeningGeneration />} ))}
{module === "writing" && <WritingGeneration />} </RadioGroup>
{module === "speaking" && <SpeakingGeneration />} </div>
{module === "level" && <LevelGeneration />} {module === "reading" && <ReadingGeneration />}
</Layout> {module === "listening" && <ListeningGeneration />}
)} {module === "writing" && <WritingGeneration />}
</> {module === "speaking" && <SpeakingGeneration />}
); {module === "level" && <LevelGeneration />}
</Layout>
)}
</>
);
} }

View File

@@ -1,196 +1,244 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs"; import {
import {withIronSessionSsr} from "iron-session/next"; BsFileEarmarkText,
import {sessionOptions} from "@/lib/session"; BsPencil,
import {useEffect, useState} from "react"; BsStar,
BsBook,
BsHeadphones,
BsPen,
BsMegaphone,
} from "react-icons/bs";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { useEffect, useState } from "react";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import {averageScore, groupBySession, totalExams} from "@/utils/stats"; import { averageScore, groupBySession, totalExams } from "@/utils/stats";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import Sidebar from "@/components/Sidebar";
import Diagnostic from "@/components/Diagnostic"; import Diagnostic from "@/components/Diagnostic";
import {ToastContainer} from "react-toastify"; import { ToastContainer } from "react-toastify";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import axios from "axios"; import axios from "axios";
import DemographicInformationInput from "@/components/DemographicInformationInput"; import DemographicInformationInput from "@/components/DemographicInformationInput";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import StudentDashboard from "@/dashboards/Student"; import StudentDashboard from "@/dashboards/Student";
import AdminDashboard from "@/dashboards/Admin"; import AdminDashboard from "@/dashboards/Admin";
import CorporateDashboard from "@/dashboards/Corporate"; import CorporateDashboard from "@/dashboards/Corporate";
import TeacherDashboard from "@/dashboards/Teacher"; import TeacherDashboard from "@/dashboards/Teacher";
import AgentDashboard from "@/dashboards/Agent"; import AgentDashboard from "@/dashboards/Agent";
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
import PaymentDue from "./(status)/PaymentDue"; import PaymentDue from "./(status)/PaymentDue";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js"; import { PayPalScriptProvider } from "@paypal/react-paypal-js";
import {CorporateUser, Type, userTypes} from "@/interfaces/user"; import {
CorporateUser,
MasterCorporateUser,
Type,
userTypes,
} from "@/interfaces/user";
import Select from "react-select"; import Select from "react-select";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
const envVariables: {[key: string]: string} = {}; const envVariables: { [key: string]: string } = {};
Object.keys(process.env) Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC")) .filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => { .forEach((x: string) => {
envVariables[x] = process.env[x]!; envVariables[x] = process.env[x]!;
}); });
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
return { return {
props: {user: req.session.user, envVariables}, props: { user: req.session.user, envVariables },
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: any; user: any;
envVariables: {[key: string]: string}; envVariables: { [key: string]: string };
} }
export default function Home(props: Props) { export default function Home(props: Props) {
const {envVariables} = props; const { envVariables } = props;
const [showDiagnostics, setShowDiagnostics] = useState(false); const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showDemographicInput, setShowDemographicInput] = useState(false); const [showDemographicInput, setShowDemographicInput] = useState(false);
const [selectedScreen, setSelectedScreen] = useState<Type>("admin"); const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
const {user, mutateUser} = useUser({redirectTo: "/login"}); const { user, mutateUser } = useUser({ redirectTo: "/login" });
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setShowDemographicInput( setShowDemographicInput(
!user.demographicInformation || !user.demographicInformation ||
!user.demographicInformation.country || !user.demographicInformation.country ||
!user.demographicInformation.gender || !user.demographicInformation.gender ||
!user.demographicInformation.phone, !user.demographicInformation.phone
); );
setShowDiagnostics(user.isFirstLogin && user.type === "student"); setShowDiagnostics(user.isFirstLogin && user.type === "student");
} }
}, [user]); }, [user]);
const checkIfUserExpired = () => { const checkIfUserExpired = () => {
const expirationDate = user!.subscriptionExpirationDate; const expirationDate = user!.subscriptionExpirationDate;
if (expirationDate === null || expirationDate === undefined) return false; if (expirationDate === null || expirationDate === undefined) return false;
if (moment(expirationDate).isAfter(moment(new Date()))) return false; if (moment(expirationDate).isAfter(moment(new Date()))) return false;
return true; return true;
}; };
if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) { if (
return ( user &&
<> (user.status === "paymentDue" ||
<Head> user.status === "disabled" ||
<title>EnCoach</title> checkIfUserExpired())
<meta ) {
name="description" return (
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." <>
/> <Head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <title>EnCoach</title>
<link rel="icon" href="/favicon.ico" /> <meta
</Head> name="description"
{user.status === "disabled" && ( content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
<Layout user={user} navDisabled> />
<div className="flex flex-col items-center justify-center text-center w-full gap-4"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<span className="font-bold text-lg">Your account has been disabled!</span> <link rel="icon" href="/favicon.ico" />
<span>Please contact an administrator if you believe this to be a mistake.</span> </Head>
</div> {user.status === "disabled" && (
</Layout> <Layout user={user} navDisabled>
)} <div className="flex flex-col items-center justify-center text-center w-full gap-4">
{(user.status === "paymentDue" || checkIfUserExpired()) && <PaymentDue hasExpired user={user} reload={router.reload} />} <span className="font-bold text-lg">
</> Your account has been disabled!
); </span>
} <span>
Please contact an administrator if you believe this to be a
mistake.
</span>
</div>
</Layout>
)}
{(user.status === "paymentDue" || checkIfUserExpired()) && (
<PaymentDue hasExpired user={user} reload={router.reload} />
)}
</>
);
}
if (user && showDemographicInput) { if (user && showDemographicInput) {
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>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>
<Layout user={user} navDisabled> <Layout user={user} navDisabled>
<DemographicInformationInput mutateUser={mutateUser} user={user} /> <DemographicInformationInput mutateUser={mutateUser} user={user} />
</Layout> </Layout>
</> </>
); );
} }
if (user && showDiagnostics) { if (user && showDiagnostics) {
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>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>
<Layout user={user} navDisabled> <Layout user={user} navDisabled>
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} /> <Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
</Layout> </Layout>
</> </>
); );
} }
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>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 />
{user && ( {user && (
<Layout user={user}> <Layout user={user}>
{user.type === "student" && <StudentDashboard user={user} />} {checkAccess(user, ["student"]) && <StudentDashboard user={user} />}
{user.type === "teacher" && <TeacherDashboard user={user} />} {checkAccess(user, ["teacher"]) && <TeacherDashboard user={user} />}
{user.type === "corporate" && <CorporateDashboard user={user} />} {checkAccess(user, ["corporate"]) && (
{user.type === "agent" && <AgentDashboard user={user} />} <CorporateDashboard user={user as CorporateUser} />
{user.type === "admin" && <AdminDashboard user={user} />} )}
{user.type === "developer" && ( {checkAccess(user, ["mastercorporate"]) && (
<> <MasterCorporateDashboard user={user as MasterCorporateUser} />
<Select )}
options={userTypes.map((u) => ({value: u, label: USER_TYPE_LABELS[u]}))} {checkAccess(user, ["agent"]) && <AgentDashboard user={user} />}
value={{value: selectedScreen, label: USER_TYPE_LABELS[selectedScreen]}} {checkAccess(user, ["admin"]) && <AdminDashboard user={user} />}
onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))} {checkAccess(user, ["developer"]) && (
/> <>
<Select
options={userTypes.map((u) => ({
value: u,
label: USER_TYPE_LABELS[u],
}))}
value={{
value: selectedScreen,
label: USER_TYPE_LABELS[selectedScreen],
}}
onChange={(value) =>
value
? setSelectedScreen(value.value)
: setSelectedScreen("admin")
}
/>
{selectedScreen === "student" && <StudentDashboard user={user} />} {selectedScreen === "student" && <StudentDashboard user={user} />}
{selectedScreen === "teacher" && <TeacherDashboard user={user} />} {selectedScreen === "teacher" && <TeacherDashboard user={user} />}
{selectedScreen === "corporate" && <CorporateDashboard user={user as unknown as CorporateUser} />} {selectedScreen === "corporate" && (
{selectedScreen === "agent" && <AgentDashboard user={user} />} <CorporateDashboard user={user as unknown as CorporateUser} />
{selectedScreen === "admin" && <AdminDashboard user={user} />} )}
</> {selectedScreen === "mastercorporate" && (
)} <MasterCorporateDashboard
</Layout> user={user as unknown as MasterCorporateUser}
)} />
</> )}
); {selectedScreen === "agent" && <AgentDashboard user={user} />}
{selectedScreen === "admin" && <AdminDashboard user={user} />}
</>
)}
</Layout>
)}
</>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { useState } from "react";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission, PermissionType } from "@/interfaces/permissions";
import { getPermissionDoc } from "@/utils/permissions.be";
import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout";
import { getUsers } from "@/utils/users.be";
import { BsTrash } from "react-icons/bs";
import Select from "@/components/Low/Select";
import Button from "@/components/Low/Button";
import axios from "axios";
import { toast, ToastContainer } from "react-toastify";
interface BasicUser {
id: string;
name: string;
}
interface PermissionWithBasicUsers {
id: string;
type: PermissionType;
users: BasicUser[];
}
export const getServerSideProps = withIronSessionSsr(async (context) => {
const { req, params } = context;
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
if (!params?.id) {
return {
redirect: {
destination: "/permissions",
permanent: false,
},
};
}
// Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string);
const allUserData: User[] = await getUsers();
const users = allUserData.map((u) => ({
id: u.id,
name: u.name,
})) as BasicUser[];
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce(
(acc: BasicUser[], userId) => {
const user = users.find((u) => u.id === userId) as BasicUser;
if (user) {
acc.push(user);
}
return acc;
},
[]
);
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permission: {
...permission,
id: params.id,
users: usersData,
},
user: req.session.user,
users,
},
};
}, sessionOptions);
interface Props {
permission: PermissionWithBasicUsers;
user: User;
users: BasicUser[];
}
export default function Page(props: Props) {
console.log("Props", props);
const { permission, user, users } = props;
const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
permission.users.map((u) => u.id)
);
const onChange = (value: any) => {
console.log("value", value);
setSelectedUsers((prev) => {
if (value?.value) {
return [...prev, value?.value];
}
return prev;
});
};
const removeUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((u) => u !== id));
};
const update = async () => {
console.log("update", selectedUsers);
try {
await axios.patch(`/api/permissions/${permission.id}`, {
users: selectedUsers,
});
toast.success("Permission updated");
} catch (err) {
toast.error("Failed to update permission");
}
};
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
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" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">
Permission: {permission.type as string}
</h1>
<div className="flex gap-3">
<Select
value={null}
options={users
.filter((u) => !selectedUsers.includes(u.id))
.map((u) => ({
label: u.name,
value: u.id,
}))}
onChange={onChange}
/>
<Button onClick={update}>Update</Button>
</div>
<div className="flex flex-col gap-3">
<h2>Blacklisted Users</h2>
<div className="flex gap-3 flex-wrap">
{selectedUsers.map((userId) => {
const name = users.find((u) => u.id === userId)?.name;
return (
<div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={userId}
>
<span className="text-base">{name}</span>
<BsTrash
style={{ cursor: "pointer" }}
onClick={() => removeUser(userId)}
size={20}
/>
</div>
);
})}
</div>
</div>
</Layout>
</>
);
}

View File

@@ -0,0 +1,94 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission } from "@/interfaces/permissions";
import { getPermissionDocs } from "@/utils/permissions.be";
import { User } from "@/interfaces/user";
import Layout from "@/components/High/Layout";
import Link from "next/link";
export const getServerSideProps = withIronSessionSsr(async ({ req }) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
// Fetch data from external API
const permissions: Permission[] = await getPermissionDocs();
console.log("Permissions", permissions);
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permissions: permissions.map((p) => {
const { users, ...rest } = p;
return rest;
}),
user: req.session.user,
},
};
}, sessionOptions);
interface Props {
permissions: Permission[];
user: User;
}
export default function Page(props: Props) {
console.log("Props", props);
const { permissions, user } = props;
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
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" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Permissions</h1>
<div className="flex gap-3 flex-wrap">
{permissions.map((permission: Permission) => {
const id = permission.id as string;
const type = permission.type as string;
return (
<Link href={`/permissions/${id}`} key={id}>
<div className="card bg-primary text-primary-content">
<div className="card-body">
<h1 className="card-title">{type}</h1>
</div>
</div>
</Link>
);
})}
</div>
</Layout>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent"].includes(user.type)) { if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent", "mastercorporate"].includes(user.type)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",

View File

@@ -202,7 +202,7 @@ export default function Stats() {
}} }}
/> />
)} )}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && ( {(["corporate", "teacher", "mastercorporate"].includes(user.type) ) && groups.length > 0 && (
<Select <Select
className="w-full" className="w-full"
options={users options={users

View File

@@ -7,6 +7,7 @@ export const USER_TYPE_LABELS: {[key in Type]: string} = {
agent: "Country Manager", agent: "Country Manager",
admin: "Admin", admin: "Admin",
developer: "Developer", developer: "Developer",
mastercorporate: "Master Corporate"
}; };
export function isCorporateUser(user: User): user is CorporateUser { export function isCorporateUser(user: User): user is CorporateUser {

View File

@@ -1,4 +1,4 @@
import { CorporateUser, Group, User } from "@/interfaces/user"; import { CorporateUser, Group, User, Type } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
export const isUserFromCorporate = async (userID: string) => { export const isUserFromCorporate = async (userID: string) => {
@@ -7,20 +7,12 @@ export const isUserFromCorporate = async (userID: string) => {
const users = (await axios.get<User[]>("/api/users/list")).data; const users = (await axios.get<User[]>("/api/users/list")).data;
const adminTypes = groups.map( const adminTypes = groups.map(
(g) => users.find((u) => u.id === g.admin)?.type, (g) => users.find((u) => u.id === g.admin)?.type
); );
return adminTypes.includes("corporate"); return adminTypes.includes("corporate");
}; };
export const getUserCorporate = async ( const getAdminForGroup = async (userID: string, role: Type) => {
userID: string,
): Promise<CorporateUser | undefined> => {
const userRequest = await axios.get<User>(`/api/users/${userID}`);
if (userRequest.status === 200) {
const user = userRequest.data;
if (user.type === "corporate") return user;
}
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)) const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
.data; .data;
@@ -29,9 +21,23 @@ export const getUserCorporate = async (
const userRequest = await axios.get<User>(`/api/users/${g.admin}`); const userRequest = await axios.get<User>(`/api/users/${g.admin}`);
if (userRequest.status === 200) return userRequest.data; if (userRequest.status === 200) return userRequest.data;
return undefined; return undefined;
}), })
); );
const admins = adminRequests.filter((x) => x?.type === "corporate"); const admins = adminRequests.filter((x) => x?.type === role);
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined; return admins.length > 0 ? (admins[0] as CorporateUser) : undefined;
}; };
export const getUserCorporate = async (
userID: string
): Promise<CorporateUser | undefined> => {
const userRequest = await axios.get<User>(`/api/users/${userID}`);
if (userRequest.status === 200) {
const user = userRequest.data;
if (user.type === "corporate") {
return getAdminForGroup(userID, "mastercorporate");
}
}
return getAdminForGroup(userID, "corporate");
};

View File

@@ -0,0 +1,83 @@
import { app, adminApp } from "@/firebase";
import { getAuth } from "firebase-admin/auth";
import {
collection,
deleteDoc,
doc,
getDoc,
getDocs,
getFirestore,
query,
setDoc,
where,
} from "firebase/firestore";
import {
Permission,
PermissionType,
permissions,
} from "@/interfaces/permissions";
import {v4} from "uuid";
const db = getFirestore(app);
async function createPermission(type: string) {
const permData = doc(db, "permissions", v4());
const permDoc = await getDoc(permData);
if (permDoc.exists()) {
return true;
}
await setDoc(permData, {
type,
users: [],
});
}
export function getPermissions(userId: string | undefined, docs: Permission[]) {
if (!userId) {
return [];
}
// the concept is like a blacklist
// if the user exists in the list, he can't access this permission
// even if his profile allows
const permissions = docs.reduce((acc: PermissionType[], doc: Permission) => {
// typescript was complaining even with the validation on the top
if (doc.users.includes(userId)) {
return acc;
}
return [...acc, doc.type];
}, []) as PermissionType[];
return permissions;
}
export async function bootstrap() {
await permissions.forEach(async (type) => {
await createPermission(type);
});
}
export async function getPermissionDoc(id: string) {
const docRef = doc(db, "permissions", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
return docSnap.data() as Permission;
}
throw new Error("Permission not found");
}
export async function getPermissionDocs() {
const q = query(collection(db, "permissions"));
// firebase is missing something like array-not-contains
const snapshot = await getDocs(q);
const docs = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Permission[];
return docs;
}

45
src/utils/permissions.ts Normal file
View File

@@ -0,0 +1,45 @@
import { PermissionType } from "@/interfaces/permissions";
import { User, Type, userTypes } from "@/interfaces/user";
export function checkAccess(
user: User,
types: Type[],
permission?: PermissionType
) {
if (!user) {
return false;
}
// if(user.type === '') {
if (!user.type) {
console.warn("User type is empty");
return false;
}
if (types.length === 0) {
console.warn("No types provided");
return false;
}
if (!types.includes(user.type)) {
return false;
}
// we may not want a permission check as most screens dont even havr a specific permission
if (permission) {
// this works more like a blacklist
// therefore if we don't find the permission here, he can't do it
if (!(user.permissions || []).includes(permission)) {
return false;
}
}
return true;
}
export function getTypesOfUser(types: Type[]) {
// basicly generate a list of all types except the excluded ones
return userTypes.filter((userType) => {
return !types.includes(userType);
})
}

14
src/utils/users.be.ts Normal file
View File

@@ -0,0 +1,14 @@
import { app } from "@/firebase";
import { collection, getDocs, getFirestore } from "firebase/firestore";
import { User } from "@/interfaces/user";
const db = getFirestore(app);
export async function getUsers() {
const snapshot = await getDocs(collection(db, "users"));
return snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as User[];
}

View File

@@ -25,7 +25,7 @@ export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group
expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited", expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited",
country: user.demographicInformation?.country || "N/A", country: user.demographicInformation?.country || "N/A",
phone: user.demographicInformation?.phone || "N/A", phone: user.demographicInformation?.phone || "N/A",
employmentPosition: (user.type === "corporate" ? user.demographicInformation?.position : user.demographicInformation?.employment) || "N/A", employmentPosition: (user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : user.demographicInformation?.employment) || "N/A",
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
verified: user.isVerified?.toString() || "FALSE", verified: user.isVerified?.toString() || "FALSE",
})); }));