diff --git a/src/app/schemas.ts b/src/app/schemas.ts index f9ed0ab..0300105 100644 --- a/src/app/schemas.ts +++ b/src/app/schemas.ts @@ -27,3 +27,12 @@ export const indyPasswordSchema = yup .matches(/[A-Z]/, "password must contain at least one uppercase letter") .matches(/[a-z]/, "password must contain at least one lowercase letter") .matches(/[0-9]/, "password must contain at least one digit") + +// A nullable schema allowing for empty values. Use when needing to apply a +// schema to an optional field, e.g. the new password field in the account form. +// Apply to any other schema using .concat(). +// TODO: Reassess the need for this after we split the account details form +export const nullableSchema = yup + .string() + .nullable() + .transform((curr: string, orig: string) => (orig === "" ? null : curr)) diff --git a/src/pages/studentAccount/DeleteAccountForm.tsx b/src/components/form/DeleteAccountForm.tsx similarity index 100% rename from src/pages/studentAccount/DeleteAccountForm.tsx rename to src/components/form/DeleteAccountForm.tsx diff --git a/src/pages/studentAccount/UpdateAccountForm.tsx b/src/components/form/UpdateAccountForm.tsx similarity index 51% rename from src/pages/studentAccount/UpdateAccountForm.tsx rename to src/components/form/UpdateAccountForm.tsx index 6b633fc..8734837 100644 --- a/src/pages/studentAccount/UpdateAccountForm.tsx +++ b/src/components/form/UpdateAccountForm.tsx @@ -1,8 +1,8 @@ import * as forms from "codeforlife/components/form" -import { Stack, Typography } from "@mui/material" import { getDirty, isDirty } from "codeforlife/utils/form" import { type FC } from "react" -import { LinkButton } from "codeforlife/components/router" +import { Typography } from "@mui/material" +import { generatePath } from "react-router" import { useNavigate } from "codeforlife/hooks" import { @@ -11,15 +11,24 @@ import { type UpdateUserResult, useUpdateUserMutation, } from "../../api/user" -import { indyPasswordSchema, studentPasswordSchema } from "../../app/schemas" -import { LastNameField } from "../../components/form" +import { + indyPasswordSchema, + nullableSchema, + studentPasswordSchema, + teacherPasswordSchema, +} from "../../app/schemas" +import { LastNameField } from "./index" +import { paths } from "../../routes" +import { useLogoutMutation } from "../../api" export interface UpdateAccountFormProps { user: RetrieveUserResult } +// TODO: Split this form into two or three forms. Needs UX work const UpdateAccountForm: FC = ({ user }) => { const navigate = useNavigate() + const [logout] = useLogoutMutation() const initialValues = user.student ? { @@ -42,9 +51,7 @@ const UpdateAccountForm: FC = ({ user }) => { <> {user.student ? ( <> - - Update your password - + Update your password You may edit your password below. It must be long enough and hard enough to stop your friends guessing it and stealing all of your @@ -56,9 +63,7 @@ const UpdateAccountForm: FC = ({ user }) => { ) : ( <> - - Update your account details - + Update your account details You can update your account details below. Please note: If you change your email address, you will need to @@ -89,28 +94,86 @@ const UpdateAccountForm: FC = ({ user }) => { return arg }, then: (_: UpdateUserResult, values: typeof initialValues) => { + const isEmailDirty = isDirty(values, initialValues, "email") + const isPasswordDirty = isDirty(values, initialValues, "password") const messages = [ "Your account details have been changed successfully.", ] - if (isDirty(values, initialValues, "email")) { - // TODO: implement this behavior on the backend. - messages.push( - "Your email will be changed once you have verified it, until then you can still log in with your old email.", - ) - } - if (isDirty(values, initialValues, "password")) { - messages.push( - "Going forward, please login using your new password.", - ) - } - navigate(".", { - state: { - notifications: messages.map(message => ({ - props: { children: message }, - })), - }, - }) + if (isEmailDirty || isPasswordDirty) { + const teacherLoginPath = generatePath(paths.login.teacher._) + if (isEmailDirty) { + void logout(null) + .unwrap() + .then(() => { + messages.push( + "Your email will be changed once you have verified it, until then you can still log in with your old email.", + ) + navigate(teacherLoginPath, { + state: { + notifications: messages.map(message => ({ + props: { children: message }, + })), + }, + }) + }) + // TODO: Check what happens here - is the field still updated? + .catch(() => { + navigate(".", { + replace: true, + state: { + notifications: [ + { + props: { + error: true, + children: "Failed to log you out.", + }, + }, + ], + }, + }) + }) + } + if (isPasswordDirty) { + void logout(null) + .unwrap() + .then(() => { + messages.push( + "Going forward, please log in using your new password.", + ) + navigate(teacherLoginPath, { + state: { + notifications: messages.map(message => ({ + props: { children: message }, + })), + }, + }) + }) + .catch(() => { + navigate(".", { + replace: true, + state: { + notifications: [ + { + props: { + error: true, + children: "Failed to log you out.", + }, + }, + ], + }, + }) + }) + } + } else { + navigate(".", { + state: { + notifications: messages.map(message => ({ + props: { children: message }, + })), + }, + }) + } }, }} > @@ -120,9 +183,14 @@ const UpdateAccountForm: FC = ({ user }) => { "password", ]) - let passwordSchema = user.student - ? studentPasswordSchema - : indyPasswordSchema + let passwordSchema = indyPasswordSchema.concat(nullableSchema) + + if (user.student) { + passwordSchema = studentPasswordSchema + } else if (user.teacher) { + passwordSchema = teacherPasswordSchema.concat(nullableSchema) + } + if (isDirty(form.values, initialValues, "current_password")) { passwordSchema = passwordSchema.notOneOf( [form.values.current_password], @@ -154,12 +222,11 @@ const UpdateAccountForm: FC = ({ user }) => { placeholder="Enter your current password" /> )} - - - Cancel - - Update details - + ({ marginTop: theme.spacing(3) })} + > + Update details + ) }} diff --git a/src/components/form/index.tsx b/src/components/form/index.tsx index 7434dae..4fce8f3 100644 --- a/src/components/form/index.tsx +++ b/src/components/form/index.tsx @@ -2,6 +2,8 @@ export * from "./ClassAutocompleteField" export { default as ClassAutocompleteField } from "./ClassAutocompleteField" export * from "./ClassNameField" export { default as ClassNameField } from "./ClassNameField" +export * from "./DeleteAccountForm" +export { default as DeleteAccountForm } from "./DeleteAccountForm" export * from "./LastNameField" export { default as LastNameField } from "./LastNameField" export * from "./NewPasswordField" @@ -12,3 +14,5 @@ export * from "./SchoolNameField" export { default as SchoolNameField } from "./SchoolNameField" export * from "./TeacherAutocompleteField" export { default as TeacherAutocompleteField } from "./TeacherAutocompleteField" +export * from "./UpdateAccountForm" +export { default as UpdateAccountForm } from "./UpdateAccountForm" diff --git a/src/pages/studentAccount/StudentAccount.tsx b/src/pages/studentAccount/StudentAccount.tsx index d2e558b..b7b7534 100644 --- a/src/pages/studentAccount/StudentAccount.tsx +++ b/src/pages/studentAccount/StudentAccount.tsx @@ -5,8 +5,7 @@ import { type SessionMetadata } from "codeforlife/hooks" import { Typography } from "@mui/material" import { handleResultState } from "codeforlife/utils/api" -import DeleteAccountForm from "./DeleteAccountForm" -import UpdateAccountForm from "./UpdateAccountForm" +import * as forms from "../../components/form" import { paths } from "../../routes" import { useRetrieveUserQuery } from "../../api/user" @@ -23,7 +22,7 @@ const _StudentAccount: FC = ({ user_type, user_id }) => bgcolor={user_type === "student" ? "tertiary" : "secondary"} /> - + {user_type === "indy" && ( <> @@ -36,7 +35,7 @@ const _StudentAccount: FC = ({ user_type, user_id }) => Join - + )} diff --git a/src/pages/teacherDashboard/account/Account.tsx b/src/pages/teacherDashboard/account/Account.tsx index 004c9a5..0975581 100644 --- a/src/pages/teacherDashboard/account/Account.tsx +++ b/src/pages/teacherDashboard/account/Account.tsx @@ -1,17 +1,48 @@ +import * as page from "codeforlife/components/page" import { type FC } from "react" import { type SchoolTeacherUser } from "codeforlife/api" +import { Typography } from "@mui/material" +import * as forms from "../../../components/form" +import BackupTokens from "./BackupTokens" +import Manage2FAForm from "./Manage2FAForm" import { type RetrieveUserResult } from "../../../api/user" +import Setup2FA from "./Setup2FA.tsx" export interface AccountProps { authUser: SchoolTeacherUser - view?: "otp" + view?: "otp" | "backupTokens" } -// @ts-expect-error unused var -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const Account: FC = ({ authUser }) => { - return <>TODO +const Account: FC = ({ authUser, view }) => { + if (view) { + return { + otp: , + backupTokens: , + }[view] + } + + return ( + <> + + + Your account + + + + + Two factor authentication + + Use your smartphone or tablet to enhance your account's security + by using an authenticator app. + + + + + + + + ) } export default Account diff --git a/src/pages/teacherDashboard/account/BackupTokens.tsx b/src/pages/teacherDashboard/account/BackupTokens.tsx new file mode 100644 index 0000000..2592b13 --- /dev/null +++ b/src/pages/teacherDashboard/account/BackupTokens.tsx @@ -0,0 +1,13 @@ +import { type FC } from "react" +import { type RetrieveUserResult } from "../../../api/user" +import { type SchoolTeacherUser } from "codeforlife/api" + +export interface BackupTokensProps { + user: SchoolTeacherUser +} + +const BackupTokens: FC = () => { + return <>TODO +} + +export default BackupTokens diff --git a/src/pages/teacherDashboard/account/Manage2FAForm.tsx b/src/pages/teacherDashboard/account/Manage2FAForm.tsx new file mode 100644 index 0000000..6270b6b --- /dev/null +++ b/src/pages/teacherDashboard/account/Manage2FAForm.tsx @@ -0,0 +1,142 @@ +import { Button, Grid, Typography, useTheme } from "@mui/material" +import { ErrorOutlineOutlined } from "@mui/icons-material" +import { type FC } from "react" +import { type SchoolTeacherUser } from "codeforlife/api" +import { generatePath } from "react-router" +import { useNavigate } from "react-router-dom" + +import { type RetrieveUserResult } from "../../../api/user" +import { paths } from "../../../routes" +import { useListAuthFactorsQuery } from "../../../api/authFactor" + +const Setup2FAForm: FC<{ user: SchoolTeacherUser }> = ({ + user, +}) => { + const navigate = useNavigate() + const theme = useTheme() + return ( + <> + + + ) +} + +const Edit2FAForm: FC<{ user: SchoolTeacherUser }> = ({ + user, +}) => { + const theme = useTheme() + const navigate = useNavigate() + // TODO: Uncomment when implementing 2FA disabling + // const [disable2fa] = useDisable2faMutation() + // const { refetch } = useTeacherHas2faQuery(null) + // const handleDisable2fa: () => void = () => { + // disable2fa(null) + // .unwrap() + // .then(refetch) + // .catch(error => { + // console.error(error) + // }) + // } + return ( + + + 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. + + View and create backup tokens for your account. + + + Note: Please make that you store any login details in a secure place. + + + + + Disable two factor authentication (2FA) + + + We recommend you to continue using 2FA, however you can disable 2FA + for your account using the button below. + + + + + ) +} + +export interface Manage2FAFormProps { + user: SchoolTeacherUser +} + +const Manage2FAForm: FC = ({ user }) => { + const { data: authFactors } = useListAuthFactorsQuery({ + limit: 50, + offset: 0, + }) + + if (!authFactors || authFactors.count === 0) { + return ( + <> + + + ) + } + + authFactors.data.forEach(authFactor => { + if (authFactor.user === user && authFactor.type === "otp") { + return ( + <> + + + ) + } + }) + + return ( + <> + + + ) +} + +export default Manage2FAForm diff --git a/src/pages/teacherDashboard/account/Setup2FA.tsx b/src/pages/teacherDashboard/account/Setup2FA.tsx new file mode 100644 index 0000000..bb66b38 --- /dev/null +++ b/src/pages/teacherDashboard/account/Setup2FA.tsx @@ -0,0 +1,13 @@ +import { type FC } from "react" +import { type RetrieveUserResult } from "../../../api/user" +import { type SchoolTeacherUser } from "codeforlife/api" + +export interface Setup2FAProps { + user: SchoolTeacherUser +} + +const Setup2FA: FC = () => { + return <>TODO +} + +export default Setup2FA diff --git a/src/routes/teacher.tsx b/src/routes/teacher.tsx index c1fb238..d791815 100644 --- a/src/routes/teacher.tsx +++ b/src/routes/teacher.tsx @@ -51,6 +51,14 @@ const teacher = ( path={paths.teacher.dashboard.tab.account._} element={} /> + } + /> + } + /> )