diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 50744796971107a2ef1033cf6633ad7f5d606733..4a15b5d501f57377404d834f5f431c2d44ff02ac 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -19,7 +19,8 @@ "comment": "Comment", "email": "E-mail", "fullName": "Full name", - "mobilePhone": "Mobile phone" + "mobilePhone": "Mobile phone", + "passportNumber": "Passport number" }, "sponsor": { "contactInfo": "Contact information", @@ -57,7 +58,8 @@ "roleEndRequired": "Role end date is required", "emailRequired": "E-mail is required", "invalidMobilePhoneNumber": "Invalid phone number", - "invalidEmail": "Invalid e-mail address" + "invalidEmail": "Invalid e-mail address", + "passportNumberRequired": "Passport number required" }, "button": { "back": "Back", diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index 9fd9ddf7ec7cfb224806bd8378f2ad33fea17cc4..3da62f8a71a4bb4c227d91c943e80391c61c575f 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -23,7 +23,8 @@ "comment": "Kommentar", "email": "E-post", "fullName": "Fullt navn", - "mobilePhone": "Mobilnummer" + "mobilePhone": "Mobilnummer", + "passportNumber": "Passport number" }, "sponsor": { "contactInfo": "Kontaktinformasjon", @@ -60,7 +61,8 @@ "roleEndRequired": "Sluttdato for rolle er obligatorisk", "emailRequired": "E-post er obligatorisk", "invalidMobilePhoneNumber": "Ugyldig telefonnummer", - "invalidEmail": "Ugyldig e-postadresse" + "invalidEmail": "Ugyldig e-postadresse", + "passportNumberRequired": "Passnummer er obligatorisk" }, "button": { "back": "Tilbake", diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 396128803ff4dc93ddaef58955f0466b9e2640a2..779e929b9737d2aaf0275a7d0ed07b2711d9c7c9 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -20,7 +20,8 @@ "comment": "Kommentar", "email": "E-post", "fullName": "Fullt namn", - "mobilePhone": "Mobilnummer" + "mobilePhone": "Mobilnummer", + "passportNumber": "Passport number" }, "sponsor": { "contactInfo": "Kontaktinformasjon", @@ -57,7 +58,8 @@ "roleEndRequired": "Sluttdato for rolle er obligatorisk", "emailRequired": "E-post er obligatorisk", "invalidMobilePhoneNumber": "Ugyldig telefonnummer", - "invalidEmail": "Ugyldig e-postadresse" + "invalidEmail": "Ugyldig e-postadresse", + "passportNumberRequired": "Passnummer er obligatorisk" }, "button": { "back": "Tilbake", diff --git a/frontend/src/routes/frontpage/index.tsx b/frontend/src/routes/frontpage/index.tsx index 07ef806e04bef454ec1c815f45d1a97abb2562d2..54205f53e10c02996b76d37e64740be16c39277e 100644 --- a/frontend/src/routes/frontpage/index.tsx +++ b/frontend/src/routes/frontpage/index.tsx @@ -28,6 +28,9 @@ export default function FrontPage() { <li key="register"> <Link to="/register/">Registration</Link> </li> + <li key="guestregister"> + <Link to="/guestregister/">Guest Registration</Link> + </li> </ul> </p> <Debug /> diff --git a/frontend/src/routes/guest/register/authenticationMethod.ts b/frontend/src/routes/guest/register/authenticationMethod.ts new file mode 100644 index 0000000000000000000000000000000000000000..d25eecdb6e3d0883fb2a1337fc22a5aa9193f50d --- /dev/null +++ b/frontend/src/routes/guest/register/authenticationMethod.ts @@ -0,0 +1,9 @@ +/** + * Controls what is shown in the registration form + */ +enum AuthenticationMethod { + Feide, + NationalIdNumberOrPassport, +} + +export default AuthenticationMethod diff --git a/frontend/src/routes/guest/register/enteredGuestData.ts b/frontend/src/routes/guest/register/enteredGuestData.ts index 24322e5499efef6749dc359df78ba4e6f8453798..73b19b6600c0ea9d06b687986e56d2228a009e99 100644 --- a/frontend/src/routes/guest/register/enteredGuestData.ts +++ b/frontend/src/routes/guest/register/enteredGuestData.ts @@ -1,6 +1,10 @@ /** - * This is data the guest has entered about himself + * This is data the guest has entered about himself. It is stored + * separate from ContactInformationBySponsor to make it clear that + * most of the data there the guest cannot change. */ export type EnteredGuestData = { mobilePhone: string + nationalIdNumber?: string + passportNumber?: string } diff --git a/frontend/src/routes/guest/register/guestDataForm.ts b/frontend/src/routes/guest/register/guestDataForm.ts index 158e2b41d35e26f10d5d642419f80ad53a7ab318..7c7394db526b96eeefe80e67fa293018f42e2020 100644 --- a/frontend/src/routes/guest/register/guestDataForm.ts +++ b/frontend/src/routes/guest/register/guestDataForm.ts @@ -1,6 +1,8 @@ /** * This is data about the guest that the sponsor has entered when the invitation was created */ +import AuthenticationMethod from './authenticationMethod' + export type ContactInformationBySponsor = { first_name: string last_name: string @@ -12,7 +14,13 @@ export type ContactInformationBySponsor = { role_end: string comment?: string - // TODO Add e-mail back - // email: string + // These fields are in the form, but it is not expected that + // they are set, with the exception of e-mail, when the guest + // first follows the invite link + email?: string + mobile_phone?: string + fnr?: string + passport?: string + user_information_source: AuthenticationMethod } diff --git a/frontend/src/routes/guest/register/index.tsx b/frontend/src/routes/guest/register/index.tsx index 97ad8ae50fec659b8bb2166d621af8817ed13628..cf506ca29dd639ef04650686fa65b0a53228288f 100644 --- a/frontend/src/routes/guest/register/index.tsx +++ b/frontend/src/routes/guest/register/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Box, Button } from '@mui/material' @@ -10,7 +10,14 @@ import GuestRegisterStep from './registerPage' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' import { EnteredGuestData } from './enteredGuestData' import { ContactInformationBySponsor } from './guestDataForm' +import AuthenticationMethod from './authenticationMethod' +import { postJsonOpts } from '../../../utils' +enum SubmitState { + NotSubmitted, + Submitted, + SubmittedError, +} /* * When the guest reaches this page he has already an invite ID set in his session. @@ -18,49 +25,82 @@ import { ContactInformationBySponsor } from './guestDataForm' export default function GuestRegister() { const { t } = useTranslation(['common']) const history = useHistory() - const [guestFormData, setGuestFormData] = useState<ContactInformationBySponsor>({ - first_name: '', - last_name: '', - ou_name_en: '', - ou_name_nb: '', - role_name_en: '', - role_name_nb: '', - role_start: '', - role_end: '', - comment: undefined, - }) + const [errorState, setErrorState] = useState<string | null>(null) + const [submitState, setSubmitState] = useState<SubmitState>( + SubmitState.NotSubmitted + ) + const guestRegisterRef = useRef<GuestRegisterCallableMethods>(null) + const REGISTER_STEP = 0 + const [activeStep, setActiveStep] = useState(0) + + const [guestFormData, setGuestFormData] = + useState<ContactInformationBySponsor>({ + first_name: '', + last_name: '', + ou_name_en: '', + ou_name_nb: '', + role_name_en: '', + role_name_nb: '', + role_start: '', + role_end: '', + comment: '', + email: '', + mobile_phone: '', + fnr: '', + passport: '', + user_information_source: AuthenticationMethod.Feide, + }) const guestContactInfo = async () => { - const response = await fetch('/api/ui/v1/invited') - - if (response.ok) { - response.json().then(jsonResponse => { - setGuestFormData({ - first_name: jsonResponse.person.first_name, - last_name: jsonResponse.person.last_name, - ou_name_en: jsonResponse.role.ou_name_en, - ou_name_nb: jsonResponse.role.ou_name_nb, - role_name_en: jsonResponse.role.role_name_en, - role_name_nb: jsonResponse.role.role_name_nb, - role_start: jsonResponse.role.start, - role_end: jsonResponse.role.end, - comment: jsonResponse.role.comments, + try { + const response = await fetch('/api/ui/v1/invited') + + if (response.ok) { + response.json().then((jsonResponse) => { + const userInformationSource = + jsonResponse.meta.user_information_source + + // TODO Set up so that information about the authentication method is included in the reponse from the server (if the user is logged in by Feide) + const userSource = + userInformationSource === 'feide' + ? AuthenticationMethod.Feide + : AuthenticationMethod.NationalIdNumberOrPassport + + setGuestFormData({ + first_name: jsonResponse.person.first_name, + last_name: jsonResponse.person.last_name, + ou_name_en: jsonResponse.role.ou_name_en, + ou_name_nb: jsonResponse.role.ou_name_nb, + role_name_en: jsonResponse.role.role_name_en, + role_name_nb: jsonResponse.role.role_name_nb, + role_start: jsonResponse.role.start, + role_end: jsonResponse.role.end, + comment: jsonResponse.role.comments, + + email: jsonResponse.person.email, + mobile_phone: jsonResponse.person.mobile_phone, + fnr: jsonResponse.fnr, + passport: jsonResponse.passport, + + user_information_source: userSource, + }) }) - }) + } + } catch (error) { + console.log(error) } } - useEffect(() => { guestContactInfo() }, []) - const guestRegisterRef = useRef<GuestRegisterCallableMethods>(null) - - const REGISTER_STEP = 0 - const [activeStep, setActiveStep] = useState(0) - const handleNext = () => { + // TODO Go to consent page + // setActiveStep((prevActiveStep) => prevActiveStep + 1) + } + + const handleSave = () => { if (activeStep === 0) { if (guestRegisterRef.current) { guestRegisterRef.current.doSubmit() @@ -69,17 +109,35 @@ export default function GuestRegister() { } const handleForwardFromRegister = ( - updateFormData: EnteredGuestData, + updateFormData: EnteredGuestData ): void => { - // TODO Go to consent page - // setActiveStep((prevActiveStep) => prevActiveStep + 1) + const payload = { + person: { + mobile_phone: updateFormData.mobilePhone, + fnr: updateFormData.nationalIdNumber, + passport: updateFormData.passportNumber, + }, + } + + fetch('/api/ui/v1/invited/', postJsonOpts(payload)) + .then((response) => { + if (response.ok) { + setSubmitState(SubmitState.Submitted) + } else { + setSubmitState(SubmitState.SubmittedError) + console.error(`Server responded with status: ${response.status}`) + } + }) + .catch((error) => { + console.error(error) + setSubmitState(SubmitState.SubmittedError) + }) } const handleCancel = () => { history.push('/') } - return ( <Page> <OverviewGuestButton /> @@ -94,20 +152,29 @@ export default function GuestRegister() { )} </Box> - <Box sx={{ display: 'flex', flexDirection: 'row', pt: 2, color: 'primary.main', paddingBottom: '1rem' }}> + <Box + sx={{ + display: 'flex', + flexDirection: 'row', + pt: 2, + color: 'primary.main', + paddingBottom: '1rem', + }} + > {activeStep === REGISTER_STEP && ( - <Button data-testid='button-next' - sx={{ color: 'theme.palette.secondary', mr: 1 }} - onClick={handleNext}> + <Button + data-testid="button-next" + sx={{ color: 'theme.palette.secondary', mr: 1 }} + onClick={handleNext} + > {t('button.next')} </Button> )} - <Button - onClick={handleCancel} - > - {t('button.cancel')} - </Button> + <Button onClick={handleCancel}>{t('button.cancel')}</Button> + + {/*TODO This button is only for testing while developing*/} + <Button onClick={handleSave}>{t('button.save')}</Button> </Box> </Page> ) diff --git a/frontend/src/routes/guest/register/registerPage.test.tsx b/frontend/src/routes/guest/register/registerPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0f98a5ce827581c1b3942499579e0e9a6465ada0 --- /dev/null +++ b/frontend/src/routes/guest/register/registerPage.test.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { render, screen, waitFor } from 'test-utils' +import AdapterDateFns from '@mui/lab/AdapterDateFns' +import { LocalizationProvider } from '@mui/lab' +import GuestRegisterStep from './registerPage' +import { EnteredGuestData } from './enteredGuestData' +import AuthenticationMethod from './authenticationMethod' + +test('Guest register page showing passport field on manual registration', async () => { + const nextHandler = (enteredGuestData: EnteredGuestData) => {} + const guestData = { + first_name: '', + last_name: '', + ou_name_en: '', + ou_name_nb: '', + role_name_en: '', + role_name_nb: '', + role_start: '', + role_end: '', + comment: '', + email: '', + mobile_phone: '', + fnr: '', + passport: '', + user_information_source: AuthenticationMethod.NationalIdNumberOrPassport, + } + + render( + <LocalizationProvider dateAdapter={AdapterDateFns}> + <GuestRegisterStep nextHandler={nextHandler} guestData={guestData} /> + </LocalizationProvider> + ) + + await waitFor(() => { + expect(screen.queryByTestId('passport_number_input')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/routes/guest/register/registerPage.tsx b/frontend/src/routes/guest/register/registerPage.tsx index c9350748a6e0ca19bd91b173c1d8fece915fdd77..a4233299690e1d8315ec79ba9bfdb54aded17d66 100644 --- a/frontend/src/routes/guest/register/registerPage.tsx +++ b/frontend/src/routes/guest/register/registerPage.tsx @@ -1,17 +1,12 @@ -import { - Box, - Stack, - TextField, - Typography, -} from '@mui/material' +import { Box, Stack, TextField, Typography } from '@mui/material' import { SubmitHandler, useForm } from 'react-hook-form' import React, { forwardRef, Ref, useImperativeHandle } from 'react' import { useTranslation } from 'react-i18next' import { ContactInformationBySponsor } from './guestDataForm' import { EnteredGuestData } from './enteredGuestData' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' -import { isValidMobilePhoneNumber } from '../../../utils' - +import { isValidFnr, isValidMobilePhoneNumber } from '../../../utils' +import AuthenticationMethod from './authenticationMethod' interface GuestRegisterProperties { nextHandler(guestData: EnteredGuestData): void @@ -19,107 +14,162 @@ interface GuestRegisterProperties { guestData: ContactInformationBySponsor } - -const GuestRegisterStep = forwardRef((props: GuestRegisterProperties, ref: Ref<GuestRegisterCallableMethods>) => { - const { i18n, t } = useTranslation(['common']) - const { nextHandler, guestData } = props - - const submit: SubmitHandler<EnteredGuestData> = (data) => { - nextHandler(data) +/** + * This component is the form where the guest enters missing information about himself and + * where he can see the data the sponsor has entered and the role. The page may also + * be populated with data from a third-party like Feide if the guest logged in using that. + */ +const GuestRegisterStep = forwardRef( + (props: GuestRegisterProperties, ref: Ref<GuestRegisterCallableMethods>) => { + const { i18n, t } = useTranslation(['common']) + const { nextHandler, guestData } = props + + const submit: SubmitHandler<EnteredGuestData> = (data) => { + nextHandler(data) + } + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<EnteredGuestData>() + const onSubmit = handleSubmit(submit) + + function doSubmit() { + return onSubmit() + } + + useImperativeHandle(ref, () => ({ doSubmit })) + + return ( + <> + <Typography + variant="h5" + sx={{ + paddingTop: '1rem', + paddingBottom: '1rem', + }} + > + {t('guestRegisterWizardText.yourContactInformation')} + </Typography> + <Typography sx={{ paddingBottom: '2rem' }}> + {t('guestRegisterWizardText.contactInformationDescription')} + </Typography> + <Box sx={{ maxWidth: '30rem' }}> + <form onSubmit={onSubmit}> + <Stack spacing={2}> + <TextField + id="firstName" + label={t('input.firstName')} + value={guestData.first_name} + disabled + /> + <TextField + id="lastName" + label={t('input.lastName')} + value={guestData.last_name} + disabled + /> + + <TextField + id="email" + label={t('input.email')} + value={guestData.email} + disabled + /> + + <TextField + id="mobilephone" + label={t('input.mobilePhone')} + error={!!errors.mobilePhone} + helperText={errors.mobilePhone && errors.mobilePhone.message} + {...register('mobilePhone', { + validate: isValidMobilePhoneNumber, + })} + /> + + {guestData.user_information_source === + AuthenticationMethod.NationalIdNumberOrPassport && ( + <> + <TextField + id="passport" + data-testid="passport_number_input" + label={t('input.passportNumber')} + {...register('passportNumber', { + required: t<string>('validation.passportNumberRequired'), + })} + /> + + <TextField + id="national_id_number" + label={t('input.nationalIdNumber')} + {...register('nationalIdNumber', { + validate: isValidFnr, + })} + /> + </> + )} + + {guestData.user_information_source === + AuthenticationMethod.Feide && ( + <TextField + id="national_id_number" + data-testid="national_id_number_feide" + label={t('input.nationalIdNumber')} + disabled + /> + )} + + <Typography variant="h5" sx={{ paddingTop: '1rem' }}> + {t('guestRegisterWizardText.yourGuestPeriod')} + </Typography> + <Typography sx={{ paddingBottom: '1rem' }}> + {t('guestRegisterWizardText.guestPeriodDescription')} + </Typography> + + <TextField + id="ou-unit" + value={ + i18n.language === 'en' + ? guestData.ou_name_en + : guestData.ou_name_nb + } + label={t('ou')} + disabled + /> + + <TextField + id="roleType" + label={t('input.roleType')} + value={ + i18n.language === 'en' + ? guestData.role_name_en + : guestData.role_name_nb + } + disabled + /> + + <TextField + id="rolePeriod" + label={t('period')} + value={`${guestData.role_start} - ${guestData.role_end}`} + disabled + /> + + <TextField + id="comment" + label={t('input.comment')} + multiline + rows={5} + value={guestData.comment} + disabled + /> + </Stack> + </form> + </Box> + </> + ) } - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm<EnteredGuestData>() - const onSubmit = handleSubmit(submit) - - function doSubmit() { - return onSubmit() - } - - useImperativeHandle(ref, () => ({ doSubmit })) - - return ( - <> - <Typography variant='h5' sx={{ - paddingTop: '1rem', - paddingBottom: '1rem', - }}>{t('guestRegisterWizardText.yourContactInformation')}</Typography> - <Typography - sx={{ paddingBottom: '2rem' }}>{t('guestRegisterWizardText.contactInformationDescription')}</Typography> - <Box sx={{ maxWidth: '30rem' }}> - <form onSubmit={onSubmit}> - <Stack spacing={2}> - <TextField - id='firstName' - label={t('input.firstName')} - value={guestData.first_name} - disabled - /> - <TextField - id='lastName' - label={t('input.lastName')} - value={guestData.last_name} - disabled - /> - - {/* TODO Put e-mail field back */} - {/*<TextField*/} - {/* id='email'*/} - {/* label={t('input.email')}*/} - {/* value={guestData.email}*/} - {/* disabled*/} - {/*/>*/} - - <TextField - id='mobilephone' - label={t('input.mobilePhone')} - error={!!errors.mobilePhone} - helperText={errors.mobilePhone && errors.mobilePhone.message} - {...register('mobilePhone', { - validate: isValidMobilePhoneNumber, - })} - /> - - <Typography variant='h5' - sx={{ paddingTop: '1rem' }}>{t('guestRegisterWizardText.yourGuestPeriod')}</Typography> - <Typography - sx={{ paddingBottom: '1rem' }}>{t('guestRegisterWizardText.guestPeriodDescription')}</Typography> - - <TextField id='ou-unit' - value={i18n.language === 'en' ? guestData.ou_name_en : guestData.ou_name_nb} - label={t('ou')} - disabled /> - - <TextField - id='roleType' - label={t('input.roleType')} - value={i18n.language === 'en' ? guestData.ou_name_en : guestData.ou_name_nb} - disabled - /> - - <TextField - id='rolePeriod' - label={t('period')} - value={`${guestData.role_start} - ${guestData.role_end}`} - disabled - /> - - <TextField - id='comment' - label={t('input.comment')} - multiline - rows={5} - value={guestData.comment} - disabled - /> - </Stack> - </form> - </Box> - </> - ) -}) +) export default GuestRegisterStep diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index a62d0d7f75f6ae2da3f547e464fa2093ebdaca80..d51a653a87117354ae68203e6f6cdda25befae54 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -16,6 +16,7 @@ import Footer from 'routes/components/footer' import Header from 'routes/components/header' import NotFound from 'routes/components/notFound' import ProtectedRoute from 'components/protectedRoute' +import GuestRegister from './guest/register' const AppWrapper = styled('div')({ display: 'flex', @@ -56,6 +57,7 @@ export default function App() { </ProtectedRoute> <Route path="/invite/:id" component={InviteLink} /> <Route path="/invite/" component={Invite} /> + <Route path="/guestregister/" component={GuestRegister} /> <Route> <NotFound /> </Route> diff --git a/frontend/src/routes/invite/index.tsx b/frontend/src/routes/invite/index.tsx index 6d44123163867ae9b567e21122f7e23ce21dc167..2edc66658d9b3f67443f542e25f704c525a7fcf1 100644 --- a/frontend/src/routes/invite/index.tsx +++ b/frontend/src/routes/invite/index.tsx @@ -1,5 +1,6 @@ import Page from 'components/page' import { useUserContext } from 'contexts' +import { Link } from 'react-router-dom' function Invite() { const { user } = useUserContext() @@ -11,6 +12,9 @@ function Invite() { TODO: Put information about login options, and buttons to them on this page </p> + After login or when clicking on the manual registration option, the user + should be sent here: + <Link to="/guestregister/">Guest Registration</Link> </Page> ) }