diff --git a/src/pages/studentAccount/DeleteAccountForm.tsx b/src/components/form/DeleteAccountForm.tsx similarity index 95% rename from src/pages/studentAccount/DeleteAccountForm.tsx rename to src/components/form/DeleteAccountForm.tsx index cb1a2c6..c28e3e4 100644 --- a/src/pages/studentAccount/DeleteAccountForm.tsx +++ b/src/components/form/DeleteAccountForm.tsx @@ -71,10 +71,10 @@ const ConfirmDialog: FC<{ } export interface DeleteAccountFormProps { - user: RetrieveUserResult + authUser: RetrieveUserResult } -const DeleteAccountForm: FC = ({ user }) => { +const DeleteAccountForm: FC = ({ authUser }) => { const [confirmDialog, setConfirmDialog] = useState<{ open: boolean destroyIndyUserArg?: DestroyIndependentUserArg @@ -98,7 +98,7 @@ const DeleteAccountForm: FC = ({ user }) => { This can't be reversed. = ({ user }) => { } - sx={theme => ({ marginTop: theme.spacing(3) })} + sx={{ marginTop: 3 }} > Delete account diff --git a/src/pages/studentAccount/UpdateAccountForm.tsx b/src/components/form/UpdateAccountForm.tsx similarity index 57% rename from src/pages/studentAccount/UpdateAccountForm.tsx rename to src/components/form/UpdateAccountForm.tsx index 6b633fc..4705aa7 100644 --- a/src/pages/studentAccount/UpdateAccountForm.tsx +++ b/src/components/form/UpdateAccountForm.tsx @@ -1,50 +1,51 @@ 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 { useNavigate } from "codeforlife/hooks" import { type RetrieveUserResult, type UpdateUserArg, - type UpdateUserResult, useUpdateUserMutation, } from "../../api/user" -import { indyPasswordSchema, studentPasswordSchema } from "../../app/schemas" -import { LastNameField } from "../../components/form" +import { + indyPasswordSchema, + studentPasswordSchema, + teacherPasswordSchema, +} from "../../app/schemas" +import { LastNameField } from "./index" export interface UpdateAccountFormProps { - user: RetrieveUserResult + authUser: RetrieveUserResult } -const UpdateAccountForm: FC = ({ user }) => { +// TODO: Split this form into two or three forms. Needs UX work +const UpdateAccountForm: FC = ({ authUser }) => { const navigate = useNavigate() - const initialValues = user.student + const initialValues = authUser.student ? { - id: user.id, + id: authUser.id, password: "", password_repeat: "", current_password: "", } : { - id: user.id, - password: "", - password_repeat: "", - current_password: "", - first_name: user.first_name, - last_name: user.last_name, - email: user.email, + id: authUser.id, + password: undefined as string | undefined, + password_repeat: undefined as string | undefined, + current_password: undefined as string | undefined, + first_name: authUser.first_name, + last_name: authUser.last_name, + email: authUser.email, } return ( <> - {user.student ? ( + {authUser.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 +57,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 @@ -74,41 +73,35 @@ const UpdateAccountForm: FC = ({ user }) => { exclude: ["password_repeat"], clean: (values: typeof initialValues) => { const arg: UpdateUserArg = { id: values.id } - if (user.student || isDirty(values, initialValues, "password")) { + if (isDirty(values, initialValues, "password")) { arg.password = values.password arg.current_password = values.current_password - } else if (isDirty(values, initialValues, "email")) { + } + if (isDirty(values, initialValues, "email")) { arg.email = values.email arg.current_password = values.current_password - } else if (isDirty(values, initialValues, "first_name")) { + } + if (isDirty(values, initialValues, "first_name")) { arg.first_name = values.first_name - } else if (isDirty(values, initialValues, "last_name")) { + } + if (isDirty(values, initialValues, "last_name")) { arg.last_name = values.last_name } return arg }, - then: (_: UpdateUserResult, values: typeof initialValues) => { - 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.", - ) - } - + // TODO: Update backend to log user out and show a message if credential fields were updated + then: () => { navigate(".", { state: { - notifications: messages.map(message => ({ - props: { children: message }, - })), + notifications: [ + { + props: { + children: + "Your account details have been changed successfully.", + }, + }, + ], }, }) }, @@ -120,9 +113,14 @@ const UpdateAccountForm: FC = ({ user }) => { "password", ]) - let passwordSchema = user.student - ? studentPasswordSchema - : indyPasswordSchema + let passwordSchema = indyPasswordSchema + + if (authUser.student) { + passwordSchema = studentPasswordSchema + } else if (authUser.teacher) { + passwordSchema = teacherPasswordSchema + } + if (isDirty(form.values, initialValues, "current_password")) { passwordSchema = passwordSchema.notOneOf( [form.values.current_password], @@ -132,7 +130,7 @@ const UpdateAccountForm: FC = ({ user }) => { return ( <> - {!user.student && ( + {!authUser.student && ( <> @@ -140,13 +138,13 @@ const UpdateAccountForm: FC = ({ user }) => { )} - {(Boolean(user.student) || dirty.email || dirty.password) && ( + {(Boolean(authUser.student) || dirty.email || dirty.password) && ( = ({ user }) => { placeholder="Enter your current password" /> )} - - - Cancel - - Update details - + + Update details + ) }} diff --git a/src/components/form/index.tsx b/src/components/form/index.tsx index 57ad250..a2ee7b5 100644 --- a/src/components/form/index.tsx +++ b/src/components/form/index.tsx @@ -6,6 +6,8 @@ export * from "./CreateClassForm" export { default as CreateClassForm } from "./CreateClassForm" export * from "./CreateStudentsForm" export { default as CreateStudentsForm } from "./CreateStudentsForm" +export * from "./DeleteAccountForm" +export { default as DeleteAccountForm } from "./DeleteAccountForm" export * from "./LastNameField" export { default as LastNameField } from "./LastNameField" export * from "./NewPasswordField" @@ -16,3 +18,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..0959515 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 { DeleteAccountForm, UpdateAccountForm } from "../../components/form" import { paths } from "../../routes" import { useRetrieveUserQuery } from "../../api/user" @@ -15,15 +14,15 @@ export interface StudentAccountProps { } const _StudentAccount: FC = ({ user_type, user_id }) => - handleResultState(useRetrieveUserQuery(user_id), user => ( + handleResultState(useRetrieveUserQuery(user_id), authUser => ( <> - + {user_type === "indy" && ( <> @@ -36,7 +35,7 @@ const _StudentAccount: FC = ({ user_type, user_id }) => Join - + )} diff --git a/src/pages/studentJoinClass/StudentJoinClass.tsx b/src/pages/studentJoinClass/StudentJoinClass.tsx index 341994c..0d97e4f 100644 --- a/src/pages/studentJoinClass/StudentJoinClass.tsx +++ b/src/pages/studentJoinClass/StudentJoinClass.tsx @@ -14,41 +14,43 @@ const _StudentJoinClass: FC = ({ user_id }) => { const user = authUser as IndependentUser return ( - - - Join a school or club - - {user.requesting_to_join_class ? ( - - ) : ( - <> - - Request to join a school or club - - - If you want to link your Code For Life account with a school or - club, ask a teacher to enable external requests and provide you - with the Class Access Code for the class you want to join. Simply - add the Class Access Code to the form below and submit. - - - Warning: once the teacher accepts you to their - class, that teacher and the school or club will manage your - account. - - - If successful, the teacher will contact you with your new login - details. - - - - )} - + <> + + + + Join a school or club + + {user.requesting_to_join_class ? ( + + ) : ( + <> + + Request to join a school or club + + + If you want to link your Code For Life account with a school or + club, ask a teacher to enable external requests and provide you + with the Class Access Code for the class you want to join. + Simply add the Class Access Code to the form below and submit. + + + Warning: once the teacher accepts you to their + class, that teacher and the school or club will manage your + account. + + + If successful, the teacher will contact you with your new login + details. + + + + )} + + ) }) } diff --git a/src/pages/teacherDashboard/account/Account.tsx b/src/pages/teacherDashboard/account/Account.tsx index 004c9a5..562f796 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 ManageOtpForm from "./ManageOtpForm.tsx" +import OtpBypassTokens from "./OtpBypassTokens.tsx" import { type RetrieveUserResult } from "../../../api/user" +import SetupOtp from "./SetupOtp.tsx" export interface AccountProps { authUser: SchoolTeacherUser - view?: "otp" + view?: "otp" | "otp-bypass-tokens" } -// @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: , + "otp-bypass-tokens": , + }[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/ManageOtpForm.tsx b/src/pages/teacherDashboard/account/ManageOtpForm.tsx new file mode 100644 index 0000000..cfd3d34 --- /dev/null +++ b/src/pages/teacherDashboard/account/ManageOtpForm.tsx @@ -0,0 +1,133 @@ +import { Button, Grid, Typography } from "@mui/material" +import { ErrorOutlineOutlined } from "@mui/icons-material" +import { type FC } from "react" +import { LinkButton } from "codeforlife/components/router" +import { type SchoolTeacherUser } from "codeforlife/api" +import { generatePath } from "react-router" + +import { type RetrieveUserResult } from "../../../api/user" +import { paths } from "../../../routes" +import { useListAuthFactorsQuery } from "../../../api/authFactor" + +const SetupOtpForm: FC<{ authUser: SchoolTeacherUser }> = ({ + authUser, +}) => { + return ( + <> + + Setup two factor authentication + + + ) +} + +const EditOtpForm: FC<{ authUser: SchoolTeacherUser }> = ({ + authUser, +}) => { + // TODO: Uncomment when implementing Otp 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. + + Manage backup tokens + + + 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 ManageOtpFormProps { + authUser: SchoolTeacherUser +} + +const ManageOtpForm: FC = ({ authUser }) => { + const { data: authFactors } = useListAuthFactorsQuery({ + limit: 50, + offset: 0, + }) + + if (!authFactors || authFactors.count === 0) { + return ( + <> + + + ) + } + + authFactors.data.forEach(authFactor => { + if (authFactor.user === authUser && authFactor.type === "otp") { + return ( + <> + + + ) + } + }) + + return ( + <> + + + ) +} + +export default ManageOtpForm diff --git a/src/pages/teacherDashboard/account/OtpBypassTokens.tsx b/src/pages/teacherDashboard/account/OtpBypassTokens.tsx new file mode 100644 index 0000000..1c43891 --- /dev/null +++ b/src/pages/teacherDashboard/account/OtpBypassTokens.tsx @@ -0,0 +1,13 @@ +import { type FC } from "react" +import { type RetrieveUserResult } from "../../../api/user" +import { type SchoolTeacherUser } from "codeforlife/api" + +export interface OtpBypassTokensProps { + authUser: SchoolTeacherUser +} + +const OtpBypassTokens: FC = () => { + return <>TODO +} + +export default OtpBypassTokens diff --git a/src/pages/teacherDashboard/account/SetupOtp.tsx b/src/pages/teacherDashboard/account/SetupOtp.tsx new file mode 100644 index 0000000..54f45c0 --- /dev/null +++ b/src/pages/teacherDashboard/account/SetupOtp.tsx @@ -0,0 +1,13 @@ +import { type FC } from "react" +import { type RetrieveUserResult } from "../../../api/user" +import { type SchoolTeacherUser } from "codeforlife/api" + +export interface SetupOtpProps { + authUser: SchoolTeacherUser +} + +const SetupOtp: FC = () => { + return <>TODO +} + +export default SetupOtp diff --git a/src/routes/paths.ts b/src/routes/paths.ts index fb2107a..a3a07e0 100644 --- a/src/routes/paths.ts +++ b/src/routes/paths.ts @@ -44,8 +44,8 @@ const paths = _("", { account: _( { tab: "account" }, { - setup2FA: _("/setup-2fa"), - backupTokens: _("/backup-tokens"), + setupOtp: _("/setup-otp"), + otpBypassTokens: _("/otp-bypass-tokens"), }, ), }), diff --git a/src/routes/teacher.tsx b/src/routes/teacher.tsx index 0564bde..cdde9aa 100644 --- a/src/routes/teacher.tsx +++ b/src/routes/teacher.tsx @@ -51,6 +51,14 @@ const teacher = ( path={paths.teacher.dashboard.tab.account._} element={} /> + } + /> + } + /> )