Skip to content

Commit

Permalink
feat: implement support for jwt in API requests (#28)
Browse files Browse the repository at this point in the history
* feat: implement basic login page with token fetching

* feat: implement basic displaying of login error

* feat: implement authentication hook with wrapper component

* refactor: remove spurious log

* feat: use form for login endpoint payload

* feat: redirect to frontcover on successful login

* chore: configure dummy secret key for dev

* fix: fix navigation

* feat: implement automatic redirection to original route after login

* refactor: use assessmentID in MarkingPage and improve router
  • Loading branch information
procaconsul authored Aug 9, 2024
1 parent 38181d4 commit 0865c10
Show file tree
Hide file tree
Showing 15 changed files with 282 additions and 71 deletions.
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

0 comments on commit 0865c10

Please sign in to comment.