Skip to content

Commit

Permalink
Portal frontend 51 (#61)
Browse files Browse the repository at this point in the history
* initial

* fixes after testing

* refactor

* use cfl navigate

* new cfl package
  • Loading branch information
SKairinos authored Sep 19, 2024
1 parent 096b883 commit baf9ffc
Show file tree
Hide file tree
Showing 17 changed files with 544 additions and 376 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"✅ Do add `devDependencies` below that are `peerDependencies` in the CFL package."
],
"dependencies": {
"codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.3.2",
"codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.3.4",
"crypto-js": "^4.2.0"
},
"devDependencies": {
Expand Down
45 changes: 41 additions & 4 deletions src/app/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,53 @@
// We disable the ESLint rule here because this is the designated place
// for importing and re-exporting the typed versions of hooks.
/* eslint-disable @typescript-eslint/no-restricted-imports */
import * as yup from "yup"
import { useDispatch, useSelector } from "react-redux"
import { useParams } from "codeforlife/hooks"
import CryptoJS from "crypto-js"
import { useState } from "react"

import type { AppDispatch, RootState } from "./store"
import { classIdSchema } from "./schemas"

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

export function useClassIdParam() {
return useParams({ classId: classIdSchema().required() })
export function usePwnedPasswordsApi(): [yup.StringSchema, boolean] {
const [online, setOnline] = useState(true)

const schema = yup.string().test({
message: "password is too common",
test: async password => {
try {
// Do not raise validation error if not online or no password.
if (!online || !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)

setOnline(false)

// Do not raise validation error if a different error occurred.
return true
}
},
})

return [schema, online]
}
109 changes: 29 additions & 80 deletions src/app/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,29 @@
import { type Schema, type StringSchema, string as YupString } from "yup"
import CryptoJS from "crypto-js"

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
}
},
})
}
import * as yup from "yup"

export const userIdSchema = yup.number()

export const classIdSchema = yup
.string()
.matches(/^[A-Z0-9]{5}$/, "invalid class code")

export const teacherPasswordSchema = yup
.string()
.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 const studentPasswordSchema = yup
.string()
.min(6, "password must be at least 6 characters long")

