diff --git a/src/constants/userPermissions.ts b/src/constants/userPermissions.ts index 70d9f2cc..ac3ae152 100644 --- a/src/constants/userPermissions.ts +++ b/src/constants/userPermissions.ts @@ -22,4 +22,7 @@ export const PERMISSIONS = { owner: ["developer", "owner"], developer: ["developer"], }, + examManagement: { + delete: ["developer", "owner"], + }, }; diff --git a/src/hooks/useExams.tsx b/src/hooks/useExams.tsx index bd645c45..c9e001b5 100644 --- a/src/hooks/useExams.tsx +++ b/src/hooks/useExams.tsx @@ -7,13 +7,15 @@ export default function useExams() { const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); - useEffect(() => { + const getData = () => { setIsLoading(true); axios .get("/api/exam") .then((response) => setExams(response.data)) .finally(() => setIsLoading(false)); - }, []); + }; - return {exams, isLoading, isError}; + useEffect(getData, []); + + return {exams, isLoading, isError, reload: getData}; } diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 975c39ee..16a6f22b 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -14,6 +14,7 @@ export interface User { bio: string; isVerified: boolean; demographicInformation?: DemographicInformation; + isDisabled?: boolean; } export interface DemographicInformation { diff --git a/src/pages/(admin)/Lists/ExamList.tsx b/src/pages/(admin)/Lists/ExamList.tsx index 3ffc1592..b1198495 100644 --- a/src/pages/(admin)/Lists/ExamList.tsx +++ b/src/pages/(admin)/Lists/ExamList.tsx @@ -1,15 +1,17 @@ +import {PERMISSIONS} from "@/constants/userPermissions"; import useExams from "@/hooks/useExams"; import useUsers from "@/hooks/useUsers"; import {Module} from "@/interfaces"; import {Exam} from "@/interfaces/exam"; -import {Type} from "@/interfaces/user"; +import {Type, User} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; import {getExamById} from "@/utils/exams"; import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; +import axios from "axios"; import clsx from "clsx"; import {capitalize} from "lodash"; import {useRouter} from "next/router"; -import {BsCheck, BsUpload} from "react-icons/bs"; +import {BsCheck, BsTrash, BsUpload} from "react-icons/bs"; import {toast} from "react-toastify"; const CLASSES: {[key in Module]: string} = { @@ -21,8 +23,8 @@ const CLASSES: {[key in Module]: string} = { const columnHelper = createColumnHelper(); -export default function ExamList() { - const {exams} = useExams(); +export default function ExamList({user}: {user: User}) { + const {exams, reload} = useExams(); const setExams = useExamStore((state) => state.setExams); const setSelectedModules = useExamStore((state) => state.setSelectedModules); @@ -45,7 +47,29 @@ export default function ExamList() { router.push("/exercises"); }; - const getTotalExercises = (exam: Exam, module: Module) => { + const deleteExam = async (exam: Exam) => { + if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; + + axios + .delete(`/api/exam/${exam.module}/${exam.id}`) + .then(() => toast.success(`Deleted the "${exam.id}" exam`)) + .catch((reason) => { + if (reason.response.status === 404) { + toast.error("Exam not found!"); + return; + } + + if (reason.response.status === 403) { + toast.error("You do not have permission to delete this exam!"); + return; + } + + toast.error("Something went wrong, please try again later."); + }) + .finally(reload); + }; + + const getTotalExercises = (exam: Exam) => { if (exam.module === "reading") { return exam.parts.flatMap((x) => x.exercises).length; } @@ -62,7 +86,7 @@ export default function ExamList() { header: "Module", cell: (info) => {capitalize(info.getValue())}, }), - columnHelper.accessor((x) => getTotalExercises(x, x.module), { + columnHelper.accessor((x) => getTotalExercises(x), { header: "Exercises", cell: (info) => info.getValue(), }), @@ -75,11 +99,18 @@ export default function ExamList() { id: "actions", cell: ({row}: {row: {original: Exam}}) => { return ( -
await loadExam(row.original.module, row.original.id)}> - +
+
await loadExam(row.original.module, row.original.id)}> + +
+ {PERMISSIONS.examManagement.delete.includes(user.type) && ( +
deleteExam(row.original)}> + +
+ )}
); }, diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index dd34003d..699dfcf0 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -8,7 +8,7 @@ import axios from "axios"; import clsx from "clsx"; import {capitalize} from "lodash"; import {Fragment} from "react"; -import {BsCheck, BsPerson, BsTrash} from "react-icons/bs"; +import {BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsPerson, BsStop, BsTrash} from "react-icons/bs"; import {toast} from "react-toastify"; const columnHelper = createColumnHelper(); @@ -56,6 +56,27 @@ export default function UserList({user}: {user: User}) { }); }; + const toggleDisableAccount = (user: User) => { + if ( + !confirm( + `Are you sure you want to ${user.isDisabled ? "enable" : "disable"} ${ + user.name + }'s account? This change is usually related to their payment state.`, + ) + ) + return; + + axios + .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, isDisabled: !user.isDisabled}) + .then(() => { + toast.success(`User ${user.isDisabled ? "enabled" : "disabled"} successfully!`); + reload(); + }) + .catch(() => { + toast.error("Something went wrong!", {toastId: "update-error"}); + }); + }; + const defaultColumns = [ columnHelper.accessor("name", { header: "Name", @@ -141,16 +162,28 @@ export default function UserList({user}: {user: User}) { )} - {PERMISSIONS.deleteUser[row.original.type].includes(user.type) && ( -
deleteAccount(row.original)}> - -
- )} {!row.original.isVerified && PERMISSIONS.updateUser[row.original.type].includes(user.type) && (
verifyAccount(row.original)}>
)} + {PERMISSIONS.updateUser[row.original.type].includes(user.type) && ( +
toggleDisableAccount(row.original)}> + {row.original.isDisabled ? ( + + ) : ( + + )} +
+ )} + {PERMISSIONS.deleteUser[row.original.type].includes(user.type) && ( +
deleteAccount(row.original)}> + +
+ )}
); }, diff --git a/src/pages/(admin)/Lists/index.tsx b/src/pages/(admin)/Lists/index.tsx index 942e9a77..aabcac82 100644 --- a/src/pages/(admin)/Lists/index.tsx +++ b/src/pages/(admin)/Lists/index.tsx @@ -48,7 +48,7 @@ export default function Lists({user}: {user: User}) { - + diff --git a/src/pages/api/exam/[module]/[id].ts b/src/pages/api/exam/[module]/[id].ts index 9e8e9cac..fc80debf 100644 --- a/src/pages/api/exam/[module]/[id].ts +++ b/src/pages/api/exam/[module]/[id].ts @@ -1,15 +1,21 @@ // 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, getDoc} from "firebase/firestore"; +import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore"; import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; +import {PERMISSIONS} from "@/constants/userPermissions"; const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return get(req, res); + if (req.method === "DELETE") return del(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { res.status(401).json({ok: false}); return; @@ -30,3 +36,28 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { res.status(404).json(undefined); } } + +async function del(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {module, id} = req.query as {module: string; id: string}; + + const docRef = doc(db, module, id); + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + if (!PERMISSIONS.examManagement.delete.includes(req.session.user.type)) { + res.status(403).json({ok: false}); + return; + } + + await deleteDoc(docRef); + + res.status(200).json({ok: true}); + } else { + res.status(404).json({ok: false}); + } +}