Skip to content

Commit

Permalink
feat: Portal frontend 40 (#96)
Browse files Browse the repository at this point in the history
* feat: manage otp bypass tokens

* fix: count tokens

* merge from main

* new cfl package

* fix: new cfl package

* feedback
  • Loading branch information
SKairinos authored Feb 26, 2025
1 parent ddfd6be commit 460a583
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 131 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
],
"dependencies": {
"@react-pdf/renderer": "^4.0.0",
"codeforlife": "2.6.10",
"codeforlife": "2.6.11",
"crypto-js": "^4.2.0",
"qrcode": "^1.5.4"
},
Expand Down
2 changes: 1 addition & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createApi } from "codeforlife/api"

const api = createApi({
tagTypes: ["SchoolTeacherInvitation"],
tagTypes: ["SchoolTeacherInvitation", "OtpBypassToken"],
})

export default api
Expand Down
31 changes: 27 additions & 4 deletions src/api/otpBypassToken.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,46 @@
import { urls } from "codeforlife/api"
import { type ListArg, type ListResult, listTag } from "codeforlife/utils/api"
import { type OtpBypassToken, urls } from "codeforlife/api"

import api from "."

export type GenerateOtpBypassTokensResult = string[]
export type ListOtpBypassTokensResult = ListResult<
OtpBypassToken,
never,
{ decrypted_token: string }
>
export type ListOtpBypassTokensArg = ListArg

export type GenerateOtpBypassTokensResult = ListOtpBypassTokensResult["data"]
export type GenerateOtpBypassTokensArg = null

const otpBypassTokenApi = api.injectEndpoints({
endpoints: build => ({
listOtpBypassTokens: build.query<
ListOtpBypassTokensResult,
ListOtpBypassTokensArg
>({
query: () => ({
url: urls.otpBypassToken.list,
method: "GET",
}),
providesTags: [listTag("OtpBypassToken")],
}),
generateOtpBypassTokens: build.mutation<
GenerateOtpBypassTokensResult,
GenerateOtpBypassTokensArg
>({
query: () => ({
url: urls.otpBypassToken.list,
url: urls.otpBypassToken.list + "generate/",
method: "POST",
}),
invalidatesTags: [listTag("OtpBypassToken")],
}),
}),
})

export default otpBypassTokenApi
export const { useGenerateOtpBypassTokensMutation } = otpBypassTokenApi
export const {
useLazyListOtpBypassTokensQuery,
useListOtpBypassTokensQuery,
useGenerateOtpBypassTokensMutation,
} = otpBypassTokenApi
54 changes: 22 additions & 32 deletions src/components/PrintPasswordReminderCardsButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as pdf from "@react-pdf/renderer"
import { Button, type ButtonProps } from "@mui/material"
import { type FC, useRef } from "react"
import {
DownloadFileButton,
type DownloadFileButtonProps,
} from "codeforlife/components"
import { type FC, useState } from "react"
import { type Student, type User } from "codeforlife/api"
import { Print as PrintIcon } from "@mui/icons-material"
import { generatePath } from "react-router-dom"
Expand Down Expand Up @@ -78,48 +81,35 @@ const PDF: FC<PDFProps> = ({ classId, students }) => {
}

export type PrintPasswordReminderCardsButtonProps = PDFProps &
Omit<ButtonProps, "endIcon" | "onClick" | "className">
Omit<DownloadFileButtonProps, "endIcon" | "onClick" | "className" | "file">

const PrintPasswordReminderCardsButton: FC<
PrintPasswordReminderCardsButtonProps
> = ({ classId, students, ...buttonProps }) => {
const linkRef = useRef<HTMLAnchorElement | null>(null)
const [file, setFile] = useState<Blob>()

const downloadPdf = async (): Promise<void> => {
try {
const blob = await pdf
.pdf(<PDF classId={classId} students={students} />)
.toBlob()

const url = URL.createObjectURL(blob)

if (linkRef.current) {
linkRef.current.href = url
linkRef.current.click()
}

URL.revokeObjectURL(url)
} catch (error) {
pdf
.pdf(<PDF classId={classId} students={students} />)
.toBlob()
.then(file => {
setFile(file)
})
.catch(error => {
console.error(error)
}
}
})

return (
<>
<Button
if (file) {
return (
<DownloadFileButton
endIcon={<PrintIcon />}
onClick={() => {
void downloadPdf()
}}
className="body"
file={file}
{...buttonProps}
>
Print reminder cards
</Button>
{/* Invisible anchor tag to trigger the download */}
<a ref={linkRef} target="_blank" style={{ display: "none" }}></a>
</>
)
</DownloadFileButton>
)
}
}

export default PrintPasswordReminderCardsButton
62 changes: 21 additions & 41 deletions src/components/StudentCredentialsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as tables from "codeforlife/components/table"
import { Button, Stack, type SxProps, Typography } from "@mui/material"
import { type Class, type Student, type User } from "codeforlife/api"
import { type FC, useRef } from "react"
import { CopyIconButton } from "codeforlife/components"
import { CopyIconButton, DownloadFileButton } from "codeforlife/components"
import { Stack, type SxProps, Typography } from "@mui/material"
import { type FC } from "react"
import { SaveAlt as SaveAltIcon } from "@mui/icons-material"
import { generatePath } from "react-router-dom"

