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/ })