diff --git a/frontend/package.json b/frontend/package.json index d8a728e6..c226d516 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,7 @@ "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.12", "@reduxjs/toolkit": "^1.9.3", - "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v1.15.2", + "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v1.15.3", "country-list": "^2.3.0", "formik": "^2.2.9", "react": "^18.2.0", diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index a00b9846..849ed33d 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -15,9 +15,7 @@ import HomeLearning from '../pages/homeLearning/HomeLearning'; import PrivacyNotice from '../pages/privacyNotice/PrivacyNotice'; import TermsOfUse from '../pages/termsOfUse/TermsOfUse'; 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 Error from '../pages/error/Error'; import TeacherSchool from '../pages/teacherDashboard/TeacherSchool'; import TeacherClasses from '../pages/teacherDashboard/TeacherClasses'; import TeacherAccount from '../pages/teacherDashboard/account/TeacherAccount'; @@ -74,9 +72,13 @@ export const paths = _('', { termsOfUse: _('/terms-of-use'), newsletter: _('/newsletter'), error: _('/error', { - forbidden: _('/forbidden'), - pageNotFound: _('/page-not-found'), - internalServerError: _('/internal-server-error') + forbidden: _('/?type=forbidden'), + pageNotFound: _('/?type=pageNotFound'), + tooManyRequests: _('/?type=tooManyRequests', { + teacher: _('&userType=teacher'), + independent: _('&userType=independent') + }), + internalServerError: _('/?type=internalServerError') }), rapidRouter: _('/rapid-router'), kurono: _('/kurono') @@ -139,18 +141,9 @@ const router = createBrowserRouter([ path: paths.newsletter._, element: }, - // TODO: merge separate error pages into one page with multiple states. - { - path: paths.error.forbidden._, - element: - }, - { - path: paths.error.pageNotFound._, - element: - }, { - path: paths.error.internalServerError._, - element: + path: paths.error._, + element: }, { path: paths.teacher.dashboard.school._, diff --git a/frontend/src/components/BaseErrorPage.tsx b/frontend/src/components/BaseErrorPage.tsx deleted file mode 100644 index 6b6f5372..00000000 --- a/frontend/src/components/BaseErrorPage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { - Unstable_Grid2 as Grid, - Stack, - Typography, - Button -} from '@mui/material'; - -import { Image, ImageProps } from 'codeforlife/lib/esm/components'; - -import BasePage from '../pages/BasePage'; -import { paths } from '../app/router'; -import PageSection from './PageSection'; - -export interface ErrorTemplateProps { - header: string, - subheader: string, - body: string, - img: Pick -}; - -const BaseErrorPage: React.FC = ({ - header, subheader, body, img -}) => { - return ( - - - - - - - {header} - - - {subheader} - - - {body} - - - - - - - - - - - ); -}; - -export default BaseErrorPage; diff --git a/frontend/src/pages/error/Error.tsx b/frontend/src/pages/error/Error.tsx new file mode 100644 index 00000000..34268b52 --- /dev/null +++ b/frontend/src/pages/error/Error.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { + Unstable_Grid2 as Grid, + Stack, + Typography, + Button +} from '@mui/material'; + +import { Image } from 'codeforlife/lib/esm/components'; +import { SearchParams } from 'codeforlife/lib/esm/helpers'; + +import { paths } from '../../app/router'; +import PageSection from '../../components/PageSection'; +import BasePage from '../BasePage'; +import ErrorProps, { + forbidden403, + pageNotFound404, + tooManyRequests429, + internalServerError500 +} from './ErrorProps'; + +const Error: React.FC = () => { + const errorTypes = [ + '403', 'forbidden', + '404', 'pageNotFound', + '429', 'tooManyRequests', + '500', 'internalServerError' + ] as const; + + const userTypes = [ + 'teacher', + 'independent' + ] as const; + + let params = SearchParams.get<{ + type: typeof errorTypes[number], + userType?: typeof userTypes[number] + }>({ + type: { + validate: SearchParams.validate.inOptions(errorTypes) + }, + userType: { + validate: SearchParams.validate.inOptions(userTypes), + isRequired: false + } + }); + + if (params === null || ( + ['429', 'tooManyRequests'].includes(params.type) && + params.userType === undefined + )) { + // Special case. Don't redirect to an error page - we're already here. + params = { type: 'internalServerError' }; + } + + let errorProps: ErrorProps; + switch (params.type) { + case '403': + case 'forbidden': + errorProps = forbidden403(); + break; + case '404': + case 'pageNotFound': + errorProps = pageNotFound404(); + break; + case '429': + case 'tooManyRequests': + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + errorProps = tooManyRequests429(params.userType!); + break; + case '500': + case 'internalServerError': + errorProps = internalServerError500(); + break; + } + + return ( + + + + + + + {errorProps.header} + + + {errorProps.subheader} + + + {errorProps.body} + + + + + + + + + + + ); +}; + +export default Error; diff --git a/frontend/src/pages/error/ErrorProps.tsx b/frontend/src/pages/error/ErrorProps.tsx new file mode 100644 index 00000000..a3b974bd --- /dev/null +++ b/frontend/src/pages/error/ErrorProps.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { + Link +} from '@mui/material'; + +import { ImageProps } from 'codeforlife/lib/esm/components'; + +import { paths } from '../../app/router'; +import KirstyImage from '../../images/kirsty.png'; +import NigelImage from '../../images/nigel.png'; +import DeeImage from '../../images/dee.png'; +import PhilImage from '../../images/phil.png'; + +export default interface ErrorProps { + header: string; + subheader: string; + body: string | React.ReactElement; + imageProps: ImageProps; +}; + +export function forbidden403(): ErrorProps { + return { + header: 'Oi!', + subheader: 'Kirsty says you\'re not allowed there.', + body: 'Those pages belong to Kirsty. She won\'t let you in even if you ask nicely.', + imageProps: { alt: 'kirsty', src: KirstyImage } + }; +} + +export function pageNotFound404(): ErrorProps { + return { + header: 'Uh oh!', + subheader: 'Sorry, Nigel can\'t find the page you were looking for.', + body: 'This might be because you have entered a web address incorrectly or the page has moved.', + imageProps: { alt: 'nigel', src: NigelImage } + }; +} + +export function tooManyRequests429( + userType: 'teacher' | 'independent' +): ErrorProps { + const resetPasswordHref = (userType === 'teacher') + ? paths.resetPassword.teacher._ + : paths.resetPassword.independent._; + + return { + header: 'Temporary lock out!', + subheader: 'Your account has been temporarily blocked as there were too many unsuccessful requests.', + body: <>If you wish to proceed, please reset your password. Alternatively, you will need to wait 24 hours for your account to be unlocked again., + imageProps: { alt: 'phil', src: PhilImage } + }; +} + +export function internalServerError500(): ErrorProps { + return { + header: 'Zap!', + subheader: 'Oh dear! Something technical has gone wrong.', + body: 'Dee will attempt to fix this soon.', + imageProps: { alt: 'dee', src: DeeImage } + }; +} diff --git a/frontend/src/pages/forbidden/Forbidden.tsx b/frontend/src/pages/forbidden/Forbidden.tsx deleted file mode 100644 index dfbe9c34..00000000 --- a/frontend/src/pages/forbidden/Forbidden.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import BaseErrorPage from '../../components/BaseErrorPage'; -import KirstyImage from '../../images/kirsty.png'; - -const Forbidden: React.FC = () => { - return ( - - ); -}; - -export default Forbidden; diff --git a/frontend/src/pages/internalServerError/InternalServerError.tsx b/frontend/src/pages/internalServerError/InternalServerError.tsx deleted file mode 100644 index 37735d57..00000000 --- a/frontend/src/pages/internalServerError/InternalServerError.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import BaseErrorPage from '../../components/BaseErrorPage'; -import DeeImage from '../../images/dee.png'; - -const InternalServerError: React.FC = () => { - return ( - - ); -}; - -export default InternalServerError; diff --git a/frontend/src/pages/pageNotFound/PageNotFound.tsx b/frontend/src/pages/pageNotFound/PageNotFound.tsx deleted file mode 100644 index a93c87b7..00000000 --- a/frontend/src/pages/pageNotFound/PageNotFound.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import BaseErrorPage from '../../components/BaseErrorPage'; -import NigelImage from '../../images/nigel.png'; - -const PageNotFound: React.FC = () => { - return ( - - ); -}; - -export default PageNotFound; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e28a0364..ae73ba5a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4093,9 +4093,9 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" -"codeforlife@github:ocadotechnology/codeforlife-package-javascript#v1.15.2": - version "1.15.2" - resolved "https://codeload.github.com/ocadotechnology/codeforlife-package-javascript/tar.gz/4e27f768943238910c925d0da9e16c0b19b25556" +"codeforlife@github:ocadotechnology/codeforlife-package-javascript#v1.15.3": + version "1.15.3" + resolved "https://codeload.github.com/ocadotechnology/codeforlife-package-javascript/tar.gz/638976530e7fde7bf75fd78efb5fbfabcf7e2a3e" dependencies: "@emotion/react" "^11.10.6" "@emotion/styled" "^11.10.6"