Skip to content

Commit

Permalink
new password form
Browse files Browse the repository at this point in the history
  • Loading branch information
SKairinos committed Jul 15, 2024
1 parent 6f6fcd0 commit fe00f5f
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 37 deletions.
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 }}

Check failure on line 48 in src/components/NewPasswordField.tsx

View workflow job for this annotation

GitHub Actions / main / test / test-js-code

Type '{ required: true; withRepeatField: true; schema: StringSchema<string | undefined, AnyObject, undefined, "">; validateOptions: { abortEarly: boolean; }; }' is not assignable to type 'IntrinsicAttributes & Omit<TextFieldProps, "name" | "type" | "autoComplete" | "schema"> & Partial<Pick<TextFieldProps, "name" | "schema">> & { ...; }'.
/>
<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
5 changes: 5 additions & 0 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import NewPasswordField, {
type NewPasswordFieldProps,
} from "./NewPasswordField"

export { NewPasswordField, type NewPasswordFieldProps }
4 changes: 2 additions & 2 deletions src/pages/resetPassword/PasswordForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as form from "codeforlife/components/form"
import { LinkButton } from "codeforlife/components/router"

import { useResetPasswordMutation } from "../../api/user"
// import CflPasswordFields from "../../features/cflPasswordFields/CflPasswordFields"
import { NewPasswordField } from "../../components"
import { paths } from "../../router"

export interface PasswordFormProps {
Expand Down Expand Up @@ -52,7 +52,7 @@ const PasswordForm: FC<PasswordFormProps> = ({ userType, userId, token }) => {
resetPassword([userId, { token, password }])
}
>
{/* <CflPasswordFields userType={userType} /> */}
<NewPasswordField userType={userType} />
<Stack mt={3} direction="row" gap={5} justifyContent="center">
<LinkButton variant="outlined" to={paths._}>
Cancel
Expand Down

0 comments on commit fe00f5f

Please sign in to comment.