From 460a5834228b03f788ae4272e53b605130811d3b Mon Sep 17 00:00:00 2001 From: Stefan Kairinos Date: Wed, 26 Feb 2025 11:28:55 +0000 Subject: [PATCH] feat: Portal frontend 40 (#96) * feat: manage otp bypass tokens * fix: count tokens * merge from main * new cfl package * fix: new cfl package * feedback --- package.json | 2 +- src/api/index.ts | 2 +- src/api/otpBypassToken.ts | 31 +++++- .../PrintPasswordReminderCardsButton.tsx | 54 +++++------ src/components/StudentCredentialsTable.tsx | 62 +++++------- .../teacherDashboard/account/Account.tsx | 2 +- src/pages/teacherDashboard/account/Otp.tsx | 38 +++++--- .../otpBypassTokens/OtpBypassTokens.tsx | 44 +++++++-- .../account/otpBypassTokens/OtpExists.tsx | 94 +++++++++++++++++++ .../account/setupOtp/SetupOtp.tsx | 57 +++++------ .../account/setupOtp/SetupPending.tsx | 6 +- yarn.lock | 8 +- 12 files changed, 269 insertions(+), 131 deletions(-) create mode 100644 src/pages/teacherDashboard/account/otpBypassTokens/OtpExists.tsx diff --git a/package.json b/package.json index 5f9fdba..a246322 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/api/index.ts b/src/api/index.ts index accec84..8abecbe 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,7 @@ import { createApi } from "codeforlife/api" const api = createApi({ - tagTypes: ["SchoolTeacherInvitation"], + tagTypes: ["SchoolTeacherInvitation", "OtpBypassToken"], }) export default api diff --git a/src/api/otpBypassToken.ts b/src/api/otpBypassToken.ts index b6a9b28..287fde9 100644 --- a/src/api/otpBypassToken.ts +++ b/src/api/otpBypassToken.ts @@ -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 diff --git a/src/components/PrintPasswordReminderCardsButton.tsx b/src/components/PrintPasswordReminderCardsButton.tsx index d3d85e1..c5e8a13 100644 --- a/src/components/PrintPasswordReminderCardsButton.tsx +++ b/src/components/PrintPasswordReminderCardsButton.tsx @@ -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" @@ -78,48 +81,35 @@ const PDF: FC = ({ classId, students }) => { } export type PrintPasswordReminderCardsButtonProps = PDFProps & - Omit + Omit const PrintPasswordReminderCardsButton: FC< PrintPasswordReminderCardsButtonProps > = ({ classId, students, ...buttonProps }) => { - const linkRef = useRef(null) + const [file, setFile] = useState() - const downloadPdf = async (): Promise => { - try { - const blob = await pdf - .pdf() - .toBlob() - - const url = URL.createObjectURL(blob) - - if (linkRef.current) { - linkRef.current.href = url - linkRef.current.click() - } - - URL.revokeObjectURL(url) - } catch (error) { + pdf + .pdf() + .toBlob() + .then(file => { + setFile(file) + }) + .catch(error => { console.error(error) - } - } + }) - return ( - <> - - {/* Invisible anchor tag to trigger the download */} - - - ) + + ) + } } export default PrintPasswordReminderCardsButton diff --git a/src/components/StudentCredentialsTable.tsx b/src/components/StudentCredentialsTable.tsx index 22a53d7..d46faba 100644 --- a/src/components/StudentCredentialsTable.tsx +++ b/src/components/StudentCredentialsTable.tsx @@ -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" @@ -19,46 +19,26 @@ const DownloadCSVButton: FC = ({ classLoginLink, students, }) => { - const linkRef = useRef(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 ( - <> - - {/* Invisible anchor tag to trigger the download */} - - + } + className="body" + file={{ text, mimeType: "csv", name: "data" }} + > + Download CSV + ) } diff --git a/src/pages/teacherDashboard/account/Account.tsx b/src/pages/teacherDashboard/account/Account.tsx index f803c63..cfe4650 100644 --- a/src/pages/teacherDashboard/account/Account.tsx +++ b/src/pages/teacherDashboard/account/Account.tsx @@ -18,7 +18,7 @@ const Account: FC = ({ authUser, view }) => { if (view) { return { "setup-otp": , - "otp-bypass-tokens": , + "otp-bypass-tokens": , }[view] } diff --git a/src/pages/teacherDashboard/account/Otp.tsx b/src/pages/teacherDashboard/account/Otp.tsx index 5479142..f82a9a2 100644 --- a/src/pages/teacherDashboard/account/Otp.tsx +++ b/src/pages/teacherDashboard/account/Otp.tsx @@ -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, @@ -21,14 +22,32 @@ const OtpExists: FC<{ authFactorId: AuthFactor["id"] }> = ({ return ( + {/* TODO: rename number of "backup tokens" to "bypass tokens". */} Backup tokens - {/*TODO: Update text to show the actual number of backup tokens*/} If you don'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. - View and create backup tokens for your account. + {handleResultState( + useListOtpBypassTokensQuery({ offset: 0, limit: 0 }), + ({ count }) => ( + + You have {count} backup tokens remaining. + + ), + { + loading: ( + + Counting remaining backup tokens... + + ), + error: ( + + Failed to count remaining backup tokens. + + ), + }, + )} = ({ authUserId }) => ( {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 ? ( ) : ( + authUserId: User["id"] } -const OtpBypassTokens: FC = () => { - return <>TODO -} +const OtpBypassTokens: FC = ({ authUserId }) => ( + + {handleResultState( + useListAuthFactorsQuery( + { offset: 0, limit: 0, user: authUserId, type: "otp" }, + { refetchOnMountOrArgChange: true }, + ), + ({ count: exists }) => + exists ? ( + + ) : ( + + ), + )} + +) export default OtpBypassTokens diff --git a/src/pages/teacherDashboard/account/otpBypassTokens/OtpExists.tsx b/src/pages/teacherDashboard/account/otpBypassTokens/OtpExists.tsx new file mode 100644 index 0000000..69567dc --- /dev/null +++ b/src/pages/teacherDashboard/account/otpBypassTokens/OtpExists.tsx @@ -0,0 +1,94 @@ +import { Button, ListItemText, Stack, Typography } from "@mui/material" +import { DownloadFileButton, ItemizedList } from "codeforlife/components" +import { Autorenew as AutorenewIcon } from "@mui/icons-material" +import { type FC } from "react" +import { Link } from "codeforlife/components/router" +import { handleResultState } from "codeforlife/utils/api" + +import { + useGenerateOtpBypassTokensMutation, + useListOtpBypassTokensQuery, +} from "../../../../api/otpBypassToken" +import { paths } from "../../../../routes" + +export interface OtpExistsProps {} + +const OtpExists: FC = () => { + const [generateOtpBypassTokens] = useGenerateOtpBypassTokensMutation() + + return ( + <> + + Backup tokens + + + your account + + + Backup tokens can be used when your primary and backup phone numbers + aren't available. The backup tokens below can be used for login + verification. If you've used up all your backup tokens, you can + generate a new set of backup tokens. Only the backup tokens shown below + will be valid. + + {handleResultState( + useListOtpBypassTokensQuery( + { offset: 0, limit: 10 }, + { refetchOnMountOrArgChange: true }, + ), + ({ data: otpBypassTokens }) => { + const decryptedTokens = otpBypassTokens.map( + ({ decrypted_token }) => decrypted_token, + ) + + const generateTokensButton = ( + + ) + + return otpBypassTokens.length ? ( + <> + + {decryptedTokens.map((decryptedToken, index) => ( + + {decryptedToken} + + ))} + + + When you generate new recovery codes, you must download or print + the new codes. Your old codes won't work anymore. + + + {generateTokensButton} + + + + ) : ( + <> + + You do not currently have any OTP bypass tokens. Please generate + a new set of tokens and download them. + + {generateTokensButton} + + ) + }, + )} + + ) +} + +export default OtpExists diff --git a/src/pages/teacherDashboard/account/setupOtp/SetupOtp.tsx b/src/pages/teacherDashboard/account/setupOtp/SetupOtp.tsx index fe583a6..b9252bd 100644 --- a/src/pages/teacherDashboard/account/setupOtp/SetupOtp.tsx +++ b/src/pages/teacherDashboard/account/setupOtp/SetupOtp.tsx @@ -16,31 +16,32 @@ export interface SetupOtpProps { const SetupOtp: FC = ({ authUserId }) => { const [completed, setCompleted] = useState(false) - return handleResultState( - useListAuthFactorsQuery({ - offset: 0, - limit: 1, - user: authUserId, - type: "otp", - }), - ({ count }) => - count ? ( - - ) : ( - - {completed ? ( + return ( + + {handleResultState( + useListAuthFactorsQuery({ + offset: 0, + limit: 0, + user: authUserId, + type: "otp", + }), + ({ count: exists }) => + exists ? ( + + ) : completed ? ( ) : ( = ({ authUserId }) => { setCompleted(true) }} /> - )} - - ), + ), + )} + ) } diff --git a/src/pages/teacherDashboard/account/setupOtp/SetupPending.tsx b/src/pages/teacherDashboard/account/setupOtp/SetupPending.tsx index 9c5c769..fb45bb6 100644 --- a/src/pages/teacherDashboard/account/setupOtp/SetupPending.tsx +++ b/src/pages/teacherDashboard/account/setupOtp/SetupPending.tsx @@ -76,7 +76,11 @@ const _SetupPending: FC< submitOptions={{ then: onSetup }} > - + Next diff --git a/yarn.lock b/yarn.lock index 20b7375..0729035 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2998,10 +2998,10 @@ clsx@^2.0.0, clsx@^2.1.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== -codeforlife@2.6.10: - version "2.6.10" - resolved "https://registry.yarnpkg.com/codeforlife/-/codeforlife-2.6.10.tgz#f338da487dda8ff4fc572406cbc2329519dfa961" - integrity sha512-rXy2a3cNRy2t5aIkMYO2gf5cC2fTo3G0h8r8y5bEHie2ALOwGOXKntnz8fZz0ozwo2rhUXphiYyRapB1Z9MIWg== +codeforlife@2.6.11: + version "2.6.11" + resolved "https://registry.yarnpkg.com/codeforlife/-/codeforlife-2.6.11.tgz#17d54eacc322b0d94a12e05d6cc4fe12839fd4af" + integrity sha512-Q4FhrMmLTtg7oy98Dk2jWin+ih0e8Rjj1bAyVDiQJDWhPa9trm2is2voFXGBc+Fjk68JENZMy7l0uY1a3bLzVg== dependencies: "@emotion/react" "^11.10.6" "@emotion/styled" "^11.10.6"