diff --git a/backend/src/routes/courses.ts b/backend/src/routes/courses.ts index 6c1a4a1..d017fb9 100644 --- a/backend/src/routes/courses.ts +++ b/backend/src/routes/courses.ts @@ -1,6 +1,6 @@ // Imports import { Request, Response } from 'express'; // Import Request and Response types -import { CourseBodyParams, CourseQueryParams, CourseRouteParams, RateObject } from '../types'; +import { CourseBodyParams, CourseQueryParams, CourseRouteParams, RateObject, UserRole } from '../types'; import { getConnection } from '../db/index'; import { User as UserDb } from '../db/User'; @@ -64,6 +64,7 @@ export async function postCourse(req: Request, res: courseName, courseDescription, university, + userId, } = req.body; if (!courseCode || !courseName || !courseDescription || !university) { @@ -71,6 +72,19 @@ export async function postCourse(req: Request, res: return; } + if (!userId) { + res.status(400).json('Missing userId'); + return; + } + + const userRepository = getConnection().getRepository(UserDb); + const user = await userRepository.findOne({ where: { userId } }); + + if (!user || user.role !== UserRole.ADMIN) { + res.status(403).json('Unauthorized user'); + return; + } + const courseRepository = getConnection().getRepository(CourseDb); const course = await courseRepository.findOne({ where: { courseCode } }); if (course) { diff --git a/backend/src/routes/exams.ts b/backend/src/routes/exams.ts index 6aedee9..ee3aaa9 100644 --- a/backend/src/routes/exams.ts +++ b/backend/src/routes/exams.ts @@ -1,14 +1,13 @@ // Imports import { Request, Response, Router } from 'express'; // Import Request and Response types -import { ExamBodyParams, ExamRouteParams } from '../types'; +import { ExamBodyParams, ExamRouteParams, UserRole } from '../types'; -import { getConnection } from '../db/index'; +import { getConnection } from '../db'; import { Course as CourseDb } from '../db/Course'; import { Exam as ExamDb } from '../db/Exam'; import { Question as QuestionDb } from '../db/Questions'; +import { User as UserDb } from '../db/User'; -// Export Routers -export const router = Router(); export async function postExam(req: Request, res: Response) { const { @@ -16,6 +15,7 @@ export async function postExam(req: Request, res: Resp examSemester, examType, courseCode, + userId, } = req.body; // Check key @@ -24,6 +24,19 @@ export async function postExam(req: Request, res: Resp return; } + if (!userId) { + res.status(400).json('Missing userId'); + return; + } + + const userRepository = getConnection().getRepository(UserDb); + const user = await userRepository.findOne({ where: { userId } }); + + if (!user || user.role !== UserRole.ADMIN) { + res.status(403).json('Unauthorized user'); + return; + } + // Check course code const courseRepository = getConnection().getRepository(CourseDb); const course = await courseRepository.findOne({ where: { courseCode } }); diff --git a/backend/src/routes/routes.ts b/backend/src/routes/routes.ts index 24c8c5a..c43e972 100644 --- a/backend/src/routes/routes.ts +++ b/backend/src/routes/routes.ts @@ -17,7 +17,7 @@ import { getExamInfo, getExamQuestions, postExam } from './exams'; import { editQuestion, getQuestion, getQuestionComments, postQuestion } from './questions'; -import { postUser } from './users'; +import { getUserRole, postUser } from './users'; import { EVAN, healthCheck } from './health'; @@ -105,6 +105,9 @@ router.post('/courses', postCourse); * */ +// Gets a user's role +router.get('/users/:userId/role', getUserRole); + // Gets comment by comment id router.get('/comments/:commentId', getComment); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 53136e4..0a344ec 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -29,3 +29,24 @@ export async function postUser(req: Request, res: Response) { res.status(201).json('User Added'); } + +export async function getUserRole(req: Request, res: Response) { + const { userId } = req.params; + + if (!userId) { + res.status(400).json('Missing userId'); + return; + } + + const userRepository = getConnection().getRepository(UserDb); + + // Check for user + const user = await userRepository.findOne({ where: { userId } }); + + if (!user) { + res.status(404).json('User not found'); + return; + } + + res.status(200).json(user.role); +} \ No newline at end of file diff --git a/backend/src/types.ts b/backend/src/types.ts index fcd86c4..f7ac330 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -61,13 +61,16 @@ export type QuestionQueryParams = { export type ExamBodyParams = Partial> & { courseCode?: string + userId?: string } export type ExamRouteParams = { examId: number } -export type CourseBodyParams = Course +export type CourseBodyParams = Course & { + userId?: string +} export type CourseRouteParams = { courseCode: string @@ -82,3 +85,8 @@ export type RateObject = { courseCode: string stars: number } + +export enum UserRole { + USER = 0, + ADMIN = 1, +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b5c71c8..c5535a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", + "react-hook-form": "^7.51.5", "react-hot-toast": "^2.4.1", "remark": "^15.0.1", "remark-gfm": "^4.0.0", @@ -6684,6 +6685,21 @@ "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" } }, + "node_modules/react-hook-form": { + "version": "7.51.5", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.5.tgz", + "integrity": "sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-hot-toast": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0086486..70789f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", + "react-hook-form": "^7.51.5", "react-hot-toast": "^2.4.1", "remark": "^15.0.1", "remark-gfm": "^4.0.0", diff --git a/frontend/src/api/usePostCourse.ts b/frontend/src/api/usePostCourse.ts new file mode 100644 index 0000000..d92d034 --- /dev/null +++ b/frontend/src/api/usePostCourse.ts @@ -0,0 +1,41 @@ +'use client'; +import { useSWRConfig } from 'swr'; +import toast from 'react-hot-toast'; +import { AddCourseFormFields } from '@/types'; + +const ENDPOINT = `${process.env.API_URL}/api`; + +export default function usePostCourse(onSuccess?: () => void) { + // use the global mutate + const { mutate } = useSWRConfig(); + + const postCourse = async (userId: string, course: AddCourseFormFields) => { + // send the course data + const res = await fetch(`${ENDPOINT}/courses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + courseCode: course.courseCode, + courseName: course.courseName, + courseDescription: course.courseDescription, + university: course.university, + }), + }); + + if (res.ok) { + toast.success('Added course', { id: 'coursePost' }); + // course successfully created, invalidate the courses cache so it refetches updated data + await mutate(ENDPOINT + '/courses'); + onSuccess?.(); + } else { + toast.error('Error adding course', { id: 'coursePostError' }); + } + }; + + return { + postCourse, + }; +} \ No newline at end of file diff --git a/frontend/src/api/usePostExam.ts b/frontend/src/api/usePostExam.ts new file mode 100644 index 0000000..29fcc88 --- /dev/null +++ b/frontend/src/api/usePostExam.ts @@ -0,0 +1,39 @@ +'use client'; +import { useSWRConfig } from 'swr'; +import toast from 'react-hot-toast'; +import { AddExamFormFields } from '@/types'; + +const ENDPOINT = `${process.env.API_URL}/api`; + +export default function usePostExam(courseCode: string, onSuccess?: () => void) { + // use the global mutate + const { mutate } = useSWRConfig(); + + const postExam = async (userId: string, exam: AddExamFormFields) => { + // send the exam data + const res = await fetch(`${ENDPOINT}/exams`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + courseCode, + ...exam, + }), + }); + + if (res.ok) { + toast.success('Added exam', { id: 'examPost' }); + // exam successfully created, invalidate the course's exam cache so it refetches updated data + await mutate(ENDPOINT + '/courses/' + courseCode + '/exams'); + onSuccess?.(); + } else { + toast.error('Error adding exam', { id: 'examPostError' }); + } + }; + + return { + postExam, + }; +} \ No newline at end of file diff --git a/frontend/src/api/useRecentChanges.ts b/frontend/src/api/useRecentChanges.ts index 7bae3c7..fef9846 100644 --- a/frontend/src/api/useRecentChanges.ts +++ b/frontend/src/api/useRecentChanges.ts @@ -2,25 +2,32 @@ import useSWR, { Fetcher } from 'swr'; import { RecentChange } from '@/types'; import { usePinned } from '@/api/usePins'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import toast from 'react-hot-toast'; const ENDPOINT = `${process.env.API_URL}/api/recent_changes`; -const fetcher: Fetcher = async (...args) => { - const res = await fetch(...args); - if (!res.ok) { - throw new Error(await res.json()); - } - - return res.json(); -}; export default function useRecentChanges() { const lastVisited: string | null = localStorage.getItem('lastVisited'); const { pinned } = usePinned(); + const fetcher: Fetcher = useCallback(async (...args) => { + if (!pinned.length) { + return []; + } + + const res = await fetch(...args); + + if (!res.ok) { + throw new Error(await res.json()); + } + + return res.json(); + }, [pinned]); + + let url = ENDPOINT + `?lastVisited=${lastVisited}`; pinned.forEach((p) => { url += `&courseCodes=${p.code}`; diff --git a/frontend/src/api/useUser.ts b/frontend/src/api/useUser.ts new file mode 100644 index 0000000..ebd2ad8 --- /dev/null +++ b/frontend/src/api/useUser.ts @@ -0,0 +1,33 @@ +'use client'; +import useSWR, { Fetcher } from 'swr'; +import { UserRole } from '@/types'; +import { useEffect } from 'react'; +import toast from 'react-hot-toast'; + +const ENDPOINT = `${process.env.API_URL}/api/users/`; + +const fetcher: Fetcher = async (...args) => { + const res = await fetch(...args); + + if (!res.ok) { + throw new Error(await res.json()); + } + + return res.json(); +}; + +export default function useUserRole(userId: string) { + const { data, error, isLoading } = useSWR(ENDPOINT + userId +'/role', fetcher); + + useEffect(() => { + if (error) { + toast.error('Error loading user role', { id: 'userRoleError' }); + } + }, [error]); + + return { + userRole: data, + isLoading, + isError: error, + }; +} \ No newline at end of file diff --git a/frontend/src/app/courses/[courseCode]/exams/add/page.tsx b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx new file mode 100644 index 0000000..de033dd --- /dev/null +++ b/frontend/src/app/courses/[courseCode]/exams/add/page.tsx @@ -0,0 +1,33 @@ +'use client'; +import requireRole from '@/app/requireRole'; +import requireAuth from '@/app/requireAuth'; +import { UserRole } from '@/types'; +import Title from '@/components/Title'; +import AddExamForm from '@/components/Exams/AddExamForm'; +import { useUser } from '@auth0/nextjs-auth0/client'; +import { useRouter } from 'next/navigation'; +import usePostExam from '@/api/usePostExam'; + +function AddExam({ params }: { params: { courseCode: string } }) { + const { user } = useUser(); + const router = useRouter(); + const { postExam } = usePostExam(params.courseCode, () => { + router.push(`/courses/${params.courseCode}`); + }); + + return ( +
+
+ + <AddExamForm + onSubmit={async (data) => { + console.log(data); + await postExam(user?.sub || '', data); + }} + /> + </div> + </main> + ); +} + +export default requireAuth(requireRole(AddExam, UserRole.ADMIN)); \ No newline at end of file diff --git a/frontend/src/app/courses/[courseCode]/page.tsx b/frontend/src/app/courses/[courseCode]/page.tsx index a0c34b7..bbc4f20 100644 --- a/frontend/src/app/courses/[courseCode]/page.tsx +++ b/frontend/src/app/courses/[courseCode]/page.tsx @@ -1,15 +1,16 @@ 'use client'; import useCourse from '@/api/useCourse'; import useExams from '@/api/useExams'; -import { Course, Exam } from '@/types'; +import { Course, Exam, UserRole } from '@/types'; import requireAuth from '@/app/requireAuth'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { IconStarFilled } from '@tabler/icons-react'; +import { IconCirclePlus, IconStarFilled } from '@tabler/icons-react'; import Card from '@/components/Card'; import { useUser } from '@auth0/nextjs-auth0/client'; import useUpdateCourse from '@/api/useUpdateCourse'; import Link from 'next/link'; import Title from '@/components/Title'; +import requireRole from '@/app/requireRole'; function CourseDescription({ course }: { course: Course }) { @@ -42,6 +43,21 @@ function CourseExams({ course }: { course: Course }) { const groupedExams = useMemo(() => exams ? mapExamsBySemester(exams) : {}, [exams]); + const AddExam = requireRole( + () => ( + <Link href={`/courses/${course.courseCode}/exams/add`}> + <Card> + <div className="flex flex-col items-center justify-center"> + <p className="flex items-center justify-center gap-1 text-md text-gray-500 dark:text-gray-400 font-medium"> + Add Exam <IconCirclePlus/> + </p> + </div> + </Card> + </Link> + ), + UserRole.ADMIN, + ); + return ( <div> {isLoading && ( @@ -95,9 +111,10 @@ function CourseExams({ course }: { course: Course }) { </div> </div> ))} + <AddExam/> </div> ) : ( - <p className="text-xl text-zinc-900 dark:text-white">No exams available</p> + <AddExam/> ) )} </div> diff --git a/frontend/src/app/courses/add/page.tsx b/frontend/src/app/courses/add/page.tsx new file mode 100644 index 0000000..4e26f3c --- /dev/null +++ b/frontend/src/app/courses/add/page.tsx @@ -0,0 +1,33 @@ +'use client'; +import requireRole from '@/app/requireRole'; +import requireAuth from '@/app/requireAuth'; +import { UserRole } from '@/types'; +import AddCourseForm from '@/components/Courses/AddCourseForm'; +import Title from '@/components/Title'; +import usePostCourse from '@/api/usePostCourse'; +import { useRouter } from 'next/navigation'; +import { useUser } from '@auth0/nextjs-auth0/client'; + +function AddCourse(){ + const { user } = useUser(); + const router = useRouter(); + const { postCourse } = usePostCourse(() => { + router.push('/courses'); + }); + + return ( + <main> + <div className="max-w-4xl w-full mx-auto flex flex-col items-center justify-center"> + <Title title="Add Course"/> + <AddCourseForm + onSubmit={async (data) => { + console.log(data); + await postCourse(user?.sub || '', data); + }} + /> + </div> + </main> + ); +} + +export default requireAuth(requireRole(AddCourse, UserRole.ADMIN)); \ No newline at end of file diff --git a/frontend/src/app/courses/page.tsx b/frontend/src/app/courses/page.tsx index 1b4067a..ce440ae 100644 --- a/frontend/src/app/courses/page.tsx +++ b/frontend/src/app/courses/page.tsx @@ -1,13 +1,16 @@ 'use client'; import useCourses from '@/api/useCourses'; import Card from '@/components/Card'; -import { IconCirclePlus } from '@tabler/icons-react'; import CourseCard from '@/components/CourseCard'; import { backendCourseToFrontend } from '@/lib/courseUtils'; import requireAuth from '../requireAuth'; import { usePinned } from '@/api/usePins'; import { useMemo } from 'react'; import Title from '@/components/Title'; +import requireRole from '@/app/requireRole'; +import { UserRole } from '@/types'; +import { IconCirclePlus } from '@tabler/icons-react'; +import Link from 'next/link'; function Courses() { const { courses, isError, isLoading } = useCourses(); @@ -29,13 +32,28 @@ function Courses() { }), [courses, pinned]); + const AddCourse = requireRole( +() => ( + <Link href="/courses/add"> + <Card> + <div className="min-h-[100px] flex flex-col items-center justify-center"> + <p className="flex items-center justify-center gap-1 text-md text-gray-500 dark:text-gray-400 font-medium"> + Add Course <IconCirclePlus/> + </p> + </div> + </Card> + </Link> + ), + UserRole.ADMIN, + ); + return ( <main> <div className="max-w-4xl w-full mx-auto flex flex-col items-center justify-center"> <Title title="Courses"/> {isLoading && ( <div role="status" className="absolute left-[50%] top-[50%]"> - <svg aria-hidden="true" + <svg aria-hidden="true" className="inline w-16 h-16 text-gray-200 animate-spin dark:text-gray-600 fill-emerald-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> <path @@ -58,9 +76,10 @@ function Courses() { key={course.code} /> ))} + <AddCourse /> </div> ) : ( - <p className="text-xl text-zinc-900 dark:text-white">No courses available</p> + <AddCourse/> )} </div> </main> diff --git a/frontend/src/app/requireRole.tsx b/frontend/src/app/requireRole.tsx new file mode 100644 index 0000000..1c4501f --- /dev/null +++ b/frontend/src/app/requireRole.tsx @@ -0,0 +1,16 @@ +import useUserRole from '@/api/useUser'; +import { UserRole } from '@/types'; +import { useUser } from '@auth0/nextjs-auth0/client'; + +export default function requireRole(WrappedComponent: React.ComponentType<any>, role: UserRole) { + const ComponentWithRole = (props: any) => { + const { user } = useUser(); + const { userRole } = useUserRole(user?.sub || ''); + + return userRole === role ? <WrappedComponent {...props} /> : null; + }; + + ComponentWithRole.displayName = `requireRole(${WrappedComponent.displayName || WrappedComponent.name || 'Component'}, ${role})`; + + return ComponentWithRole; +} \ No newline at end of file diff --git a/frontend/src/components/Courses/AddCourseForm.tsx b/frontend/src/components/Courses/AddCourseForm.tsx new file mode 100644 index 0000000..2da2511 --- /dev/null +++ b/frontend/src/components/Courses/AddCourseForm.tsx @@ -0,0 +1,36 @@ +import { useForm } from 'react-hook-form'; +import Input from '@/components/Input'; +import Select from '@/components/Select'; +import TextArea from '@/components/TextArea'; +import { Button } from '@/components/Button'; +import { AddCourseFormFields } from '@/types'; + +const universityOptions = [ + { label: 'University of Queensland', value: 'UQ' }, +]; + +export default function AddCourseForm({ onSubmit }: { onSubmit: (data: AddCourseFormFields) => void }) { + const { register, handleSubmit } = useForm<AddCourseFormFields>(); + + return ( + <form + className="w-full" + onSubmit={handleSubmit(onSubmit)} + > + <Input label="Course Code" {...register('courseCode')} /> + <Input label="Course Name" {...register('courseName')} /> + <TextArea label="Course Description" {...register('courseDescription')} /> + <Select + label="University" + options={universityOptions} {...register('university')} + /> + <Button + type="submit" + className="w-full" + > + Submit + </Button> + </form> + ); +} + diff --git a/frontend/src/components/Exams/AddExamForm.tsx b/frontend/src/components/Exams/AddExamForm.tsx new file mode 100644 index 0000000..715b92a --- /dev/null +++ b/frontend/src/components/Exams/AddExamForm.tsx @@ -0,0 +1,44 @@ +import { useForm } from 'react-hook-form'; +import Input from '@/components/Input'; +import Select from '@/components/Select'; +import { Button } from '@/components/Button'; +import { AddExamFormFields } from '@/types'; + +const examTypeOptions = [ + { label: 'Final', value: 'final' }, + { label: 'Midsem', value: 'midsem' }, + { label: 'Quiz', value: 'quiz' }, +]; +const examSemesterOptions = [ + { label: 'Semester 1', value: 1 }, + { label: 'Semester 2', value: 2 }, + { label: 'Summer Semester', value: 3 }, +]; + +export default function AddExamForm({ onSubmit }: { onSubmit: (data: AddExamFormFields) => void }) { + const { register, handleSubmit } = useForm<AddExamFormFields>(); + + return ( + <form + className="w-full" + onSubmit={handleSubmit(onSubmit)} + > + <Input label="Year" {...register('examYear')} /> + <Select + label="Semester" + options={examSemesterOptions} {...register('examSemester')} + /> + <Select + label="Type" + options={examTypeOptions} {...register('examType')} + /> + <Button + type="submit" + className="w-full" + > + Submit + </Button> + </form> + ); +} + diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx new file mode 100644 index 0000000..75382d5 --- /dev/null +++ b/frontend/src/components/Input.tsx @@ -0,0 +1,29 @@ +import { forwardRef, InputHTMLAttributes } from 'react'; + + +const Input = forwardRef<HTMLInputElement, { label: string } & InputHTMLAttributes<HTMLInputElement>>(({ + label, + ...inputProps +}, ref) => { + return ( + <div className="mb-5"> + {!!label && ( + <label + htmlFor={inputProps.id} + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + {label} + </label> + )} + <input + {...inputProps} + className="bg-transparent border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-emerald-500 focus:border-emerald-500 block w-full p-2.5 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-emerald-500 dark:focus:border-emerald-500" + ref={ref} + /> + </div> + ); +}); + +Input.displayName = 'Input'; + +export default Input; \ No newline at end of file diff --git a/frontend/src/components/Select.tsx b/frontend/src/components/Select.tsx new file mode 100644 index 0000000..8e1e34f --- /dev/null +++ b/frontend/src/components/Select.tsx @@ -0,0 +1,42 @@ +import { forwardRef, SelectHTMLAttributes } from 'react'; + +const Select = forwardRef<HTMLSelectElement, { + label: string, + options: { label: string, value: any }[] +} & SelectHTMLAttributes<HTMLSelectElement>>(({ + label, + options, + ...inputProps +}, ref) => { + return ( + <div className="mb-5"> + {!!label && ( + <label + htmlFor={inputProps.id} + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + {label} + </label> + )} + <select + {...inputProps} + ref={ref} + className="bg-transparent border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-emerald-500 focus:border-emerald-500 block w-full p-2.5 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-emerald-500 dark:focus:border-emerald-500" + > + {options.map((opt) => ( + <option + key={opt.value} + value={opt.value} + className="bg-white dark:bg-gray-800 dark:text-white" + > + {opt.label} + </option> + ))} + </select> + </div> + ); +}); + +Select.displayName = 'Select'; + +export default Select; \ No newline at end of file diff --git a/frontend/src/components/TextArea.tsx b/frontend/src/components/TextArea.tsx new file mode 100644 index 0000000..dc73376 --- /dev/null +++ b/frontend/src/components/TextArea.tsx @@ -0,0 +1,29 @@ +import { forwardRef, TextareaHTMLAttributes } from 'react'; + + +const TextArea = forwardRef<HTMLTextAreaElement, { label: string } & TextareaHTMLAttributes<HTMLTextAreaElement>>(({ + label, + ...inputProps +}, ref) => { + return ( + <div className="mb-5"> + {!!label && ( + <label + htmlFor={inputProps.id} + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + {label} + </label> + )} + <textarea + {...inputProps} + className="block p-2.5 w-full text-sm text-gray-900 bg-transparent rounded-lg border border-gray-300 focus:ring-emerald-500 focus:border-emerald-500 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-emerald-500 dark:focus:border-emerald-500" + ref={ref} + /> + </div> + ); +}); + +TextArea.displayName = 'TextArea'; + +export default TextArea; \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a6718c9..3154bc5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -52,4 +52,23 @@ export type DisplayCourse = { export type RecentChange = { courseCode: Course['courseCode'] exams: Array<Exam & { changes: number }> -} \ No newline at end of file +} + +export enum UserRole { + USER = 0, + ADMIN = 1, +} + +export type AddCourseFormFields = { + courseCode: string + courseName: string + courseDescription: string + university: string +} + + +export type AddExamFormFields = { + examYear: number + examSemester: number + examType: string +} diff --git a/integration_tests/Dockerfile b/integration_tests/Dockerfile index 08285a0..0d7e105 100644 --- a/integration_tests/Dockerfile +++ b/integration_tests/Dockerfile @@ -17,4 +17,4 @@ RUN playwright install chromium COPY . tests # Run the unit tests -CMD bash -c "sleep 10 && python -m pytest -v --report-log report.log tests" \ No newline at end of file +CMD bash -c "sleep 10 && python -m pytest -k \"not test_e2e\" -v --report-log report.log tests" \ No newline at end of file diff --git a/integration_tests/backend/test_courses.py b/integration_tests/backend/test_courses.py index 28b4e7d..1d04e1f 100644 --- a/integration_tests/backend/test_courses.py +++ b/integration_tests/backend/test_courses.py @@ -76,7 +76,8 @@ def setUp(self): # Creates a new user userData = { - "userId": self.USER_ID + "userId": self.USER_ID, + "role": 1 # admin role } newUser = self.User(**userData) @@ -118,6 +119,7 @@ def test_course_post(self): Checks for the correct response message """ courseData = { + "userId": self.USER_ID, "courseCode": "COMP3506", "courseName": "Data Structures and Algorithms", "courseDescription": "Doing some DSA", @@ -148,6 +150,7 @@ def test_course_post_null_coursecode(self): Checks for the correct response message """ courseData = { + "userId": self.USER_ID, "courseCode": "", "courseName": "Software Architecture", "courseDescription": "Doing some software architecture stuff with Richard and Evan (my bestie)", @@ -173,6 +176,7 @@ def test_course_post_duplicate_coursecode(self): Checks for the correct response message """ courseData = { + "userId": self.USER_ID, "courseCode": "CSSE6400", "courseName": "Software Architecture", "courseDescription": "Doing some software architecture stuff with Richard and Evan (my bestie)", diff --git a/integration_tests/backend/test_exams.py b/integration_tests/backend/test_exams.py index 280ac8b..f0267d7 100644 --- a/integration_tests/backend/test_exams.py +++ b/integration_tests/backend/test_exams.py @@ -30,6 +30,15 @@ def setUp(self): self.COMMENT_PNG = None self.PARENT_COMMENT_ID = None + # Creates a new user + userData = { + "userId": self.USER_ID, + "role": 1 # admin role + } + + newUser = self.User(**userData) + self.session.add(newUser) + # Create course courseData = { "courseCode": self.COURSE_CODE, @@ -94,6 +103,7 @@ def test_exam_post(self): Checks for the correct response message """ body = { + "userId": self.USER_ID, "examYear": self.EXAM_YEAR, "examSemester": self.EXAM_SEMESTER, "examType": self.EXAM_TYPE, @@ -118,6 +128,7 @@ def test_exam_post_missing_courseCode(self): Checks for the correct response message """ body = { + "userId": self.USER_ID, "examYear": self.EXAM_YEAR, "examSemester": self.EXAM_SEMESTER, "examType": self.EXAM_TYPE @@ -140,6 +151,7 @@ def test_exam_post_course_not_found(self): Checks for the correct response message """ body = { + "userId": self.USER_ID, "examYear": self.EXAM_YEAR, "examSemester": self.EXAM_SEMESTER, "examType": self.EXAM_TYPE, diff --git a/integration_tests/backend/test_full_suite.py b/integration_tests/backend/test_full_suite.py index f470838..1762962 100644 --- a/integration_tests/backend/test_full_suite.py +++ b/integration_tests/backend/test_full_suite.py @@ -78,8 +78,19 @@ def test_full_1(self): response = requests.post(self.host() + '/users', json=user) self.assertEqual(201, response.status_code) + # Give them admin perms (has to be done manually for ~security~ reasons) + created_user = self.session.query(self.User).filter_by( + userId=str(self.USER_ID)).first() + created_user.role = 1 + self.session.commit() + + # Check they have the correct role + response = requests.get(self.host() + '/users/' + str(self.USER_ID) + '/role') + self.assertEqual(200, response.status_code) + self.assertEqual(1, response.json()) + # Create a course - response = requests.post(self.host() + '/courses', json=course) + response = requests.post(self.host() + '/courses', json={**course, "userId": self.USER_ID}) self.assertEqual(201, response.status_code) # Check the course params set correctly @@ -106,7 +117,7 @@ def test_full_1(self): } # Create the exam - response = requests.post(self.host() + '/exams', json=exam) + response = requests.post(self.host() + '/exams', json={**exam, "userId": self.USER_ID}) self.assertEqual(201, response.status_code) # Testing examId returned and is a valid int examId = response.json()['examId'] diff --git a/integration_tests/frontend/test_full_e2e.py b/integration_tests/frontend/test_full_e2e.py new file mode 100644 index 0000000..211dcef --- /dev/null +++ b/integration_tests/frontend/test_full_e2e.py @@ -0,0 +1,200 @@ +import re + +from playwright.sync_api import sync_playwright, expect + +from ..base import BaseCase + +""" +This test can only be run locally since it depends on the frontend +being run using npm and having auth0 credentials setup +""" + + +class FullE2E(BaseCase): + def setUp(self): + self.p = sync_playwright().start() + self.browser = self.p.chromium.launch() + self.context = self.browser.new_context(color_scheme="dark") + self.page = self.context.new_page() + self.page.goto("http://localhost:3000") + self.session = self.get_db_session() + self.User = self.Base.classes['user'] + + # Creates the admin user in db + admin_user_data = { + "userId": "auth0|6650788478a66f328f8351b4", + "role": 1 # admin role + } + + admin_user = self.User(**admin_user_data) + self.session.add(admin_user) + + # Creates the regular user in db + regular_user_data = { + "userId": "auth0|6650784678a66f328f835197", + "role": 0 # regular user role + } + + regular_user = self.User(**regular_user_data) + self.session.add(regular_user) + + self.session.commit() + + def tearDown(self): + self.page.close() + self.context.close() + self.browser.close() + self.p.stop() + + def test_e2e(self): + # Sign in as admin user + self.page.get_by_role("link", name="Sign in").click() + self.page.get_by_label("Email address*").click() + self.page.get_by_label("Email address*").fill("admin@unibasement.local") + self.page.get_by_label("Password*").click() + self.page.get_by_label("Password*").fill("Admin_Password") + self.page.get_by_role("button", name="Continue", exact=True).click() + self.page.wait_for_url("http://localhost:3000/") + + # Check that the user is redirected to the home page which shows a greeting + # <greeting>, admin + expect(self.page.locator("body")).to_contain_text(", admin") + + # Go to courses + self.page.get_by_role("link", name="Courses").click() + self.page.wait_for_url("http://localhost:3000/courses") + + # Expect to see the add courses button as admin + expect(self.page.get_by_role("link", name="Add Course")).to_be_visible() + + # Add a course + self.page.get_by_role("link", name="Add Course").click() + self.page.wait_for_url("http://localhost:3000/courses/add") + expect(self.page.get_by_text("Add Course")).to_be_visible() + + # Fill in form + self.page.locator("input[name=\"courseCode\"]").click() + self.page.locator("input[name=\"courseCode\"]").fill("CSSE6400") + self.page.locator("input[name=\"courseName\"]").click() + self.page.locator("input[name=\"courseName\"]").fill("Software Architecture") + self.page.locator("textarea[name=\"courseDescription\"]").click() + self.page.locator("textarea[name=\"courseDescription\"]").fill("My favourite course :)") + self.page.get_by_role("button", name="Submit").click() + + # Expect to be redirected to the courses page on submit + self.page.wait_for_url("http://localhost:3000/courses") + + # Expect to see the course on the courses page + expect(self.page.get_by_role("link", name="CSSE6400 Software Architecture")).to_be_visible() + self.page.get_by_role("link", name="CSSE6400 Software Architecture").click() + + # Expect to be redirected to the CSSE6400 page + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400") + + # Expect to see the course description + expect(self.page.get_by_text("My favourite course :)")).to_be_visible() + + # Go to past exams + self.page.get_by_role("button", name="Past Exams").click() + + # Expect to see the add exam button as admin + expect(self.page.get_by_role("link", name="Add Exam")).to_be_visible() + + # Add an exam + self.page.get_by_role("link", name="Add Exam").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400/exams/add") + expect(self.page.get_by_text("Add Exam")).to_be_visible() + + # Fill in form + self.page.get_by_role("textbox").click() + self.page.get_by_role("textbox").fill("2023") + self.page.locator("select[name=\"examSemester\"]").select_option("1") + self.page.locator("select[name=\"examType\"]").select_option("final") + self.page.get_by_role("button", name="Submit").click() + + # Expect to be redirected to the CSSE6400 page on submit + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400") + self.page.get_by_role("button", name="Past Exams").click() + + # Expect to see the exam on the past exams page + expect(self.page.get_by_role("heading", name="2023")).to_be_visible() + expect(self.page.get_by_role("heading", name="Semester")).to_be_visible() + expect(self.page.get_by_role("link", name="final")).to_be_visible() + + # Go to the exam page + self.page.get_by_role("link", name="final").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400/exams/1") + + # Expect to be able to add a question + expect(self.page.get_by_role("button", name="Add Question")).to_be_visible() + + # Add a question + self.page.get_by_role("button", name="Add Question").click() + self.page.get_by_placeholder("Add an answer...").fill("This is a test question.") + self.page.get_by_role("button", name="Post").click() + + # Add an answer + self.page.get_by_role("button", name="Answers").click() + self.page.get_by_role("button", name="Add Answer").click() + self.page.get_by_placeholder("Add an answer...").fill("An example answer") + self.page.get_by_role("button", name="Post").click() + + # Logout as admin + self.page.get_by_text("admin").click() + self.page.get_by_role("link", name="Logout").click() + self.page.wait_for_url("http://localhost:3000/") + + # Sign in as regular user + self.page.get_by_role("link", name="Sign in").click() + self.page.get_by_label("Email address*").click() + self.page.get_by_label("Email address*").fill("user@unibasement.local") + self.page.get_by_label("Password*").click() + self.page.get_by_label("Password*").fill("User_Password") + self.page.get_by_role("button", name="Continue", exact=True).click() + self.page.wait_for_url("http://localhost:3000/") + + # Check that the user is redirected to the home page which shows a greeting + # <greeting>, user + expect(self.page.locator("body")).to_contain_text(", user") + + # Go to courses & find the CSSE6400 course + self.page.get_by_role("link", name="Courses").click() + self.page.wait_for_url("http://localhost:3000/courses") + expect(self.page.get_by_text("Add Course")).not_to_be_visible() + expect(self.page.get_by_role("link", name="CSSE6400 Software Architecture")).to_be_visible() + self.page.get_by_role("link", name="CSSE6400 Software Architecture").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400") + + # Check rating the course + self.page.get_by_role("button", name="Rate The Course").click() + expect(self.page.locator("body")).to_contain_text("0 rating") + self.page.locator("div").filter( + has_text=re.compile(r"^DescriptionPast ExamsRate The CourseLeave your rating:$")).locator("path").nth( + 4).click() + expect(self.page.locator("body")).to_contain_text("1 rating") + + # Go to exams + self.page.get_by_role("button", name="Past Exams").click() + expect(self.page.get_by_text("Add Exam")).not_to_be_visible() + expect(self.page.get_by_role("link", name="final")).to_be_visible() + + # Go to the exam page + self.page.get_by_role("link", name="final").click() + self.page.wait_for_url("http://localhost:3000/courses/CSSE6400/exams/1") + + # Expect to see the question + expect(self.page.get_by_text("This is a test question.")).to_be_visible() + + # Expect to see the answer + self.page.get_by_role("button", name="Answers").click() + expect(self.page.get_by_text("An example answer")).to_be_visible() + + # Check upvoting the answer + expect(self.page.get_by_text("1", exact=True)).not_to_be_visible() + self.page.locator("#accordion-collapse-body").get_by_role("button").nth(1).click() + expect(self.page.get_by_text("1", exact=True)).to_be_visible() + + # Logout as regular user + self.page.get_by_text("user").click() + self.page.get_by_role("link", name="Logout").click() + self.page.wait_for_url("http://localhost:3000/")