diff --git a/dev.local.docker-compose.yml b/dev.local.docker-compose.yml index d5dcd76..4ab4aba 100644 --- a/dev.local.docker-compose.yml +++ b/dev.local.docker-compose.yml @@ -9,6 +9,7 @@ services: command: poetry run uvicorn main:app --reload --host 0.0.0.0 --port 5004 environment: - DB_URL=postgresql://user:pass@host.docker.internal/answerbook + - SECRET_KEY=dev_secret_key - MATHPIX_APP_ID - MATHPIX_APP_KEY volumes: diff --git a/package-lock.json b/package-lock.json index 10cd90c..fb0a74c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "class-transformer": "^0.5.1", "classnames": "^2.5.1", "date-fns": "^3.6.0", + "jwt-decode": "^4.0.0", "localforage": "^1.10.0", "match-sorter": "^6.3.4", "react": "^18.3.1", @@ -18502,6 +18503,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/katex": { "version": "0.16.11", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", diff --git a/package.json b/package.json index 8224079..e904b3a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "class-transformer": "^0.5.1", "classnames": "^2.5.1", "date-fns": "^3.6.0", + "jwt-decode": "^4.0.0", "localforage": "^1.10.0", "match-sorter": "^6.3.4", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index dc97d56..8e81d14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,55 @@ import React from 'react' -import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom' +import { Navigate, Outlet, RouterProvider, createBrowserRouter } from 'react-router-dom' +import AuthWrapper from './pages/AuthWrapper' import ExamRoot from './pages/ExamRoot' import FrontCover from './pages/FrontCover' +import LoginPage from './pages/LoginPage' import MarkingPage from './pages/MarkingPage' import QuestionPage from './pages/QuestionPage' +const AuthRoot = () => ( + + + +) + const router = createBrowserRouter([ { - path: '/', - element: , + path: ':year/:moduleCode/:qualifier/', children: [ { - index: true, - element: , - }, - { - path: 'frontcover', - element: , + element: , + children: [ + { + path: 'marking', + element: , + }, + { + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'frontcover', + element: , + }, + { + path: 'questions/:number', + element: , + }, + ], + }, + ], }, { - path: 'questions/:questionId/:username', - element: , + path: 'login', + element: , }, ], }, - { - path: 'marking', - element: , - }, ]) function App() { diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 031d8cf..dcb884f 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -1,6 +1,10 @@ import axios from 'axios' import { camelCase, isArray, isObject, mapKeys, mapValues, snakeCase } from 'lodash' +import { getToken } from '../hooks/authentication' + +export const BASE_URL = process.env.REACT_APP_API_ENTRYPOINT + const convertKeys = (obj, convertFunc) => { if (isArray(obj)) { return obj.map((item) => convertKeys(item, convertFunc)) @@ -14,13 +18,12 @@ const convertKeys = (obj, convertFunc) => { } const axiosInstance = axios.create({ - baseURL: process.env.REACT_APP_API_ENTRYPOINT, + baseURL: BASE_URL, }) axiosInstance.interceptors.request.use((config) => { - if (config.data) { - config.data = convertKeys(config.data, snakeCase) - } + if (config.data) config.data = convertKeys(config.data, snakeCase) + if (getToken()) config.headers.setAuthorization(`Bearer ${getToken()}`) return config }) diff --git a/src/api/routes.ts b/src/api/routes.ts index 92a990e..bf90118 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,11 +1,11 @@ const routes = { - summary: '/summary', - questions: '/questions', - students: '/students', - answers: '/answers', - marks: '/marks', - studentMarks: (studentID: string) => `/${studentID}/marks`, - question: (number: number) => `/questions/${number}`, + summary: (assessmentID: string) => `/${assessmentID}/summary`, + questions: (assessmentID: string) => `/${assessmentID}/questions`, + students: (assessmentID: string) => `/${assessmentID}/students`, + answers: (assessmentID: string) => `/${assessmentID}/answers`, + marks: (assessmentID: string) => `/${assessmentID}/marks`, + login: (assesmentID: string) => `/${assesmentID}/auth/login`, + question: (assessmentID: string, number: number) => `/${assessmentID}/questions/${number}`, questionAnswers: (number: number, username: string) => `/answers/${username}/question/${number}`, getMathPixToken: '/proxy/mathpix-token', } diff --git a/src/components/topBars/ExamNavBar.tsx b/src/components/topBars/ExamNavBar.tsx index 08add6d..865d6b6 100644 --- a/src/components/topBars/ExamNavBar.tsx +++ b/src/components/topBars/ExamNavBar.tsx @@ -1,17 +1,26 @@ import { Container, Section, TabNav } from '@radix-ui/themes' -import React, { FC } from 'react' -import { useLocation, useParams } from 'react-router-dom' - -import { DEFAULT_TEST_USERNAME } from '../../utils/globalConstants' +import { range } from 'lodash' +import React, { FC, useMemo } from 'react' +import { useLocation } from 'react-router-dom' interface NavBarProps { questionCount: number } +enum Pages { + QUESTION, + FRONTCOVER, +} + +function pathToPage(pathname: string): Pages { + if (pathname.includes('questions')) return Pages.QUESTION + return Pages.FRONTCOVER +} + const ExamNavBar: FC = ({ questionCount }) => { const { pathname } = useLocation() - - const { username = DEFAULT_TEST_USERNAME } = useParams() + const currentPage = useMemo(() => pathToPage(pathname), [pathname]) + const linkPrefix = useMemo(() => (currentPage === Pages.QUESTION ? '../' : ''), [currentPage]) return (
= ({ questionCount }) => { > - + Frontcover - {[...Array(questionCount).keys()].map((i) => ( + {range(1, questionCount + 1).map((q) => ( - {`Question ${i + 1}`} + {`Question ${q}`} ))} diff --git a/src/hooks/assessmentParams.ts b/src/hooks/assessmentParams.ts new file mode 100644 index 0000000..1a34cce --- /dev/null +++ b/src/hooks/assessmentParams.ts @@ -0,0 +1,8 @@ +import { useParams } from 'react-router-dom' + +export const useAssessmentParams = () => { + const { year, moduleCode, qualifier } = useParams() + return { + assessmentID: `y${year}_${moduleCode}_${qualifier}`, + } +} diff --git a/src/hooks/authentication.ts b/src/hooks/authentication.ts new file mode 100644 index 0000000..35bbaeb --- /dev/null +++ b/src/hooks/authentication.ts @@ -0,0 +1,53 @@ +import axios from 'axios' +import { jwtDecode } from 'jwt-decode' +import { toPairs } from 'lodash' +import { useState } from 'react' + +import { BASE_URL } from '../api/axiosInstance' +import routes from '../api/routes' +import { useAssessmentParams } from './assessmentParams' + +export interface Credentials { + username: string + password: string +} + +const ACCESS_TOKEN_KEY = 'answerbook-access-token' + +export function getToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY) +} + +export const useAuthentication = () => { + const { assessmentID } = useAssessmentParams() + const [authError, setAuthError] = useState() + + function hasValidToken(token: string | null) { + if (!token) return false + try { + const decoded = jwtDecode(token) + if (!decoded || !decoded.exp || !decoded.sub) return false + const currentTime = Math.floor(Date.now() / 1000) + return decoded.exp > currentTime && (decoded.sub as any).assessment_code === assessmentID + } catch (error) { + return false + } + } + + function saveToken(token: string) { + localStorage.setItem(ACCESS_TOKEN_KEY, token) + } + + function requestToken(credentials: Credentials) { + const form = new FormData() + toPairs(credentials).forEach(([k, v]) => form.set(k, v)) + return axios + .post(`${BASE_URL}${routes.login(assessmentID)}`, form) + .then(({ data }) => { + saveToken(data.access_token) + }) + .catch((error) => setAuthError(error?.response?.data?.detail ?? 'Authentication failed')) + } + + return { authError, requestToken, hasValidToken } +} diff --git a/src/hooks/exam.ts b/src/hooks/exam.ts index 3194be6..cf307ae 100644 --- a/src/hooks/exam.ts +++ b/src/hooks/exam.ts @@ -7,21 +7,25 @@ import routes from '../api/routes' import { Answer, AnswerMap, Question, Summary } from '../types/exam' import { buildResourceLookupTable } from '../utils/answers' import { DEFAULT_TEST_USERNAME } from '../utils/globalConstants' +import { useAssessmentParams } from './assessmentParams' export const useQuestion = (number: number | undefined) => { + const { assessmentID } = useAssessmentParams() const [question, setQuestion] = useState() const [questionIsLoaded, setQuestionIsLoaded] = useState(false) useEffect(() => { if (number === undefined) return axiosInstance - .get(routes.question(number)) + .get(routes.question(assessmentID, number)) .then(({ data }) => setQuestion(plainToInstance(Question, data))) .finally(() => setQuestionIsLoaded(true)) - }, [number]) + }, [assessmentID, number]) return { question, questionIsLoaded } } export const useQuestionAnswers = (number: number | undefined) => { + const { assessmentID } = useAssessmentParams() + const [answers, setAnswers] = useState([]) const [answersAreLoaded, setAnswersAreLoaded] = useState(false) @@ -33,7 +37,7 @@ export const useQuestionAnswers = (number: number | undefined) => { .get(routes.questionAnswers(number, username)) .then(({ data }) => setAnswers(data.map((d) => plainToInstance(Answer, d)))) .finally(() => setAnswersAreLoaded(true)) - }, [number, username]) + }, [assessmentID, number, username]) const answersLookup: AnswerMap = useMemo(() => { return buildResourceLookupTable(answers, 'answer') @@ -60,19 +64,21 @@ export const useQuestionAnswers = (number: number | undefined) => { const saveAnswers = useCallback(() => { if (number === undefined) return axiosInstance.post(routes.questionAnswers(number, username), answers).then(() => {}) - }, [answers, number, username]) + }, [answers, assessmentID, number, username]) return { lookupAnswer, answersAreLoaded, setAnswer, saveAnswers } } export const useAssessmentSummary = () => { + const { assessmentID } = useAssessmentParams() const [summary, setSummary] = useState() const [summaryIsLoaded, setSummaryIsLoaded] = useState(false) useEffect(() => { + if (!assessmentID) return axiosInstance - .get(routes.summary) + .get(routes.summary(assessmentID)) .then(({ data }) => setSummary(plainToInstance(Summary, data))) .finally(() => setSummaryIsLoaded(true)) - }, []) + }, [assessmentID]) return { summary, summaryIsLoaded } } diff --git a/src/hooks/marking.ts b/src/hooks/marking.ts index ff09536..deb5ca7 100644 --- a/src/hooks/marking.ts +++ b/src/hooks/marking.ts @@ -7,41 +7,45 @@ import routes from '../api/routes' import { Answer, AnswerMap, Question } from '../types/exam' import { MarkMap, MarkRoot, Student } from '../types/marking' import { buildResourceLookupTable } from '../utils/answers' +import { useAssessmentParams } from './assessmentParams' export const useStudents = () => { + const { assessmentID } = useAssessmentParams() const [students, setStudents] = useState([]) const [studentsAreLoaded, setStudentsAreLoaded] = useState(false) useEffect(() => { axiosInstance - .get(routes.students) + .get(routes.students(assessmentID)) .then(({ data }) => setStudents(data.map((d) => plainToInstance(Student, d)))) .finally(() => setStudentsAreLoaded(true)) - }, []) + }, [assessmentID]) return { students, studentsAreLoaded } } export const useQuestions = () => { + const { assessmentID } = useAssessmentParams() const [questions, setQuestions] = useState>() const [questionsAreLoaded, setQuestionsAreLoaded] = useState(false) useEffect(() => { axiosInstance - .get(routes.questions) + .get(routes.questions(assessmentID)) .then(({ data }) => setQuestions(mapValues(data, (q) => plainToInstance(Question, q)))) .finally(() => setQuestionsAreLoaded(true)) - }, []) + }, [assessmentID]) return { questions, questionsAreLoaded } } export const useMarks = () => { + const { assessmentID } = useAssessmentParams() const [marks, setMarks] = useState([]) const [marksAreLoaded, setMarksAreLoaded] = useState(false) useEffect(() => { axiosInstance - .get(routes.marks) + .get(routes.marks(assessmentID)) .then(({ data }) => setMarks(data.map((d) => plainToInstance(MarkRoot, d)))) .finally(() => setMarksAreLoaded(true)) - }, []) + }, [assessmentID]) const rawMarksTable = useMemo(() => groupBy(marks, 'username'), [marks]) @@ -57,7 +61,7 @@ export const useMarks = () => { ) function saveMark(newMark: MarkRoot) { - axiosInstance.post(routes.marks, instanceToPlain(newMark)).then(({ data }) => { + axiosInstance.post(routes.marks(assessmentID), instanceToPlain(newMark)).then(({ data }) => { const newMark = plainToInstance(MarkRoot, data) setMarks((ms) => { const otherMarks = ms.filter((m) => m.id !== newMark.id) @@ -70,14 +74,15 @@ export const useMarks = () => { } export const useAnswers = () => { + const { assessmentID } = useAssessmentParams() const [answers, setAnswers] = useState([]) const [answersAreLoaded, setAnswersAreLoaded] = useState(false) useEffect(() => { axiosInstance - .get(routes.answers) + .get(routes.answers(assessmentID)) .then(({ data }) => setAnswers(data.map((d) => plainToInstance(Answer, d)))) .finally(() => setAnswersAreLoaded(true)) - }, []) + }, [assessmentID]) const answersLookup: { [username: string]: AnswerMap } = useMemo( () => mapValues(groupBy(answers, 'username'), (as) => buildResourceLookupTable(as, 'answer')), diff --git a/src/pages/AuthWrapper.tsx b/src/pages/AuthWrapper.tsx new file mode 100644 index 0000000..5b65a5a --- /dev/null +++ b/src/pages/AuthWrapper.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { Navigate, useLocation } from 'react-router-dom' + +import { getToken, useAuthentication } from '../hooks/authentication' + +const AuthWrapper = ({ children }) => { + const { pathname } = useLocation() + const { hasValidToken } = useAuthentication() + if (!hasValidToken(getToken())) return + return children +} + +export default AuthWrapper diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..a82f221 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,88 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons' +import { + Box, + Button, + Callout, + Card, + Container, + Flex, + Heading, + Section, + Text, + TextField, +} from '@radix-ui/themes' +import React, { FC, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { useAuthentication } from '../hooks/authentication' + +const DEFAULT_REDIRECT = '../frontcover' +const LoginPage: FC = () => { + const { state } = useLocation() + const navigate = useNavigate() + const { authError, requestToken } = useAuthentication() + const initialCreds = { username: '', password: '' } + const [credentials, setCredentials] = useState(initialCreds) + + const handleCredsChange = (key: 'username' | 'password') => (e) => + setCredentials((credentials) => ({ ...credentials, [key]: e.target.value })) + + const handleCredsSubmission = () => + requestToken(credentials).then(() => + navigate(state?.prev ?? DEFAULT_REDIRECT, { relative: 'path' }) + ) + + return ( +
+ + + + + Log in + + + + + Username + + + + + + Password + + + + + + + {authError && ( + + + + + {authError} + + )} + + + + +
+ ) +} + +export default LoginPage diff --git a/src/pages/QuestionPage.tsx b/src/pages/QuestionPage.tsx index 0d6b7e8..9a84d89 100644 --- a/src/pages/QuestionPage.tsx +++ b/src/pages/QuestionPage.tsx @@ -2,9 +2,8 @@ import { Button, Separator } from '@radix-ui/themes' import { instanceToPlain } from 'class-transformer' import { map, sum } from 'lodash' import React, { FC } from 'react' -import { matchPath, useLocation } from 'react-router-dom' +import { useParams } from 'react-router-dom' -import UserSelector from '../components/Selection/UserSelector' import Body from '../components/pageStructure/Body' import Header from '../components/pageStructure/Header' import Part from '../components/questionStructure/Part' @@ -15,29 +14,25 @@ import { useQuestion, useQuestionAnswers } from '../hooks/exam' import { parseAnswer } from '../utils/answers' const QuestionPage: FC = () => { - const { pathname } = useLocation() - const pathMatch = matchPath({ path: '/questions/:number/:username' }, pathname) + const { number } = useParams() - const { question, questionIsLoaded } = useQuestion(Number(pathMatch?.params?.number)) - const { lookupAnswer, setAnswer, saveAnswers } = useQuestionAnswers( - Number(pathMatch?.params?.number) - ) + const { question, questionIsLoaded } = useQuestion(Number(number)) + const { lookupAnswer, setAnswer, saveAnswers } = useQuestionAnswers(Number(number)) const handlerFactory = (question: number, part: number, section: number, task: number) => (newAnswer: string) => { setAnswer(question, part, section, task, newAnswer) } - if (!pathMatch || !questionIsLoaded) return
Placeholder
+ if (!number || !questionIsLoaded) return
Placeholder
if (question === undefined) return
404
- const questionID = Number(pathMatch.params.number) + const questionID = Number(number) return ( <> -
+
- {Object.entries(question.parts).map(([partIDString, part]) => { const partID = Number(partIDString) diff --git a/src/tests/components/NavBar.test.tsx b/src/tests/components/NavBar.test.tsx index 482da47..e067f58 100644 --- a/src/tests/components/NavBar.test.tsx +++ b/src/tests/components/NavBar.test.tsx @@ -4,7 +4,6 @@ import React from 'react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import ExamNavBar from '../../components/topBars/ExamNavBar' -import { DEFAULT_TEST_USERNAME } from '../../utils/globalConstants' describe('NavBar', () => { const renderWithRouter = (ui, { route = '/' } = {}) => { @@ -24,24 +23,23 @@ describe('NavBar', () => { // Check for static link const frontCoverLink = screen.getByRole('link', { name: /frontcover/i }) expect(frontCoverLink).toBeInTheDocument() - expect(frontCoverLink).toHaveAttribute('href', '/frontcover') + expect(frontCoverLink).toHaveAttribute('href', 'frontcover') // Check for dynamic question links for (let i = 1; i <= questionCount; i++) { const questionLink = screen.getByRole('link', { name: new RegExp(`question ${i}`, 'i') }) expect(questionLink).toBeInTheDocument() - expect(questionLink).toHaveAttribute('href', `/questions/${i}/${DEFAULT_TEST_USERNAME}`) } }) it('highlights the correct active link based on the current path', () => { - const activeRoute = `/questions/3/${DEFAULT_TEST_USERNAME}` + const activeRoute = '/questions/3' renderWithRouter(, { route: activeRoute }) // Check for active class on the correct link const activeLink = screen.getByRole('link', { name: /question 3/i }) expect(activeLink).toBeInTheDocument() - expect(activeLink).toHaveAttribute('href', activeRoute) + expect(activeLink).toHaveAttribute('href', `..${activeRoute}`) expect(activeLink).toHaveAttribute('data-active') }) @@ -51,7 +49,7 @@ describe('NavBar', () => { // Check for static link const frontCoverLink = screen.getByRole('link', { name: /frontcover/i }) expect(frontCoverLink).toBeInTheDocument() - expect(frontCoverLink).toHaveAttribute('href', '/frontcover') + expect(frontCoverLink).toHaveAttribute('href', 'frontcover') // Check that no question links are rendered const questionLink = screen.queryByRole('link', { name: /Question \d/ })