Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement support for jwt in API requests #28

Merged
merged 10 commits into from
Aug 9, 2024
1 change: 1 addition & 0 deletions dev.local.docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ services:
command: poetry run uvicorn main:app --reload --host 0.0.0.0 --port 5004
environment:
- DB_URL=postgresql://user:[email protected]/answerbook
- SECRET_KEY=dev_secret_key
- MATHPIX_APP_ID
- MATHPIX_APP_KEY
volumes:
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 36 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<AuthWrapper>
<Outlet />
</AuthWrapper>
)

const router = createBrowserRouter([
{
path: '/',
element: <ExamRoot />,
path: ':year/:moduleCode/:qualifier/',
children: [
{
index: true,
element: <Navigate to="frontcover" replace />,
},
{
path: 'frontcover',
element: <FrontCover />,
element: <AuthRoot />,
children: [
{
path: 'marking',
element: <MarkingPage />,
},
{
element: <ExamRoot />,
children: [
{
index: true,
element: <Navigate to="frontcover" replace />,
},
{
path: 'frontcover',
element: <FrontCover />,
},
{
path: 'questions/:number',
element: <QuestionPage />,
},
],
},
],
},
{
path: 'questions/:questionId/:username',
element: <QuestionPage />,
path: 'login',
element: <LoginPage />,
},
],
},
{
path: 'marking',
element: <MarkingPage />,
},
])

function App() {
Expand Down
11 changes: 7 additions & 4 deletions src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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
})

Expand Down
14 changes: 7 additions & 7 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -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',
}
Expand Down
33 changes: 21 additions & 12 deletions src/components/topBars/ExamNavBar.tsx
Original file line number Diff line number Diff line change
@@ -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<NavBarProps> = ({ questionCount }) => {
const { pathname } = useLocation()

const { username = DEFAULT_TEST_USERNAME } = useParams()
const currentPage = useMemo(() => pathToPage(pathname), [pathname])
const linkPrefix = useMemo(() => (currentPage === Pages.QUESTION ? '../' : ''), [currentPage])

return (
<Section
Expand All @@ -23,16 +32,16 @@ const ExamNavBar: FC<NavBarProps> = ({ questionCount }) => {
>
<Container>
<TabNav.Root size="2">
<TabNav.Link href="/frontcover" active={pathname === '/frontcover'}>
<TabNav.Link href={`${linkPrefix}frontcover`} active={currentPage === Pages.FRONTCOVER}>
Frontcover
</TabNav.Link>
{[...Array(questionCount).keys()].map((i) => (
{range(1, questionCount + 1).map((q) => (
<TabNav.Link
href={`/questions/${i + 1}/${username}`}
key={i}
active={pathname.startsWith(`/questions/${i + 1}`)}
key={q}
href={`${linkPrefix}questions/${q}`}
active={pathname.endsWith(`/questions/${q}`)}
>
{`Question ${i + 1}`}
{`Question ${q}`}
</TabNav.Link>
))}
</TabNav.Root>
Expand Down
8 changes: 8 additions & 0 deletions src/hooks/assessmentParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useParams } from 'react-router-dom'

export const useAssessmentParams = () => {
const { year, moduleCode, qualifier } = useParams()
return {
assessmentID: `y${year}_${moduleCode}_${qualifier}`,
}
}
53 changes: 53 additions & 0 deletions src/hooks/authentication.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
18 changes: 12 additions & 6 deletions src/hooks/exam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Question>()
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<Answer[]>([])
const [answersAreLoaded, setAnswersAreLoaded] = useState(false)

Expand All @@ -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')
Expand All @@ -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<Summary>()
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 }
}
Loading