export const indyPasswordSchema = yup
.string()
.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")
24 changes: 8 additions & 16 deletions src/components/form/NewPasswordField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import {

import {
indyPasswordSchema,
pwnedPasswordSchema,
studentPasswordSchema,
teacherPasswordSchema,
} from "../../app/schemas"
import { usePwnedPasswordsApi } from "../../app/hooks"

export interface NewPasswordFieldProps
extends Omit<
Expand All @@ -25,28 +25,20 @@ const NewPasswordField: FC<NewPasswordFieldProps> = ({
userType,
...passwordFieldProps
}) => {
const [pwnedPasswords, setPwnedPasswords] = useState<{
online: boolean
dialogOpen: boolean
}>({ online: true, dialogOpen: false })
const [pwnedPasswordsSchema, pwnedPasswordsOnline] = usePwnedPasswordsApi()
const [dialogOpen, setDialogOpen] = useState(!pwnedPasswordsOnline)

let schema = {
teacher: teacherPasswordSchema,
independent: indyPasswordSchema,
student: studentPasswordSchema,
}[userType]()
}[userType]

if (
pwnedPasswords.online &&
pwnedPasswordsOnline &&
(userType === "teacher" || userType === "independent")
) {
schema = pwnedPasswordSchema({
schema,
onError: () => {
// Alert user test couldn't be carried out.
setPwnedPasswords({ online: false, dialogOpen: true })
},
})
schema = schema.concat(pwnedPasswordsSchema)
}

return (
Expand All @@ -58,7 +50,7 @@ const NewPasswordField: FC<NewPasswordFieldProps> = ({
validateOptions={{ abortEarly: false }}
{...passwordFieldProps}
/>
<Dialog open={!pwnedPasswords.online && pwnedPasswords.dialogOpen}>
<Dialog open={dialogOpen}>
<Typography variant="h5" className="no-override">
Password Vulnerability Check Unavailable
</Typography>
Expand All @@ -70,7 +62,7 @@ const NewPasswordField: FC<NewPasswordFieldProps> = ({
<Button
className="no-override"
onClick={() => {
setPwnedPasswords({ online: false, dialogOpen: false })
setDialogOpen(false)
}}
>
I understand
Expand Down
2 changes: 1 addition & 1 deletion src/pages/login/studentForms/Class.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const Class: FC<ClassProps> = () => {
name="classId"
label="Class code"
placeholder="Enter your class code"
schema={classIdSchema()}
schema={classIdSchema}
required
/>
<Typography variant="body2" fontWeight="bold">
Expand Down
2 changes: 1 addition & 1 deletion src/pages/login/studentForms/FirstName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const FirstName: FC<FirstNameProps> = () => {
const [loginAsStudent] = useLoginAsStudentMutation()
const navigate = useNavigate()

const params = useParams({ classId: classIdSchema().required() })
const params = useParams({ classId: classIdSchema.required() })

useEffect(() => {
if (!params) {
Expand Down
9 changes: 8 additions & 1 deletion src/pages/register/TeacherForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,14 @@ const TeacherForm: FC<TeacherFormProps> = () => {
"Sign up to receive updates about Code for Life games and teaching resources.",
}}
/>
<NewPasswordField name="user.password" userType="teacher" />
<NewPasswordField
name="user.password"
userType="teacher"
repeatFieldProps={{
label: "Repeat password",
placeholder: "Enter your password again",
}}
/>
<Stack alignItems="end">
<form.SubmitButton endIcon={<ChevronRightIcon />}>
Register
Expand Down
4 changes: 2 additions & 2 deletions src/pages/studentAccount/UpdateAccountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ const UpdateAccountForm: FC<UpdateAccountFormProps> = ({ user }) => {
])

let passwordSchema = user.student
? studentPasswordSchema()
: indyPasswordSchema()
? studentPasswordSchema
: indyPasswordSchema
if (isDirty(form.values, initialValues, "current_password")) {
passwordSchema = passwordSchema.notOneOf(
[form.values.current_password],
Expand Down
8 changes: 7 additions & 1 deletion src/pages/teacherDashboard/classes/Classes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import JoinClassRequest from "./JoinClassRequest"
import JoinClassRequestTable from "./JoinClassRequestTable"
import ResetStudentsPassword from "./class/ResetStudentsPassword"
import { type RetrieveUserResult } from "../../../api/user"
import UpdateStudentUser from "./class/UpdateStudentUser"

export interface ClassesProps {
authUser: SchoolTeacherUser<RetrieveUserResult>
view?: "class" | "join-class-request" | "reset-students-password"
view?:
| "class"
| "join-class-request"
| "reset-students-password"
| "update-student-user"
}

const Classes: FC<ClassesProps> = ({ authUser, view }) => {
Expand All @@ -21,6 +26,7 @@ const Classes: FC<ClassesProps> = ({ authUser, view }) => {
class: <Class />,
"join-class-request": <JoinClassRequest />,
"reset-students-password": <ResetStudentsPassword />,
"update-student-user": <UpdateStudentUser />,
}[view]
}

Expand Down
2 changes: 1 addition & 1 deletion src/pages/teacherDashboard/classes/JoinClassRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ const JoinClassRequest: FC<JoinClassRequestProps> = () => {
const navigate = useNavigate()
const [wasAccepted, setWasAccepted] = useState(false)
const params = useParams({
classId: classIdSchema().required(),
classId: classIdSchema.required(),
userId: yup.number().required(),
})

Expand Down
11 changes: 11 additions & 0 deletions src/pages/teacherDashboard/classes/class/Class.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import * as pages from "codeforlife/components/page"
import { type FC, useState } from "react"
import { Button } from "@mui/material"
import { Navigate } from "codeforlife/components/router"
import { SecurityOutlined as SecurityOutlinedIcon } from "@mui/icons-material"
import { type StudentUser } from "codeforlife/api"
import { useParams } from "codeforlife/hooks"

import { type ListUsersResult } from "../../../../api/user"
import ResetStudentsPasswordDialog from "./ResetStudentsPasswordDialog"
import { classIdSchema } from "../../../../app/schemas"
import { paths } from "../../../../routes"

export interface ClassProps {}

const Class: FC<ClassProps> = () => {
const params = useParams({ classId: classIdSchema.required() })
const [dialog, setDialog] = useState<"reset-students-password">()
// @ts-expect-error temp fix until setStudentUsers is used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -29,6 +34,11 @@ const Class: FC<ClassProps> = () => {
},
})

if (!params)
return <Navigate to={paths.teacher.dashboard.tab.classes._} replace />

const { classId } = params

function closeDialog() {
setDialog(undefined)
}
Expand All @@ -46,6 +56,7 @@ const Class: FC<ClassProps> = () => {
</Button>
</pages.Section>
<ResetStudentsPasswordDialog
classId={classId}
open={dialog === "reset-students-password"}
onClose={closeDialog}
studentUsers={Object.values(studentUsers)}
Expand Down
Loading

0 comments on commit baf9ffc

Please sign in to comment.