Expand All @@ -19,46 +19,26 @@ const DownloadCSVButton: FC<DownloadCSVButtonProps> = ({
classLoginLink,
students,
}) => {
const linkRef = useRef<HTMLAnchorElement | null>(null)

const generateCSV: () => string = () => {
const lines = [["Name", "Password", "Class Link", "Login URL"].join(",")]
students.forEach(student => {
lines.push(
[
student.user.first_name,
student.user.password,
classLoginLink,
makeAutoLoginLink(classLoginLink, student),
].join(","),
)
})

return lines.join("\n")
}

const downloadCSV: () => void = () => {
const csvContent = generateCSV()
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)

if (linkRef.current) {
linkRef.current.href = url
linkRef.current.download = "data.csv"
linkRef.current.click()
}

URL.revokeObjectURL(url)
}
const text = [
["Name", "Password", "Class Link", "Login URL"].join(","),
...students.map(student =>
[
student.user.first_name,
student.user.password,
classLoginLink,
makeAutoLoginLink(classLoginLink, student),
].join(","),
),
].join("\n")

return (
<>
<Button endIcon={<SaveAltIcon />} className="body" onClick={downloadCSV}>
Download CSV
</Button>
{/* Invisible anchor tag to trigger the download */}
<a ref={linkRef} target="_blank" style={{ display: "none" }}></a>
</>
<DownloadFileButton
endIcon={<SaveAltIcon />}
className="body"
file={{ text, mimeType: "csv", name: "data" }}
>
Download CSV
</DownloadFileButton>
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/pages/teacherDashboard/account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const Account: FC<AccountProps> = ({ authUser, view }) => {
if (view) {
return {
"setup-otp": <SetupOtp authUserId={authUser.id} />,
"otp-bypass-tokens": <OtpBypassTokens authUser={authUser} />,
"otp-bypass-tokens": <OtpBypassTokens authUserId={authUser.id} />,
}[view]
}

Expand Down
38 changes: 26 additions & 12 deletions src/pages/teacherDashboard/account/Otp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
useListAuthFactorsQuery,
} from "../../../api/authFactor"
import { paths } from "../../../routes"
import { useListOtpBypassTokensQuery } from "../../../api/otpBypassToken"

const OtpExists: FC<{ authFactorId: AuthFactor["id"] }> = ({
authFactorId,
Expand All @@ -21,14 +22,32 @@ const OtpExists: FC<{ authFactorId: AuthFactor["id"] }> = ({
return (
<Grid container>
<Grid sm={6} mt={4}>
{/* TODO: rename number of "backup tokens" to "bypass tokens". */}
<Typography variant="h6">Backup tokens</Typography>
{/*TODO: Update text to show the actual number of backup tokens*/}
<Typography>
If you don&apos;t have your smartphone or tablet with you, you can
access your account using backup tokens. You have 0 backup tokens
remaining.
access your account using backup tokens.
</Typography>
<Typography>View and create backup tokens for your account.</Typography>
{handleResultState(
useListOtpBypassTokensQuery({ offset: 0, limit: 0 }),
({ count }) => (
<Typography variant="body2">
You have {count} backup tokens remaining.
</Typography>
),
{
loading: (
<Typography variant="body2">
Counting remaining backup tokens...
</Typography>
),
error: (
<Typography variant="body2" color="error.main">
Failed to count remaining backup tokens.
</Typography>
),
},
)}
<LinkButton
className="body"
to={paths.teacher.dashboard.tab.account.otp.bypassTokens._}
Expand Down Expand Up @@ -108,16 +127,11 @@ const Otp: FC<OtpProps> = ({ authUserId }) => (
</Typography>
{handleResultState(
useListAuthFactorsQuery(
{
offset: 0,
limit: 1,
user: authUserId,
type: "otp",
},
{ offset: 0, limit: 1, user: authUserId, type: "otp" },
{ refetchOnMountOrArgChange: true },
),
({ count, data: authFactors }) =>
count ? (
({ count: exists, data: authFactors }) =>
exists ? (
<OtpExists authFactorId={authFactors[0].id} />
) : (
<LinkButton
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,45 @@
import * as pages from "codeforlife/components/page"
import { type FC } from "react"
import { type RetrieveUserResult } from "../../../../api/user"
import { type SchoolTeacherUser } from "codeforlife/api"
import { Navigate } from "codeforlife/components/router"
import { type User } from "codeforlife/api"
import { handleResultState } from "codeforlife/utils/api"

import OtpExists from "./OtpExists"
import { paths } from "../../../../routes"
import { useListAuthFactorsQuery } from "../../../../api/authFactor"

export interface OtpBypassTokensProps {
authUser: SchoolTeacherUser<RetrieveUserResult>
authUserId: User["id"]
}

const OtpBypassTokens: FC<OtpBypassTokensProps> = () => {
return <>TODO</>
}
const OtpBypassTokens: FC<OtpBypassTokensProps> = ({ authUserId }) => (
<pages.Section>
{handleResultState(
useListAuthFactorsQuery(
{ offset: 0, limit: 0, user: authUserId, type: "otp" },
{ refetchOnMountOrArgChange: true },
),
({ count: exists }) =>
exists ? (
<OtpExists />
) : (
<Navigate
to={paths.teacher.dashboard.tab.account.otp.setup._}
replace
state={{
notifications: [
{
props: {
error: true,
children: "One-time password not set up.",
},
},
],
}}
/>
),
)}
</pages.Section>
)

export default OtpBypassTokens
Loading

0 comments on commit 460a583

Please sign in to comment.