diff --git a/.env b/.env index dfeba9c..adb5a8f 100644 --- a/.env +++ b/.env @@ -1,9 +1,11 @@ VITE_API_BASE_URL=http://localhost:8000/api/ VITE_SERVICE_NAME=portal -# Links external sites. -VITE_LINK_OPEN_VERIFY_EMAIL_IN_GMAIL="https://mail.google.com/mail/#search/from%3Ano-reply%40info.codeforlife.education+subject%3AEmail+Verification" -VITE_LINK_OPEN_VERIFY_EMAIL_IN_OUTLOOK=https://outlook.live.com/mail/ +# Gmail. +VITE_GMAIL_FILTERS_PASSWORD_RESET_REQUEST="from:no-reply@info.codeforlife.education subject:Password reset request" +VITE_GMAIL_FILTERS_EMAIL_VERIFICATION="from:no-reply@info.codeforlife.education subject:Email verification" + +# Links to external sites. VITE_LINK_FEMALE_GRADUATES_IN_CS=https://www.wisecampaign.org.uk/core-stem-graduates-2019/ # TODO: determine which of these we need. diff --git a/.vscode/launch.json b/.vscode/launch.json index a01fca9..0d3a1fc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,22 @@ { "configurations": [ + { + "args": [ + "run", + "${relativeFile}" + ], + "autoAttachChildProcesses": true, + "console": "integratedTerminal", + "name": "Vitest: Current File", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "request": "launch", + "skipFiles": [ + "/**", + "**/node_modules/**" + ], + "smartStep": true, + "type": "node" + }, { "name": "Vite Server", "preLaunchTask": "run", diff --git a/package.json b/package.json index d5eb9d7..ca7346d 100644 --- a/package.json +++ b/package.json @@ -18,19 +18,23 @@ "lint:fix": "eslint --fix .", "type-check": "tsc --noEmit" }, - "//": "🚫 Don't add `dependencies` below that are inherited from the CFL package.", + "//": [ + "🚫 Don't add `dependencies` below that are inherited from the CFL package.", + "✅ Do add `devDependencies` below that are `peerDependencies` in the CFL package." + ], "dependencies": { - "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.1.1" + "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.1.2", + "crypto-js": "^4.2.0" }, - "//": "✅ Do add `devDependencies` below that are `peerDependencies` in the CFL package.", "devDependencies": { "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.2", + "@types/crypto-js": "^4.2.2", "@types/js-cookie": "^3.0.3", - "@types/qs": "^6.9.7", "@types/node": "^20.14.2", + "@types/qs": "^6.9.7", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", diff --git a/src/app/schemas.ts b/src/app/schemas.ts index 96a8f77..0f27d91 100644 --- a/src/app/schemas.ts +++ b/src/app/schemas.ts @@ -1,35 +1,80 @@ -import * as yup from "yup" - -export const classIdSchema = yup - .string() - .matches(/^[A-Z0-9]{5}$/, "Invalid class code") - -const passwordSchema = yup.string().required("required") - -export const teacherPasswordSchema = passwordSchema.test({ - message: "too-weak", - test: password => - password.length >= 10 && - !( - password.search(/[A-Z]/) === -1 || - password.search(/[a-z]/) === -1 || - password.search(/[0-9]/) === -1 || - password.search(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/) === -1 - ), -}) - -export const studentPasswordSchema = passwordSchema.test({ - message: "too-weak", - test: password => password.length >= 6, -}) - -export const indyPasswordSchema = passwordSchema.test({ - message: "too-weak", - test: password => - password.length >= 8 && - !( - password.search(/[A-Z]/) === -1 || - password.search(/[a-z]/) === -1 || - password.search(/[0-9]/) === -1 - ), -}) +import CryptoJS from "crypto-js" +import { string as YupString, type Schema, type StringSchema } from "yup" + +type Options = Partial<{ schema: S } & Extras> + +export function classIdSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema.matches(/^[A-Z0-9]{5}$/, "invalid class code") +} + +export function teacherPasswordSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema + .min(10, "password must be at least 10 characters long") + .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") + .matches( + /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, + "password must contain at least one special character", + ) +} + +export function studentPasswordSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema.min(6, "password must be at least 6 characters long") +} + +export function indyPasswordSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema + .min(8, "password must be at least 8 characters long") + .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") +} + +export function pwnedPasswordSchema( + options?: Options void }>, +) { + const { schema = YupString().required(), onError } = options || {} + + return schema.test({ + message: "password is too common", + test: async password => { + try { + // Do not raise validation error if no password. + if (!password) return true + + // Hash the password. + const hashedPassword = CryptoJS.SHA1(password).toString().toUpperCase() + const hashPrefix = hashedPassword.substring(0, 5) + const hashSuffix = hashedPassword.substring(5) + + // Call Pwned Passwords API. + // https://haveibeenpwned.com/API/v3#SearchingPwnedPasswordsByRange + const response = await fetch( + `https://api.pwnedpasswords.com/range/${hashPrefix}`, + ) + // TODO: Standardize how to log non-okay responses. + if (!response.ok) throw Error() + + // Parse response. + const data = await response.text() + return !data.includes(hashSuffix) + } catch (error) { + console.error(error) + + if (onError) onError(error) + + // Do not raise validation error if a different error occurred. + return true + } + }, + }) +} diff --git a/src/components/NewPasswordField.tsx b/src/components/NewPasswordField.tsx new file mode 100644 index 0000000..bc665d7 --- /dev/null +++ b/src/components/NewPasswordField.tsx @@ -0,0 +1,72 @@ +import { Button, Dialog, Typography } from "@mui/material" +import { type FC, useState } from "react" + +import { PasswordField } from "codeforlife/components/form" + +import { + indyPasswordSchema, + pwnedPasswordSchema, + studentPasswordSchema, + teacherPasswordSchema, +} from "../app/schemas" + +export interface NewPasswordFieldProps { + userType: "teacher" | "independent" | "student" +} + +const NewPasswordField: FC = ({ userType }) => { + const [pwnedPasswords, setPwnedPasswords] = useState<{ + online: boolean + dialogOpen: boolean + }>({ online: true, dialogOpen: false }) + + let schema = { + teacher: teacherPasswordSchema, + independent: indyPasswordSchema, + student: studentPasswordSchema, + }[userType]() + + if ( + pwnedPasswords.online && + (userType === "teacher" || userType === "independent") + ) { + schema = pwnedPasswordSchema({ + schema, + onError: () => { + // Alert user test couldn't be carried out. + setPwnedPasswords({ online: false, dialogOpen: true }) + }, + }) + } + + return ( + <> + + + + Password Vulnerability Check Unavailable + + + We are currently unable to check your password vulnerability. Please + ensure that you are using a strong password. If you are happy to + continue, please confirm. + + + + + ) +} + +export default NewPasswordField diff --git a/src/components/OpenInEmailButtons.tsx b/src/components/OpenInEmailButtons.tsx new file mode 100644 index 0000000..2480862 --- /dev/null +++ b/src/components/OpenInEmailButtons.tsx @@ -0,0 +1,32 @@ +import { MailOutline as MailOutlineIcon } from "@mui/icons-material" +import { Stack } from "@mui/material" +import { type FC } from "react" + +import { LinkButton } from "codeforlife/components/router" + +export interface OpenInEmailButtonsProps { + gmailFilters: string +} + +const OpenInEmailButtons: FC = ({ gmailFilters }) => { + return ( + + } + > + Open in Gmail + + } + > + Open in Outlook + + + ) +} + +export default OpenInEmailButtons diff --git a/src/components/index.tsx b/src/components/index.tsx new file mode 100644 index 0000000..ca19af8 --- /dev/null +++ b/src/components/index.tsx @@ -0,0 +1,13 @@ +import NewPasswordField, { + type NewPasswordFieldProps, +} from "./NewPasswordField" +import OpenInEmailButtons, { + type OpenInEmailButtonsProps, +} from "./OpenInEmailButtons" + +export { + NewPasswordField, + OpenInEmailButtons, + type NewPasswordFieldProps, + type OpenInEmailButtonsProps, +} diff --git a/src/pages/emailVerification/EmailVerification.tsx b/src/pages/emailVerification/EmailVerification.tsx index ca678be..3506c74 100644 --- a/src/pages/emailVerification/EmailVerification.tsx +++ b/src/pages/emailVerification/EmailVerification.tsx @@ -1,5 +1,4 @@ import { - MailOutline as MailOutlineIcon, Send as SendIcon, SentimentVeryDissatisfied as SentimentVeryDissatisfiedIcon, } from "@mui/icons-material" @@ -28,7 +27,7 @@ const EmailVerification: FC = () => { }) useEffect(() => { - if (!params) navigate(paths.error.pageNotFound._) + if (!params) navigate(paths.error.type.pageNotFound._) }, [params, navigate]) if (!params) return <> @@ -50,20 +49,10 @@ const EmailVerification: FC = () => { "Please follow the link within the email to verify your details. This will expire in 1 hour.", ]} icon={} - buttonProps={[ - { - to: import.meta.env.VITE_LINK_OPEN_VERIFY_EMAIL_IN_GMAIL, - target: "_blank", - children: "Open in Gmail", - endIcon: , - }, - { - to: import.meta.env.VITE_LINK_OPEN_VERIFY_EMAIL_IN_OUTLOOK, - target: "_blank", - children: "Open in Outlook", - endIcon: , - }, - ]} + openInEmailButtonsProps={{ + gmailFilters: import.meta.env + .VITE_GMAIL_FILTERS_EMAIL_VERIFICATION, + }} /> ) : ( = ({ @@ -24,7 +24,7 @@ const Status: FC = ({ header, body, icon, - buttonProps, + openInEmailButtonsProps, }) => ( @@ -37,12 +37,8 @@ const Status: FC = ({ {text} ))} - {buttonProps && ( - - {buttonProps.map((props, index) => ( - - ))} - + {openInEmailButtonsProps && ( + )} diff --git a/src/pages/home/AboutUs.tsx b/src/pages/home/AboutUs.tsx index d211c13..c072598 100644 --- a/src/pages/home/AboutUs.tsx +++ b/src/pages/home/AboutUs.tsx @@ -57,9 +57,8 @@ const AboutUs: FC = () => ( - {/* TODO: add more accurate figure */} - Code for Life has over 350,000 registered users across the world. + Code for Life has over 660,000 registered users across the world. diff --git a/src/pages/login/IndyForm.tsx b/src/pages/login/IndyForm.tsx index 7eb1209..ef56d3b 100644 --- a/src/pages/login/IndyForm.tsx +++ b/src/pages/login/IndyForm.tsx @@ -36,10 +36,7 @@ const IndyForm: FC = () => { Don't worry, you can  - - reset your password - - . + reset your password. diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx index ea03d1c..e34b79c 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -42,7 +42,7 @@ const Login: FC = ({ form }) => { } else { navigate( { - teacher: paths.teacher.dashboard.school._, + teacher: paths.teacher.dashboard.tab.school._, student: paths.student.dashboard._, indy: paths.indy.dashboard._, }[sessionMetadata.user_type], diff --git a/src/pages/login/studentForms/Class.tsx b/src/pages/login/studentForms/Class.tsx index 1583249..a190ed7 100644 --- a/src/pages/login/studentForms/Class.tsx +++ b/src/pages/login/studentForms/Class.tsx @@ -68,7 +68,7 @@ const Class: FC = () => { name="classId" label="Class code" placeholder="Enter your class code" - schema={classIdSchema} + schema={classIdSchema()} required /> diff --git a/src/pages/login/studentForms/FirstName.tsx b/src/pages/login/studentForms/FirstName.tsx index b1aa6bf..0773cf7 100644 --- a/src/pages/login/studentForms/FirstName.tsx +++ b/src/pages/login/studentForms/FirstName.tsx @@ -1,13 +1,10 @@ import { ChevronRight as ChevronRightIcon } from "@mui/icons-material" import { Stack } from "@mui/material" import { useEffect, type FC } from "react" -import { useParams } from "react-router-dom" -import * as yup from "yup" import * as form from "codeforlife/components/form" -import { useNavigate } from "codeforlife/hooks" +import { useNavigate, useParams } from "codeforlife/hooks" import { submitForm } from "codeforlife/utils/form" -import { tryValidateSync } from "codeforlife/utils/schema" import { useLoginAsStudentMutation } from "../../../api/sso" import { classIdSchema } from "../../../app/schemas" @@ -20,10 +17,7 @@ const FirstName: FC = () => { const [loginAsStudent] = useLoginAsStudentMutation() const navigate = useNavigate() - const params = tryValidateSync( - useParams(), - yup.object({ classId: classIdSchema.required() }), - ) + const params = useParams({ classId: classIdSchema().required() }) useEffect(() => { if (!params) { diff --git a/src/pages/login/teacherForms/Email.tsx b/src/pages/login/teacherForms/Email.tsx index 2e742f1..0e3d369 100644 --- a/src/pages/login/teacherForms/Email.tsx +++ b/src/pages/login/teacherForms/Email.tsx @@ -27,7 +27,7 @@ const Email: FC = () => { navigate( auth_factors.includes("otp") ? paths.login.teacher.otp._ - : paths.teacher.dashboard.school._, + : paths.teacher.dashboard.tab.school._, ) }, })} diff --git a/src/pages/login/teacherForms/Otp.tsx b/src/pages/login/teacherForms/Otp.tsx index 72397d8..3f564dc 100644 --- a/src/pages/login/teacherForms/Otp.tsx +++ b/src/pages/login/teacherForms/Otp.tsx @@ -25,7 +25,7 @@ const Otp: FC = () => { initialValues={{ otp: "" }} onSubmit={submitForm(loginWithOtp, { then: () => { - navigate(paths.teacher.dashboard.school._) + navigate(paths.teacher.dashboard.tab.school._) }, })} > diff --git a/src/pages/login/teacherForms/OtpBypassToken.tsx b/src/pages/login/teacherForms/OtpBypassToken.tsx index 4a8cb8d..fcc5f91 100644 --- a/src/pages/login/teacherForms/OtpBypassToken.tsx +++ b/src/pages/login/teacherForms/OtpBypassToken.tsx @@ -25,7 +25,7 @@ const OtpBypassToken: FC = () => { initialValues={{ token: "" }} onSubmit={submitForm(loginWithOtpBypassToken, { then: () => { - navigate(paths.teacher.dashboard.school._) + navigate(paths.teacher.dashboard.tab.school._) }, })} > diff --git a/src/pages/resetPassword/EmailForm.tsx b/src/pages/resetPassword/EmailForm.tsx new file mode 100644 index 0000000..e1f3b68 --- /dev/null +++ b/src/pages/resetPassword/EmailForm.tsx @@ -0,0 +1,63 @@ +import { Send as SendIcon } from "@mui/icons-material" +import { Stack, Typography } from "@mui/material" +import { type FC } from "react" + +import * as form from "codeforlife/components/form" +import { Link, LinkButton } from "codeforlife/components/router" + +import { useLazyRequestPasswordResetQuery } from "../../api/user" +import { OpenInEmailButtons } from "../../components" +import { paths } from "../../router" + +export interface EmailFormProps {} + +const EmailForm: FC = () => { + const [requestPasswordReset, result] = useLazyRequestPasswordResetQuery() + + return result.isSuccess ? ( + + + Thank you + + + If you have entered a valid email address, you will receive a link to + reset your password. Make sure to check your spam. + + + + + homepage + + + ) : ( + + + Reset password + + + Please enter your email address + + + We will send an email with a link to reset your password. + + { + requestPasswordReset(values) + }} + > + + + + Cancel + + Reset password + + + + ) +} + +export default EmailForm diff --git a/src/pages/resetPassword/PasswordForm.tsx b/src/pages/resetPassword/PasswordForm.tsx new file mode 100644 index 0000000..9fbec4d --- /dev/null +++ b/src/pages/resetPassword/PasswordForm.tsx @@ -0,0 +1,67 @@ +import { CheckCircleOutline as CheckCircleOutlineIcon } from "@mui/icons-material" +import { Stack, Typography } from "@mui/material" +import { type FC } from "react" + +import * as form from "codeforlife/components/form" +import { LinkButton } from "codeforlife/components/router" + +import { useResetPasswordMutation } from "../../api/user" +import { NewPasswordField } from "../../components" +import { paths } from "../../router" + +export interface PasswordFormProps { + userType: "teacher" | "independent" + userId: number + token: string +} + +const PasswordForm: FC = ({ userType, userId, token }) => { + const [resetPassword, result] = useResetPasswordMutation() + + return result.isSuccess ? ( + + + Your password has been reset + + + Please log in. + + OK + + + ) : ( + + + Password Reset + + + Please enter a new password and confirm it in the box below to reset + your account’s password. + + + resetPassword([userId, { token, password }]) + } + > + + + + Cancel + + Reset password + + + + ) +} + +export default PasswordForm diff --git a/src/pages/resetPassword/ResetPassword.tsx b/src/pages/resetPassword/ResetPassword.tsx new file mode 100644 index 0000000..bf5a3c6 --- /dev/null +++ b/src/pages/resetPassword/ResetPassword.tsx @@ -0,0 +1,52 @@ +import { type FC, useEffect } from "react" +import * as yup from "yup" + +import * as page from "codeforlife/components/page" +import { useNavigate, useParams } from "codeforlife/hooks" +import { ThemedBox } from "codeforlife/theme" + +import { themeOptions } from "../../app/theme" +import { paths } from "../../router" +import EmailForm from "./EmailForm" +import PasswordForm from "./PasswordForm" + +export interface ResetPasswordProps {} + +const ResetPassword: FC = () => { + const navigate = useNavigate() + + const params = useParams({ + userType: yup + .string() + .oneOf(["teacher", "independent"] as const) + .required(), + userId: yup.number(), + token: yup.string(), + }) + + useEffect(() => { + if (!params) navigate(paths.error.type.pageNotFound._) + }, [navigate, params]) + + if (!params) return <> + + return ( + + + + {params.userId && params.token ? ( + + ) : ( + + )} + + + + ) +} + +export default ResetPassword diff --git a/src/router/paths.ts b/src/router/paths.ts index 285e68c..d2affe8 100644 --- a/src/router/paths.ts +++ b/src/router/paths.ts @@ -12,11 +12,9 @@ const paths = _("", { }), indy: _("/independent"), }), - resetPassword: _("/reset-password", { - userType: _("/:userType", { - teacher: _({ userType: "teacher" }), - indy: _({ userType: "independent" }), - }), + resetPassword: _("/reset-password/:userType/:userId?/:token?", { + teacher: _({ userType: "teacher" }), + indy: _({ userType: "independent" }), }), teacher: _("/teacher", { onboarding: _("/onboarding"), diff --git a/src/router/routes/authentication.tsx b/src/router/routes/authentication.tsx index 8affeab..e38fe3d 100644 --- a/src/router/routes/authentication.tsx +++ b/src/router/routes/authentication.tsx @@ -3,7 +3,7 @@ import { Route } from "react-router-dom" import EmailVerification from "../../pages/emailVerification/EmailVerification" import Login from "../../pages/login/Login" // import Register from '../../pages/register/Register' -// import ResetPassword from '../../pages/resetPassword/ResetPassword' +import ResetPassword from "../../pages/resetPassword/ResetPassword" import paths from "../paths" const authentication = ( @@ -33,7 +33,7 @@ const authentication = ( path={paths.register.emailVerification.userType._} element={} /> - {/* } /> */} + } /> {/* } /> */} ) diff --git a/yarn.lock b/yarn.lock index 5dcfb26..2a3d186 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1891,6 +1891,11 @@ dependencies: "@babel/types" "^7.20.7" +"@types/crypto-js@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea" + integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ== + "@types/estree@1.0.5", "@types/estree@^1.0.0": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" @@ -2645,9 +2650,9 @@ 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@github:ocadotechnology/codeforlife-package-javascript#v2.1.1": - version "2.1.1" - resolved "https://codeload.github.com/ocadotechnology/codeforlife-package-javascript/tar.gz/4057d7f1bb8b269c2ef29a91b7c25b2fc69c0154" +"codeforlife@github:ocadotechnology/codeforlife-package-javascript#v2.1.2": + version "2.1.2" + resolved "https://codeload.github.com/ocadotechnology/codeforlife-package-javascript/tar.gz/c8d3ae8eb8dee688276fe78a648be321dfdb23d9" dependencies: "@emotion/react" "^11.10.6" "@emotion/styled" "^11.10.6" @@ -2774,6 +2779,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + css-tree@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20"