Skip to content

Commit

Permalink
Merge branch 'development' into comms_preferences
Browse files Browse the repository at this point in the history
  • Loading branch information
faucomte97 committed Jul 16, 2024
2 parents d1de8d8 + 1ba4909 commit a31aac5
Show file tree
Hide file tree
Showing 23 changed files with 450 additions and 100 deletions.
8 changes: 5 additions & 3 deletions .env
Original file line number Diff line number Diff line change
@@ -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:[email protected] subject:Password reset request"
VITE_GMAIL_FILTERS_EMAIL_VERIFICATION="from:[email protected] 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.
Expand Down
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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_internals>/**",
"**/node_modules/**"
],
"smartStep": true,
"type": "node"
},
{
"name": "Vite Server",
"preLaunchTask": "run",
Expand Down
12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
115 changes: 80 additions & 35 deletions src/app/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<S extends Schema, Extras = {}> = Partial<{ schema: S } & Extras>

export function classIdSchema(options?: Options<StringSchema>) {
const { schema = YupString() } = options || {}

return schema.matches(/^[A-Z0-9]{5}$/, "invalid class code")
}

export function teacherPasswordSchema(options?: Options<StringSchema>) {
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<StringSchema>) {
const { schema = YupString() } = options || {}

return schema.min(6, "password must be at least 6 characters long")
}

export function indyPasswordSchema(options?: Options<StringSchema>) {
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<StringSchema, { onError: (error: unknown) => 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
}
},
})
}
72 changes: 72 additions & 0 deletions src/components/NewPasswordField.tsx
Original file line number Diff line number Diff line change
@@ -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<NewPasswordFieldProps> = ({ 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 (
<>
<PasswordField
required
withRepeatField
schema={schema}
validateOptions={{ abortEarly: false }}
/>
<Dialog open={!pwnedPasswords.online && pwnedPasswords.dialogOpen}>
<Typography variant="h5" className="no-override">
Password Vulnerability Check Unavailable
</Typography>
<Typography className="no-override">
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.
</Typography>
<Button
className="no-override"
onClick={() => {
setPwnedPasswords({ online: false, dialogOpen: false })
}}
>
I understand
</Button>
</Dialog>
</>
)
}

export default NewPasswordField
32 changes: 32 additions & 0 deletions src/components/OpenInEmailButtons.tsx
Original file line number Diff line number Diff line change
@@ -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<OpenInEmailButtonsProps> = ({ gmailFilters }) => {
return (
<Stack direction="row" spacing={5}>
<LinkButton
to={`https://mail.google.com/mail/#search/${encodeURIComponent(gmailFilters)}`}
target="_blank"
endIcon={<MailOutlineIcon />}
>
Open in Gmail
</LinkButton>
<LinkButton
to="https://outlook.live.com/mail/"
target="_blank"
endIcon={<MailOutlineIcon />}
>
Open in Outlook
</LinkButton>
</Stack>
)
}

export default OpenInEmailButtons
13 changes: 13 additions & 0 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import NewPasswordField, {
type NewPasswordFieldProps,
} from "./NewPasswordField"
import OpenInEmailButtons, {
type OpenInEmailButtonsProps,
} from "./OpenInEmailButtons"

export {
NewPasswordField,
OpenInEmailButtons,
type NewPasswordFieldProps,
type OpenInEmailButtonsProps,
}
21 changes: 5 additions & 16 deletions src/pages/emailVerification/EmailVerification.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
MailOutline as MailOutlineIcon,
Send as SendIcon,
SentimentVeryDissatisfied as SentimentVeryDissatisfiedIcon,
} from "@mui/icons-material"
Expand Down Expand Up @@ -28,7 +27,7 @@ const EmailVerification: FC<EmailVerificationProps> = () => {
})

useEffect(() => {
if (!params) navigate(paths.error.pageNotFound._)
if (!params) navigate(paths.error.type.pageNotFound._)
}, [params, navigate])

if (!params) return <></>
Expand All @@ -50,20 +49,10 @@ const EmailVerification: FC<EmailVerificationProps> = () => {
"Please follow the link within the email to verify your details. This will expire in 1 hour.",
]}
icon={<SendIcon {...svgIconProps} />}
buttonProps={[
{
to: import.meta.env.VITE_LINK_OPEN_VERIFY_EMAIL_IN_GMAIL,
target: "_blank",
children: "Open in Gmail",
endIcon: <MailOutlineIcon />,
},
{
to: import.meta.env.VITE_LINK_OPEN_VERIFY_EMAIL_IN_OUTLOOK,
target: "_blank",
children: "Open in Outlook",
endIcon: <MailOutlineIcon />,
},
]}
openInEmailButtonsProps={{
gmailFilters: import.meta.env
.VITE_GMAIL_FILTERS_EMAIL_VERIFICATION,
}}
/>
) : (
<Status
Expand Down
22 changes: 9 additions & 13 deletions src/pages/emailVerification/Status.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import { Stack, Typography } from "@mui/material"
import { type FC, type ReactElement } from "react"

import {
Link,
LinkButton,
type LinkButtonProps,
} from "codeforlife/components/router"
import { Link } from "codeforlife/components/router"
import { ThemedBox, type ThemedBoxProps } from "codeforlife/theme"

import { themeOptions } from "../../app/theme"
import {
OpenInEmailButtons,
type OpenInEmailButtonsProps,
} from "../../components"
import { paths } from "../../router"

export interface StatusProps {
userType: ThemedBoxProps["userType"]
header: string
body: string[]
icon: ReactElement
buttonProps?: LinkButtonProps[]
openInEmailButtonsProps?: OpenInEmailButtonsProps
}

const Status: FC<StatusProps> = ({
userType,
header,
body,
icon,
buttonProps,
openInEmailButtonsProps,
}) => (
<ThemedBox withShapes options={themeOptions} userType={userType}>
<Stack alignItems="center" marginBottom={2.5}>
Expand All @@ -37,12 +37,8 @@ const Status: FC<StatusProps> = ({
<Typography key={index}>{text}</Typography>
))}
</Stack>
{buttonProps && (
<Stack direction="row" spacing={5}>
{buttonProps.map((props, index) => (
<LinkButton key={index} {...props} />
))}
</Stack>
{openInEmailButtonsProps && (
<OpenInEmailButtons {...openInEmailButtonsProps} />
)}
</Stack>
<Link to={paths._} className="back-to">
Expand Down
Loading

0 comments on commit a31aac5

Please sign in to comment.