diff --git a/.gitignore b/.gitignore index c7b36789..d311079b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ yarn-error.log* /backend/static *.sqlite3 /frontend/build +node_modules diff --git a/frontend/package.json b/frontend/package.json index 80255680..63593101 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,14 +28,16 @@ "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.12", "@reduxjs/toolkit": "^1.9.3", - "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v1.5.5", + "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v1.7.1", + "formik": "^2.2.9", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.5", "react-router-dom": "^6.8.2", "react-scripts": "^5.0.1", "sass": "^1.58.3", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yup": "^1.1.1" }, "devDependencies": { "@cypress/code-coverage": "^3.10.0", @@ -60,4 +62,4 @@ "nyc": { "exclude": [] } -} +} \ No newline at end of file diff --git a/frontend/public/handlebars_template.html b/frontend/public/handlebars_template.html index a6826c3a..07b7b26a 100644 --- a/frontend/public/handlebars_template.html +++ b/frontend/public/handlebars_template.html @@ -3,6 +3,7 @@ <head> <title>Code for Life</title> + <base href="/"> <link rel="icon" type="image/x-icon" href="{% static '{{ faviconUrl }}' %}"> <style> html, diff --git a/frontend/public/index.html b/frontend/public/index.html index cd62b183..83523cc4 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -20,6 +20,7 @@ Learn how to configure a non-root public URL by running `npm run build`. --> <title>Code for Life</title> + <base href="/"> <link rel="icon" type="image/x-icon" href="favicon.ico"> <style> html, diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 2ef71ae1..a0c25a8e 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -15,12 +15,14 @@ import Newsletter from '../pages/newsletter/Newsletter'; import Forbidden from '../pages/forbidden/Forbidden'; import PageNotFound from '../pages/pageNotFound/PageNotFound'; import InternalServerError from '../pages/internalServerError/InternalServerError'; +import EmailVerification from 'pages/emailVerification/EmailVerification'; export const paths = { home: '/', teachers: '/teachers', students: '/students', register: '/register', + emailVerification: '/register/email-verification', aboutUs: '/about-us', codingClubs: '/coding-clubs', getInvolved: '/get-involved', @@ -90,6 +92,10 @@ const router = createBrowserRouter([ { path: paths.internalServerError, element: <InternalServerError /> + }, + { + path: paths.emailVerification, + element: <EmailVerification /> } ]); diff --git a/frontend/src/app/theme.ts b/frontend/src/app/theme.ts index cec24ab6..4e4ccb4b 100644 --- a/frontend/src/app/theme.ts +++ b/frontend/src/app/theme.ts @@ -2,51 +2,59 @@ import { createTheme, ThemeOptions } from '@mui/material/styles'; import { theme as cflTheme } from 'codeforlife'; +// Styles shared by all form components. +export const formStyleOverrides = { + fontFamily: '"Inter"', + fontSize: '14px', + fontWeight: 500, + marginBottom: '12px' +}; + const options: ThemeOptions = { typography: { h1: { fontFamily: '"SpaceGrotesk"', fontWeight: 500, - marginBottom: 24 + marginBottom: '24px' }, h2: { fontFamily: '"SpaceGrotesk"', fontWeight: 500, - marginBottom: 22 + marginBottom: '22px' }, h3: { fontFamily: '"SpaceGrotesk"', fontWeight: 500, - marginBottom: 20 + marginBottom: '20px' }, h4: { fontFamily: '"SpaceGrotesk"', fontWeight: 500, - marginBottom: 18 + marginBottom: '18px' }, h5: { fontFamily: '"SpaceGrotesk"', fontWeight: 500, - marginBottom: 16 + marginBottom: '16px' }, h6: { fontFamily: '"SpaceGrotesk"', fontWeight: 500, - marginBottom: 14 + marginBottom: '14px' }, body1: { fontFamily: '"Inter"', - fontSize: 18, - marginBottom: 16 + fontSize: '18px', + marginBottom: '16px' }, body2: { fontFamily: '"Inter"', - fontSize: 16, - marginBottom: 14 + fontSize: '16px', + marginBottom: '14px' }, button: { fontFamily: '"Inter"', - fontSize: 14, + fontSize: '14px', fontWeight: 550 } }, @@ -80,13 +88,17 @@ const options: ThemeOptions = { MuiFormControlLabel: { defaultProps: { sx: { - '.MuiTypography-root': { m: 0 } + '.MuiTypography-root': { + ...formStyleOverrides, + m: 0 + } } } }, MuiInputBase: { styleOverrides: { root: { + background: 'white', margin: 0 } } @@ -114,6 +126,22 @@ const options: ThemeOptions = { padding: 0 }) } + }, + MuiFormHelperText: { + styleOverrides: { + root: { + ...formStyleOverrides, + marginTop: 4, + marginLeft: 4 + } + } + }, + MuiCheckbox: { + styleOverrides: { + root: { + color: 'white' + } + } } // MuiToolbar: { // styleOverrides: { diff --git a/frontend/src/components/DatePicker.tsx b/frontend/src/components/DatePicker.tsx new file mode 100644 index 00000000..66b8992e --- /dev/null +++ b/frontend/src/components/DatePicker.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { + Unstable_Grid2 as Grid, + Select, + SelectProps, + MenuItem, + FormHelperText, + FormHelperTextProps +} from '@mui/material'; + +import { formStyleOverrides } from '../app/theme'; + +const monthOptions = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' +]; + +export interface DatePickerProps { + defaultsToToday?: boolean, + previousYears?: number, + helperText?: string, + formHelperTextProps?: FormHelperTextProps, + onChange: (date: Date | undefined) => void +} + +const DatePicker: React.FC<DatePickerProps> = ({ + defaultsToToday = false, + previousYears = 150, + helperText, + formHelperTextProps, + onChange +}) => { + const now = new Date(); + + const [date, setDate] = React.useState((defaultsToToday) + ? { day: now.getDay(), month: now.getMonth(), year: now.getFullYear() } + : { day: 0, month: 0, year: 0 } + ); + + const dayIsDisabled = date.month === 0 || date.year === 0; + + if ([date.day, date.month, date.year].every(n => n !== 0)) { + onChange(new Date(date.year, date.month, date.day)); + } + + function getLastDay(month: number, year: number): number { + return new Date(year, month, 0).getDate(); + } + + function _onChange( + key: 'day' | 'month' | 'year', + value: number | string + ): void { + const newDate = { ...date }; + newDate[key] = Number(value); + + if (key !== 'day' && + !dayIsDisabled && + newDate.day > getLastDay(newDate.month, newDate.year) + ) { + newDate.day = 0; + onChange(undefined); + } + + setDate(newDate); + } + + function getDayOptions(): number[] { + return Array + .from(Array(getLastDay(date.month, date.year)).keys()) + .map(day => day + 1); + } + + const yearOptions = Array + .from(Array(previousYears).keys()) + .map(year => year + 1 - previousYears + now.getFullYear()) + .reverse(); + + const commonSelectProps: SelectProps<number> = { + style: { backgroundColor: 'white', width: '100%' }, + size: 'small' + }; + + return ( + <Grid + container + columnSpacing={2} + marginBottom={formStyleOverrides.marginBottom} + > + {helperText !== undefined && helperText !== '' && + <Grid xs={12}> + <FormHelperText {...formHelperTextProps}> + {helperText} + </FormHelperText> + </Grid> + } + <Grid xs={4}> + <Select + id='select-day' + value={date.day} + onChange={(event) => { _onChange('day', event.target.value); }} + disabled={dayIsDisabled} + {...commonSelectProps} + > + <MenuItem className='header' value={0}> + Day + </MenuItem> + {!dayIsDisabled && getDayOptions().map((day) => + <MenuItem key={`day-${day}`} value={day} dense> + {day} + </MenuItem> + )} + </Select> + </Grid> + <Grid xs={4}> + <Select + id='select-month' + value={date.month} + onChange={(event) => { _onChange('month', event.target.value); }} + {...commonSelectProps} + > + <MenuItem className='header' value={0}> + Month + </MenuItem> + {monthOptions.map((month, index) => + <MenuItem key={`month-${month}`} value={index + 1} dense> + {month} + </MenuItem> + )} + </Select> + </Grid> + <Grid xs={4}> + <Select + id='select-year' + value={date.year} + onChange={(event) => { _onChange('year', event.target.value); }} + {...commonSelectProps} + > + <MenuItem className='header' value={0}> + Year + </MenuItem> + {yearOptions.map((year) => + <MenuItem key={`year-${year}`} value={year} dense> + {year} + </MenuItem> + )} + </Select> + </Grid> + </Grid> + ); +}; + +export default DatePicker; diff --git a/frontend/src/components/PageSection.tsx b/frontend/src/components/PageSection.tsx index 86fecd9e..ba6bb003 100644 --- a/frontend/src/components/PageSection.tsx +++ b/frontend/src/components/PageSection.tsx @@ -14,10 +14,17 @@ export interface PageSectionProps extends Pick<Grid2Props, ( py?: boolean background?: string className?: string + maxWidth?: Breakpoint } const PageSection: React.FC<PageSectionProps> = ({ - bgcolor, children, px = true, py = true, background, className + bgcolor, + children, + px = true, + py = true, + background, + className, + maxWidth = process.env.REACT_APP_CONTAINER_MAX_WIDTH as Breakpoint }) => { return <> <Grid @@ -27,7 +34,7 @@ const PageSection: React.FC<PageSectionProps> = ({ sx={{ background, bgcolor }} > <Container - maxWidth={process.env.REACT_APP_CONTAINER_MAX_WIDTH as Breakpoint} + maxWidth={maxWidth} className={className} > {children} diff --git a/frontend/src/components/formik/CflCheckboxField.tsx b/frontend/src/components/formik/CflCheckboxField.tsx new file mode 100644 index 00000000..7f1627ee --- /dev/null +++ b/frontend/src/components/formik/CflCheckboxField.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { + FormControlLabel, + FormControlLabelProps, + Checkbox, + CheckboxProps, + IconProps +} from '@mui/material'; +import { + Error as ErrorIcon +} from '@mui/icons-material'; + +import { formStyleOverrides } from '../../app/theme'; +import CflField, { CflFieldProps } from './CflField'; + +export type CflCheckboxFieldProps = ( + Omit<CflFieldProps, ( + 'type' | + 'as' | + 'asProps' | + 'component' | + 'render' | + 'children' | + 'errorIconProps' | + 'errorMessageProps' | + 'stackProps' + )> & + CheckboxProps & { + errorIconProps?: Omit<IconProps, 'children'>, + formControlLabelProps: Omit<FormControlLabelProps, 'control'> + } +); + +const CflCheckboxField: React.FC<CflCheckboxFieldProps> = ({ + name, + tooltipProps, + errorIconProps = { style: { color: 'white' } }, + formControlLabelProps, + ...checkboxProps +}) => { + return ( + <CflField + name={name} + type='checkbox' + // @ts-expect-error prematurely complains about missing props. + as={FormControlLabel} + asProps={{ + control: <Checkbox {...checkboxProps} />, + ...formControlLabelProps + }} + stackProps={{ + direction: 'row', + style: { + marginBottom: formStyleOverrides.marginBottom + } + }} + tooltipProps={tooltipProps} + errorIconProps={{ + style: { + margin: 'auto 12px auto 0px', + ...errorIconProps.style + }, + children: <ErrorIcon />, + ...errorIconProps + }} + /> + ); +}; + +export default CflCheckboxField; diff --git a/frontend/src/components/formik/CflField.tsx b/frontend/src/components/formik/CflField.tsx new file mode 100644 index 00000000..9d12ae82 --- /dev/null +++ b/frontend/src/components/formik/CflField.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { + Tooltip, + TooltipProps, + Stack, + StackProps, + Icon, + IconProps +} from '@mui/material'; +import { + ErrorOutline as ErrorOutlineIcon +} from '@mui/icons-material'; +import { + Field, + FieldConfig, + ErrorMessage, + ErrorMessageProps +} from 'formik'; + +export interface CflFieldProps extends FieldConfig<any> { + stackProps?: StackProps, + tooltipProps?: Omit<TooltipProps, 'title' | 'children'>, + errorIconProps?: IconProps + errorMessageProps?: Omit<ErrorMessageProps, ( + 'name' | + 'children' + )> & { + afterField?: boolean + }, + asProps?: Record<string, any> +} + +const CflField: React.FC<CflFieldProps> = ({ + name, + stackProps, + tooltipProps, + errorIconProps = { color: 'error' }, + errorMessageProps = { + afterField: true, + render: undefined, + children: undefined + }, + asProps, + ...otherFieldProps +}) => { + let { + afterField, + render, + ...otherErrorMessageProps + } = errorMessageProps; + + if (render === undefined) { + const { + children = <ErrorOutlineIcon />, + ...otherErrorIconProps + } = errorIconProps; + + render = (errorMessage: string) => ( + <Tooltip title={errorMessage} {...tooltipProps}> + <Icon {...otherErrorIconProps}> + {children} + </Icon> + </Tooltip> + ); + } + + const errorMessage = ( + <ErrorMessage + name={name} + render={render} + {...otherErrorMessageProps} + /> + ); + + return ( + <Stack {...stackProps}> + {!afterField && errorMessage} + <Field name={name} {...otherFieldProps} {...asProps} /> + {afterField && errorMessage} + </Stack> + ); +}; + +export default CflField; diff --git a/frontend/src/components/formik/CflPasswordFields.tsx b/frontend/src/components/formik/CflPasswordFields.tsx new file mode 100644 index 00000000..4203018f --- /dev/null +++ b/frontend/src/components/formik/CflPasswordFields.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { + Typography, + Stack, + InputAdornment +} from '@mui/material'; +import { + Circle as CircleIcon, + Security as SecurityIcon +} from '@mui/icons-material'; + +import CflTextField, { CflTextFieldProps } from './CflTextField'; + +export function isStrongPassword( + password: string, + { forTeacher }: { forTeacher: boolean } +): boolean { + return (forTeacher) + ? (password.length >= 10 && + !( + password.search(/[A-Z]/) === -1 || + password.search(/[a-z]/) === -1 || + password.search(/[0-9]/) === -1 || + password.search(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/) === -1 + )) + : (password.length >= 8 && + !( + password.search(/[A-Z]/) === -1 || + password.search(/[a-z]/) === -1 || + password.search(/[0-9]/) === -1 + )); +} + +interface CflPasswordFieldProps extends Omit<CflTextFieldProps, ( + 'type' | + 'onKeyUp' | + 'InputProps' +)> { } + +export interface CflPasswordFieldsProps + extends Omit<CflPasswordFieldProps, 'name'> { + forTeacher: boolean, + passwordFieldProps?: CflPasswordFieldProps, + repeatPasswordFieldProps?: CflPasswordFieldProps +} + +const CflPasswordFields: React.FC<CflPasswordFieldsProps> = ({ + forTeacher, + passwordFieldProps = { + name: 'password', + placeholder: 'Password', + helperText: 'Enter a password' + }, + repeatPasswordFieldProps = { + name: 'repeatPassword', + placeholder: 'Repeat password', + helperText: 'Repeat password' + }, + ...commonPasswordFieldProps +}) => { + const [password, setPassword] = React.useState(''); + + // TODO: Load from central storage. + const mostUsed = ['Abcd1234', 'Password1', 'Qwerty123']; + + let status: { name: string, color: string }; + if (password === '') { + status = { name: 'No password!', color: '#FF0000' }; + } else if (mostUsed.includes(password)) { + status = { name: 'Password too common!', color: '#DBA901' }; + } else if (isStrongPassword(password, { forTeacher })) { + status = { name: 'Strong password!', color: '#088A08' }; + } else { + status = { name: 'Password too weak!', color: '#DBA901' }; + } + + const inputProps: CflTextFieldProps['InputProps'] = { + endAdornment: ( + <InputAdornment position='end'> + <SecurityIcon /> + </InputAdornment> + ) + }; + + return <> + <CflTextField + type='password' + InputProps={inputProps} + onKeyUp={(event) => { + setPassword((event.target as HTMLTextAreaElement).value); + }} + {...passwordFieldProps} + {...commonPasswordFieldProps} + /> + <CflTextField + type='password' + InputProps={inputProps} + {...repeatPasswordFieldProps} + {...commonPasswordFieldProps} + /> + <Stack direction='row' justifyContent='center'> + <CircleIcon + htmlColor={status.color} + stroke='white' + strokeWidth={1} + /> + <Typography + fontSize={18} + fontWeight={500} + margin={0} + > + {status.name} + </Typography> + </Stack> + </>; +}; + +export default CflPasswordFields; diff --git a/frontend/src/components/formik/CflTextField.tsx b/frontend/src/components/formik/CflTextField.tsx new file mode 100644 index 00000000..946b49e5 --- /dev/null +++ b/frontend/src/components/formik/CflTextField.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { + TextField, + TextFieldProps, + InputAdornment, + Tooltip, + Icon +} from '@mui/material'; +import { + ErrorOutline as ErrorOutlineIcon +} from '@mui/icons-material'; + +import CflField, { CflFieldProps } from './CflField'; + +export type CflTextFieldProps = ( + Omit<TextFieldProps, 'name'> & + Omit<CflFieldProps, ( + 'as' | + 'asProps' | + 'component' | + 'render' | + 'children' | + 'errorMessageProps' | + 'stackProps' + )> +); + +const CflTextField: React.FC<CflTextFieldProps> = ({ + name, + type, + tooltipProps, + errorIconProps = { color: 'error' }, + InputProps = {}, + onKeyUp, + ...otherTextFieldProps +}) => { + const [errorMessage, setErrorMessage] = React.useState(''); + const [valueChanged, setValueChanged] = React.useState(false); + + const resetErrorMessage = (): void => { + if (valueChanged) { + setErrorMessage(''); + } else { + setValueChanged(true); + } + }; + + let { + endAdornment, + ...otherInputProps + } = InputProps; + + if (errorMessage !== '') { + endAdornment = ( + <> + {endAdornment} + <InputAdornment position='end'> + <Tooltip title={errorMessage} {...tooltipProps}> + <Icon {...errorIconProps}> + <ErrorOutlineIcon /> + </Icon> + </Tooltip> + </InputAdornment> + </> + ); + } + + if (onKeyUp === undefined) { + onKeyUp = resetErrorMessage; + } else { + const originalOnKeyUp = onKeyUp; + onKeyUp = (event) => { + originalOnKeyUp(event); + resetErrorMessage(); + }; + } + + const textFieldProps: TextFieldProps = { + name, + type, + onKeyUp, + InputProps: { + endAdornment, + ...otherInputProps + }, + ...otherTextFieldProps + }; + + return ( + <CflField + name={name} + type={type} + as={TextField} + asProps={textFieldProps} + errorMessageProps={{ + render: (errorMessage) => { + setErrorMessage(errorMessage); + setValueChanged(false); + return <></>; + } + }} + /> + ); +}; + +export default CflTextField; diff --git a/frontend/src/images/paper_plane.png b/frontend/src/images/paper_plane.png new file mode 100644 index 00000000..6324bdf7 Binary files /dev/null and b/frontend/src/images/paper_plane.png differ diff --git a/frontend/src/images/sadface.png b/frontend/src/images/sadface.png new file mode 100644 index 00000000..e9b86779 Binary files /dev/null and b/frontend/src/images/sadface.png differ diff --git a/frontend/src/pages/emailVerification/EmailVerification.tsx b/frontend/src/pages/emailVerification/EmailVerification.tsx new file mode 100644 index 00000000..49b00bfb --- /dev/null +++ b/frontend/src/pages/emailVerification/EmailVerification.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { + getSearchParams, + stringToBoolean +} from 'codeforlife/lib/esm/helpers'; + +import SadFaceImg from '../../images/sadface.png'; +import PaperPlaneImg from '../../images/paper_plane.png'; +import { paths } from '../../app/router'; +import BasePage from '../../pages/BasePage'; +import PageSection from '../../components/PageSection'; +import Status from './Status'; + +const EmailVerification: React.FC = () => { + const navigate = useNavigate(); + + const params = getSearchParams({ + success: stringToBoolean, + forTeacher: stringToBoolean + }); + + React.useEffect(() => { + if (params === null) { + navigate(paths.internalServerError); + } + }, []); + + return ( + <BasePage> + <PageSection maxWidth='md' className='flex-center'> + {params !== null && (params.success + ? <Status + forTeacher={params.forTeacher} + header='We need to verify your email address' + body={[ + 'An email has been sent to the address you provided.', + 'Please follow the link within the email to verify your details. This will expire in 1 hour.', + 'If you don\'t receive the email within the next few minutes, check your spam folder.' + ]} + imageProps={{ + alt: 'PaperPlane', + src: PaperPlaneImg + }} + /> + : <Status + forTeacher={params.forTeacher} + header='Your email address verification failed' + body={[ + 'You used an invalid link, either you mistyped the URL or that link is expired.', + 'When you next attempt to log in, you will be sent a new verification email.' + ]} + imageProps={{ + alt: 'SadFace', + src: SadFaceImg + }} + /> + )} + </PageSection> + </BasePage> + ); +}; + +export default EmailVerification; diff --git a/frontend/src/pages/emailVerification/Status.tsx b/frontend/src/pages/emailVerification/Status.tsx new file mode 100644 index 00000000..dbfd2b6e --- /dev/null +++ b/frontend/src/pages/emailVerification/Status.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { + Button, + Stack, + Typography, + createTheme, + useTheme, + ThemeProvider, + ThemeOptions, + SxProps +} from '@mui/material'; +import { + Circle as CircleIcon, + Hexagon as HexagonIcon +} from '@mui/icons-material'; + +import { + Image, + ImageProps +} from 'codeforlife/lib/esm/components'; + +import { paths } from '../../app/router'; + +const Status: React.FC<{ + forTeacher: boolean, + header: string, + body: string[], + imageProps: ImageProps +}> = ({ forTeacher, header, body, imageProps }) => { + const themeOptions: ThemeOptions = { + components: { + MuiTypography: { + styleOverrides: { + root: { + color: forTeacher ? 'white' : 'black' + } + } + } + } + }; + + const commonIconSxProps: SxProps = { + display: { xs: 'none', md: 'block' }, + fontSize: '200px', + position: 'absolute' + }; + + return ( + <ThemeProvider theme={createTheme(useTheme(), themeOptions)}> + <Stack + paddingY={{ xs: 2, sm: 3, md: 5 }} + paddingX={{ xs: 2, sm: 5, md: 10 }} + alignItems='center' + bgcolor={forTeacher ? '#ee0857' : '#ffc709'} + position='relative' + > + <CircleIcon + color={forTeacher ? 'secondary' : 'primary'} + sx={{ + ...commonIconSxProps, + top: '5%', + left: '0%', + transform: 'translate(-60%, 0%)' + }} + /> + <HexagonIcon + color={forTeacher ? 'tertiary' : 'secondary'} + sx={{ + ...commonIconSxProps, + bottom: '5%', + right: '0%', + transform: 'translate(60%, 0%)' + }} + /> + <Typography variant='h4' paddingY={1} textAlign='center'> + {header} + </Typography> + <Image + maxWidth='100px' + marginY={5} + {...imageProps} + /> + {body.map((text, index) => + <Typography key={index}> + {text} + </Typography> + )} + <Button + href={paths.home} + color={forTeacher ? 'tertiary' : 'white'} + style={{ marginTop: 30 }} + > + Back to homepage + </Button> + </Stack> + </ThemeProvider> + ); +}; + +export default Status; diff --git a/frontend/src/pages/register/BaseForm.tsx b/frontend/src/pages/register/BaseForm.tsx new file mode 100644 index 00000000..01604249 --- /dev/null +++ b/frontend/src/pages/register/BaseForm.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { + useTheme, + createTheme, + ThemeProvider, + Stack, + StackProps, + Typography, + FormHelperText +} from '@mui/material'; + +const BaseForm: React.FC<{ + header: string, + subheader: string, + description: string, + bgcolor: StackProps['bgcolor'], + children: StackProps['children'], + color: string +}> = + ({ + header, + subheader, + description, + bgcolor, + children, + color + }) => ( + <ThemeProvider theme={createTheme(useTheme(), { + components: { + MuiTypography: { + styleOverrides: { + root: { color, fontWeight: 500 } + } + }, + MuiTextField: { + styleOverrides: { + root: { background: 'transparent' } + } + }, + MuiFormHelperText: { + styleOverrides: { + root: { color } + } + } + } + })}> + <Stack bgcolor={bgcolor} p={3} height='100%'> + <Typography variant='h4' textAlign='center'> + {header} + </Typography> + <Typography> + {subheader} + </Typography> + <FormHelperText style={{ marginBottom: 30 }}> + {description} + </FormHelperText> + {children} + </Stack> + </ThemeProvider> + ); + +export default BaseForm; diff --git a/frontend/src/pages/register/IndependentForm.tsx b/frontend/src/pages/register/IndependentForm.tsx new file mode 100644 index 00000000..90acd19e --- /dev/null +++ b/frontend/src/pages/register/IndependentForm.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { + Stack, + Link, + Button, + FormHelperText +} from '@mui/material'; +import { + ChevronRight as ChevronRightIcon +} from '@mui/icons-material'; +import { + Formik, + Form, + FormikHelpers +} from 'formik'; +import * as Yup from 'yup'; + +import { paths } from '../../app/router'; +import BaseForm from './BaseForm'; +import DatePicker from '../../components/DatePicker'; +import CflTextField from '../../components/formik/CflTextField'; +import CflCheckboxField from 'components/formik/CflCheckboxField'; +import CflPasswordFields, { isStrongPassword } from '../../components/formik/CflPasswordFields'; + +interface IndependentFormValues { + fullName: string; + email: string; + termsOfUse: boolean; + receiveUpdates: boolean; + password: string; + repeatPassword: string; +} + +const initialValues: IndependentFormValues = { + fullName: '', + email: '', + termsOfUse: false, + receiveUpdates: false, + password: '', + repeatPassword: '' +}; + +const validationSchema: { [K in keyof IndependentFormValues]: Yup.Schema } = { + fullName: Yup + .string() + .required('This field is required'), + email: Yup + .string() + .email('Invalid email address') + .required('This field is required'), + termsOfUse: Yup + .bool() + .oneOf([true], 'You need to accept the terms and conditions'), + receiveUpdates: Yup + .bool(), + password: Yup + .string() + .required('This field is required') + .test( + 'independent-password-strength-check', + 'Invalid password', + (password) => isStrongPassword(password, { forTeacher: false }) + ), + repeatPassword: Yup + .string() + .oneOf([Yup.ref('password'), undefined], "Passwords don't match") + .required('This field is required') +}; + +const IndependentForm: React.FC = () => { + const [yearsOfAge, setYearsOfAge] = React.useState<number>(); + + const EmailApplicableAge = 13; + const ReceiveUpdateAge = 18; + + function onDateOfBirthChange(dob: Date | undefined): void { + setYearsOfAge((dob === undefined) + ? undefined + : Math.floor( + (new Date().getTime() - dob.getTime()) / + (1000 * 60 * 60 * 24 * 365) + ) + ); + } + + return ( + <BaseForm + header='Independent learner' + subheader='Register below if you are not part of a school or club and wish to set up a home learning account.' + description='You will have access to learning resources for Rapid Router.' + bgcolor='#ffc709' // TODO: use theme.palette + color='black' + > + <DatePicker + helperText='Please enter your date of birth (we do not store this information).' + onChange={onDateOfBirthChange} + /> + {yearsOfAge !== undefined && + <Formik + initialValues={initialValues} + validationSchema={Yup.object(validationSchema)} + onSubmit={( + values: IndependentFormValues, + { setSubmitting }: FormikHelpers<IndependentFormValues> + ) => { + // TODO: to call backend + setSubmitting(false); + }} + > + {(formik) => ( + <Form> + <CflTextField + name='fullName' + placeholder='Full name' + helperText='Enter your full name' + size='small' + /> + <CflTextField + name='email' + placeholder='Email address' + helperText={(yearsOfAge >= EmailApplicableAge) + ? 'Enter your email address' + : 'Please enter your parent\'s email address' + } + size='small' + /> + {yearsOfAge < EmailApplicableAge && + <FormHelperText style={{ fontWeight: 'bold' }}> + We will send your parent/guardian an email to ask them to activate the account for you. Once they've done this you'll be able to log in using your name and password. + </FormHelperText> + } + {yearsOfAge >= EmailApplicableAge && + <CflCheckboxField + name='termsOfUse' + formControlLabelProps={{ + label: <> + I have read and understood the + <Link + href={paths.termsOfUse} + target='_blank' + color='inherit' + className='body' + > + Terms of use + </Link> + and the + <Link + href={paths.privacyNotice} + target='_blank' + color='inherit' + className='body' + > + Privacy notice + </Link> + . + </> + }} + /> + } + {yearsOfAge >= ReceiveUpdateAge && + <CflCheckboxField + name='receiveUpdates' + formControlLabelProps={{ + label: 'Sign up to receive updates about Code for Life games and teaching resources.' + }} + /> + } + <CflPasswordFields + forTeacher={false} + size='small' + /> + <Stack direction='row' justifyContent='end'> + <Button + type='submit' + endIcon={<ChevronRightIcon />} + disabled={!formik.dirty} + > + Register + </Button> + </Stack> + </Form> + )} + </Formik> + } + </BaseForm> + ); +}; + +export default IndependentForm; diff --git a/frontend/src/pages/register/Register.tsx b/frontend/src/pages/register/Register.tsx index 749c4647..d71536a2 100644 --- a/frontend/src/pages/register/Register.tsx +++ b/frontend/src/pages/register/Register.tsx @@ -4,13 +4,23 @@ import { } from '@mui/material'; import BasePage from '../../pages/BasePage'; +import PageSection from '../../components/PageSection'; +import TeacherForm from './TeacherForm'; +import IndependentForm from './IndependentForm'; const Register: React.FC = () => { return ( <BasePage> - <Grid xs={12}> - TODO - </Grid> + <PageSection> + <Grid container spacing={2}> + <Grid xs={12} md={6}> + <TeacherForm /> + </Grid> + <Grid xs={12} md={6}> + <IndependentForm /> + </Grid> + </Grid> + </PageSection> </BasePage> ); }; diff --git a/frontend/src/pages/register/TeacherForm.tsx b/frontend/src/pages/register/TeacherForm.tsx new file mode 100644 index 00000000..7a7a17b1 --- /dev/null +++ b/frontend/src/pages/register/TeacherForm.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { + Stack, + Link, + Button, + InputAdornment +} from '@mui/material'; +import { + EmailOutlined as EmailOutlinedIcon, + ChevronRight as ChevronRightIcon +} from '@mui/icons-material'; +import { + Formik, + FormikHelpers, + Form +} from 'formik'; +import * as Yup from 'yup'; + +import { paths } from 'app/router'; +import BaseForm from './BaseForm'; +import CflTextField from '../../components/formik/CflTextField'; +import CflCheckboxField from 'components/formik/CflCheckboxField'; +import CflPasswordFields, { isStrongPassword } from '../../components/formik/CflPasswordFields'; + +interface TeacherFormValues { + firstName: string; + lastName: string; + email: string; + termsOfUse: boolean; + receiveUpdates: boolean; + password: string; + repeatPassword: string; +} + +const initialValues: TeacherFormValues = { + firstName: '', + lastName: '', + email: '', + termsOfUse: false, + receiveUpdates: false, + password: '', + repeatPassword: '' +}; + +const validationSchema: { [K in keyof TeacherFormValues]: Yup.Schema } = { + firstName: Yup + .string() + .required('This field is required'), + lastName: Yup + .string() + .required('This field is required'), + email: Yup + .string() + .email('Invalid email address') + .required('This field is required'), + termsOfUse: Yup + .bool() + .oneOf([true], 'You need to accept the terms and conditions'), + receiveUpdates: Yup + .bool(), + password: Yup + .string() + .required('This field is required') + .test( + 'teacher-password-strength-check', + 'Invalid password', + (password) => isStrongPassword(password, { forTeacher: true }) + ), + repeatPassword: Yup + .string() + .oneOf([Yup.ref('password'), undefined], "Passwords don't match") + .required('This field is required') +}; + +const TeacherForm: React.FC = () => { + return ( + <BaseForm + header='Teacher/Tutor' + subheader='Register below to create your school or club.' + description='You will have access to teaching resources, progress tracking and lesson plans for both Rapid Router and Kurono.' + bgcolor='#ee0857' // TODO: use theme.palette + color='white' + > + <Formik + initialValues={initialValues} + validationSchema={Yup.object(validationSchema)} + onSubmit={( + values: TeacherFormValues, + { setSubmitting }: FormikHelpers<TeacherFormValues> + ) => { + // TODO: to call backend + setSubmitting(false); + }} + > + {(formik) => ( + <Form> + <CflTextField + name='firstName' + placeholder='First name' + helperText='Enter your first name' + size='small' + /> + <CflTextField + name='lastName' + placeholder='Last name' + helperText='Enter your last name' + size='small' + /> + <CflTextField + name='email' + placeholder='Email address' + helperText='Enter your email address' + size='small' + InputProps={{ + endAdornment: ( + <InputAdornment position='end'> + <EmailOutlinedIcon /> + </InputAdornment> + ) + }} + /> + <CflCheckboxField + name='termsOfUse' + formControlLabelProps={{ + label: <> + I am over 18 years old have read and understood the + <Link + href={paths.termsOfUse} + target='_blank' + color='inherit' + className='body' + > + Terms of use + </Link> + and the + <Link + href={paths.privacyNotice} + target='_blank' + color='inherit' + className='body' + > + Privacy notice + </Link> + . + </> + }} + /> + <CflCheckboxField + name='receiveUpdates' + formControlLabelProps={{ + label: 'Sign up to receive updates about Code for Life games and teaching resources.' + }} + /> + <CflPasswordFields + forTeacher={true} + size='small' + /> + <Stack direction='row' justifyContent='end'> + <Button + type='submit' + endIcon={<ChevronRightIcon />} + disabled={!formik.dirty} + > + Register + </Button> + </Stack> + </Form> + )} + </Formik> + </BaseForm> + ); +}; + +export default TeacherForm; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 42446b98..405b6920 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2269,14 +2269,14 @@ integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== "@types/node@*": - version "18.16.3" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.3.tgz#6bda7819aae6ea0b386ebc5b24bdf602f1b42b01" - integrity sha512-OPs5WnnT1xkCBiuQrZA4+YAV4HEJejmHneyraIaxsbev5yCEr6KMwINNFP9wQeFIw8FWcoTqF3vQsa5CDaI+8Q== + version "20.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.0.0.tgz#081d9afd28421be956c1a47ced1c9a0034b467e2" + integrity sha512-cD2uPTDnQQCVpmRefonO98/PPijuOnnEy5oytWJFPY1N9aJCz2wJ5kSGWO+zJoed2cY2JxQh6yBuUq4vIn61hw== "@types/node@^14.14.31": - version "14.18.43" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.43.tgz#679e000d9f1d914132ea295b4a1ffdf20370ec49" - integrity sha512-n3eFEaoem0WNwLux+k272P0+aq++5o05bA9CfiwKPdYPB5ZambWKdWoeHy7/OJiizMhzg27NLaZ6uzjLTzXceQ== + version "14.18.44" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.44.tgz#1d42ba325c5b434ee78437378ef0b7589f32c151" + integrity sha512-Sg79dXC3jrRlG0QOLrK5eq2hRzpU4pkD7xBiYNYJ6r9OitJMxkpTpWf6m3qa2AWzb76uMHx+6x5T1Y/WAiS3nw== "@types/node@^17.0.45": version "17.0.45" @@ -2335,16 +2335,16 @@ "@types/react" "^17" "@types/react-transition-group@^4.4.5": - version "4.4.5" - resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" - integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA== + version "4.4.6" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e" + integrity sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew== dependencies: "@types/react" "*" "@types/react@*", "@types/react@^18.0.28": - version "18.2.4" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.4.tgz#970e6d56f6d3fd8bd2cb1d1f042aef1d0426d08e" - integrity sha512-IvAIhJTmKAAJmCIcaa6+5uagjyh+9GvcJ/thPZcw+i+vx+22eHlTy2Q1bJg/prES57jehjebq9DnIhOTtIhmLw== + version "18.2.5" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.5.tgz#f9403e1113b12b53f7edcdd9a900c10dd4b49a59" + integrity sha512-RuoMedzJ5AOh23Dvws13LU9jpZHIc/k90AgmK7CecAYeWmSr3553L4u5rk4sWAPBuQosfT7HmTfG4Rg5o4nGEA== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -4078,9 +4078,9 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" -"codeforlife@github:ocadotechnology/codeforlife-package-javascript#v1.5.5": +"codeforlife@github:ocadotechnology/codeforlife-package-javascript#v1.7.1": version "1.0.0" - resolved "https://codeload.github.com/ocadotechnology/codeforlife-package-javascript/tar.gz/52217abca0badc2b047dd632a6b7ee272d5d0e44" + resolved "https://codeload.github.com/ocadotechnology/codeforlife-package-javascript/tar.gz/05cfa24eb87061de1ed7e85e0d0a5d81e7981ab5" dependencies: "@emotion/react" "^11.10.6" "@emotion/styled" "^11.10.6" @@ -4916,6 +4916,11 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + deepmerge@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" @@ -5240,9 +5245,9 @@ ejs@^3.1.6: jake "^10.8.5" electron-to-chromium@^1.4.284: - version "1.4.382" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.382.tgz#87e659b0f0d5f7b19759038871bac0a327191f82" - integrity sha512-czMavlW52VIPgutbVL9JnZIZuFijzsG1ww/1z2Otu1r1q+9Qe2bTsH3My3sZarlvwyqHM6+mnZfEnt2Vr4dsIg== + version "1.4.384" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.384.tgz#5c23b5579930dec9af2a93edafddbe991542eace" + integrity sha512-I97q0MmRAAqj53+a8vZsDkEXBZki+ehYAOPzwtQzALip52aEp2+BJqHFtTlsfjoqVZYwPpHC8wM6MbsSZQ/Eqw== elliptic@^6.5.3: version "6.5.4" @@ -6285,6 +6290,19 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formik@^2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -8492,6 +8510,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.clone@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" @@ -10620,6 +10643,11 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -10843,6 +10871,11 @@ react-error-overlay@^6.0.11: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -12486,11 +12519,21 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A== +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tiny-inflate@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== +tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tmp@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -12555,6 +12598,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + tough-cookie@^2.3.3, tough-cookie@^2.5.0, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -12607,7 +12655,7 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.8.1: +tslib@^1.10.0, tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -12680,7 +12728,7 @@ type-fest@^0.8.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^2.13.0: +type-fest@^2.13.0, type-fest@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -13622,3 +13670,13 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.1.1.tgz#49dbcf5ae7693ed0a36ed08a9e9de0a09ac18e6b" + integrity sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0"