diff --git a/frontend/package.json b/frontend/package.json index 206b6ac5a3f96ffe01f7d9aa4b1c1c4fba923630..1aef0771b8c64246a3e85a30633ee48971f7e995 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -84,6 +84,7 @@ "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.25.1", "eslint-plugin-react-hooks": "^4.2.0", + "jest-fetch-mock": "^3.0.3", "jest-junit": "^12.2.0" } } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index a7da5745d1a2f12c554974895c469f3b5c99af42..b67cb08d7f8323f0e5db5df9f092049fb847bf07 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -34,7 +34,6 @@ "endNow": "End role", "overviewGuest": "Guest overview" }, - "loading": "Loading...", "termsHeader": "Terms", "staging": "Staging", @@ -79,7 +78,8 @@ "back": "Back", "next": "Next", "save": "Save", - "cancel": "Cancel" + "cancel": "Cancel", + "backToFrontPage": "Go to front page" }, "registerWizardText": { "registerPage": "Enter the contact information for the guest below. All fields are mandatory.", @@ -103,5 +103,9 @@ "yourGuestPeriod": "Your guest period", "guestPeriodDescription": "Period registered for your guest role." }, - "yourGuestAccount": "Your guest account" + "yourGuestAccount": "Your guest account", + "feideId": "Feide ID", + "thankYou": "Thanks!", + "sponsorSubmitSuccessDescription": "Your registration has been completed. You will receive an e-mail when the guest has filled in the missing information, so that the guest account can be approved.", + "guestSubmitSuccessDescription": "Your registration is now completed. You will receive an e-mail or SMS when your account has been created." } diff --git a/frontend/public/locales/en/invite.json b/frontend/public/locales/en/invite.json new file mode 100644 index 0000000000000000000000000000000000000000..70693d275ab5a2c20d9513f1854d1fa4e0643844 --- /dev/null +++ b/frontend/public/locales/en/invite.json @@ -0,0 +1,6 @@ +{ + "description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number.", + "header": "Guest Registration", + "login": "Log in with FEIDE", + "manual": "Registrate manually" +} diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index b127b11c1e5ac23d2578f6e709ef60e5829c97ff..7e09d2553bba25220c3fb98634b234877b826c88 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -78,7 +78,8 @@ "back": "Tilbake", "next": "Neste", "save": "Lagre", - "cancel": "Avbryt" + "cancel": "Avbryt", + "backToFrontPage": "Tilbake til forsiden" }, "registerWizardText": { "registerPage": "Fyll inn kontaktinformasjonen til gjesten under. Alle feltene er obligatoriske.", @@ -102,5 +103,9 @@ "yourGuestPeriod": "Din gjesteperiode", "guestPeriodDescription": "Registrert periode for din gjesterolle." }, - "yourGuestAccount": "Din gjestekonto" + "yourGuestAccount": "Din gjestekonto", + "feideId": "Feide ID", + "thankYou": "Takk!", + "sponsorSubmitSuccessDescription": "Din registrering er nå fullført. Du vil få en e-post når gjesten har fylt inn informasjonen som mangler, slik at gjestekontoen kan godkjennes.", + "guestSubmitSuccessDescription": "Din registrering er nå fullført. Du vil få en e-post eller SMS når kontoen er opprettet." } diff --git a/frontend/public/locales/nb/invite.json b/frontend/public/locales/nb/invite.json new file mode 100644 index 0000000000000000000000000000000000000000..6f6b73f0d316f226596aeaa894184030db619f91 --- /dev/null +++ b/frontend/public/locales/nb/invite.json @@ -0,0 +1,6 @@ +{ + "description": "Vennligst velg hvordan du vil logge inn for å fullføre registreringen. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikke er mulig kan du fylle ut registreringskjemaet manuelt med passnummer", + "header": "Gjestetjenesten", + "login": "Logg inn med FEIDE", + "manual": "Registrer manuelt" +} diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index f7a66816f3aee5f1b846d0dac1cc6d954e287849..ea89200e2d08c003582b35781855f6d91ed99bae 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -79,7 +79,8 @@ "back": "Tilbake", "next": "Neste", "save": "Lagre", - "cancel": "Avbryt" + "cancel": "Avbryt", + "backToFrontPage": "Tilbake til forsida" }, "registerWizardText": { "registerPage": "Fyll inn kontaktinformasjonen til gjesten under. Alle feltene er obligatoriske.", @@ -103,5 +104,9 @@ "yourGuestPeriod": "Din gjesteperiode", "guestPeriodDescription": "Registrert periode for di gjesterolle." }, - "yourGuestAccount": "Din gjestekonto" + "yourGuestAccount": "Din gjestekonto", + "feideId": "Feide ID", + "thankYou": "Takk!", + "sponsorSubmitSuccessDescription": "Di registrering er no fullført. Du vil få ein e-post når gjesten har fylt inn informasjonen som manglar, slik at gjestekontoen kan godkjennast.", + "guestSubmitSuccessDescription": "Di registrering er no fullført. Du vil få ein e-post eller SMS når kontoen er oppretta." } diff --git a/frontend/public/locales/nn/invite.json b/frontend/public/locales/nn/invite.json new file mode 100644 index 0000000000000000000000000000000000000000..abb2afaf0747f645944c94525cc49b28015e87bf --- /dev/null +++ b/frontend/public/locales/nn/invite.json @@ -0,0 +1,6 @@ +{ + "description": "Ver venleg og vel korleis du vil logge inn for å fullføre registreringa. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikkje er mogeleg kan du fylle ut registreringskjemaet manuelt med passnummer", + "header": "Gjestetjenesten", + "login": "Logg inn med FEIDE", + "manual": "Registrer manuelt" +} diff --git a/frontend/src/components/button/index.tsx b/frontend/src/components/button/index.tsx index 86754f65372556a64daa8da31e431fc5a0aff776..255b7af8f01ad8b96e6e9a7bb1e9984ddbd076f7 100644 --- a/frontend/src/components/button/index.tsx +++ b/frontend/src/components/button/index.tsx @@ -8,7 +8,11 @@ interface IHrefButton { function HrefButton({ to, children }: IHrefButton) { return ( - <Button variant="contained" href={to} sx={{ backgroundColor: '#18191C' }}> + <Button + variant="contained" + href={to} + sx={{ backgroundColor: 'theme.palette.secondary' }} + > {children} </Button> ) diff --git a/frontend/src/components/button/linebutton.tsx b/frontend/src/components/button/linebutton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5ec984a2ab7adc84a396576b829789fcf00771a --- /dev/null +++ b/frontend/src/components/button/linebutton.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Button } from '@mui/material' + +interface IHrefButton { + to: string + children: React.ReactNode +} + +function HrefLineButton({ to, children }: IHrefButton) { + return ( + <Button href={to} sx={{ color: 'theme.palette.secondary' }}> + {children} + </Button> + ) +} + +// eslint-disable-next-line import/prefer-default-export +export { HrefLineButton } diff --git a/frontend/src/routes/guest/register/enteredGuestData.ts b/frontend/src/routes/guest/register/enteredGuestData.ts index 8e4cdb6669a170abdbf636c0948cfecefe17c309..d731360363d3a0ce30826799031b11e2c1a18d6e 100644 --- a/frontend/src/routes/guest/register/enteredGuestData.ts +++ b/frontend/src/routes/guest/register/enteredGuestData.ts @@ -4,6 +4,7 @@ * most of the data there the guest cannot change. */ export type EnteredGuestData = { + mobilePhoneCountry: string 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 cb52a87bec8507b4077dbaf17dcce03fc9e9aa82..09e7da4288e247fdf95e43dce5e164bd20956daa 100644 --- a/frontend/src/routes/guest/register/guestDataForm.ts +++ b/frontend/src/routes/guest/register/guestDataForm.ts @@ -1,9 +1,11 @@ -/** - * This is data about the guest that the sponsor has entered when the invitation was created - */ import AuthenticationMethod from './authenticationMethod' -export type ContactInformationBySponsor = { +/** + * This is the basis for the data shown in the guest registration form. + * + * It mostly contains data about the guest that was entered during the invite step. + */ +export type GuestInviteInformation = { first_name: string last_name: string ou_name_nb: string @@ -14,14 +16,20 @@ export type ContactInformationBySponsor = { role_end: string comment?: string + feide_id?: 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_country_code?: string mobile_phone?: string + fnr?: string passport?: string passportNationality?: string + countryForCallingCode?: string authentication_method: AuthenticationMethod } diff --git a/frontend/src/routes/guest/register/index.test.tsx b/frontend/src/routes/guest/register/index.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b264ad24fa70588adcf37ce9309716cae67086a --- /dev/null +++ b/frontend/src/routes/guest/register/index.test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import fetchMock, { enableFetchMocks } from 'jest-fetch-mock' +import { render, waitFor, screen } from 'test-utils' +import AdapterDateFns from '@mui/lab/AdapterDateFns' +import { LocalizationProvider } from '@mui/lab' +import GuestRegister from './index' + +enableFetchMocks() + +const testData = { + person: { + first_name: 'Test', + last_name: 'Tester', + mobile_phone: '+4797543910', + email: 'test@example.org', + fnr: '04062141242', + passport: 'NO-123456', + }, + role: { + ou_name_en: 'English organizational unit name', + ou_name_nb: 'Norsk navn organisasjonsenhet', + name_en: 'Guest role', + name_nb: 'Gjesterolle', + start: '2021-08-10', + end: '2021-08-16', + comment: '', + }, + meta: { + session_type: 'invite', + }, +} + +beforeEach(() => { + fetchMock.mockIf('/api/ui/v1/invited', () => + Promise.resolve<any>(JSON.stringify(testData)) + ) +}) + +test('Mobile phone number parsed and split correctly', async () => { + render( + <LocalizationProvider dateAdapter={AdapterDateFns}> + <GuestRegister /> + </LocalizationProvider> + ) + + await waitFor(() => screen.queryByText(testData.person.first_name)) + await waitFor(() => screen.queryByText(testData.person.last_name)) + await waitFor(() => screen.queryByText(testData.person.email)) + await waitFor(() => screen.queryByText(testData.person.fnr)) + await waitFor(() => screen.queryByText('NO')) + await waitFor(() => screen.queryByText('123456')) + + // There is no proper i18n loaded so the country name will be undefined, the country code should still show though + await waitFor(() => screen.queryByText('undefined (47)')) + await waitFor(() => screen.queryByText('97543910')) +}) diff --git a/frontend/src/routes/guest/register/index.tsx b/frontend/src/routes/guest/register/index.tsx index c0f75c2828f42cd3af8fab6fc471db2bedd3ee06..4fa17263838f99d201569bc7b0837447ec945e6b 100644 --- a/frontend/src/routes/guest/register/index.tsx +++ b/frontend/src/routes/guest/register/index.tsx @@ -5,13 +5,19 @@ import { Box, Button } from '@mui/material' import Page from 'components/page' import { useHistory } from 'react-router-dom' +import { + CountryCode, + getCountries, + getCountryCallingCode, +} from 'libphonenumber-js' import OverviewGuestButton from '../../components/overviewGuestButton' import GuestRegisterStep from './registerPage' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' import { EnteredGuestData } from './enteredGuestData' -import { ContactInformationBySponsor } from './guestDataForm' +import { GuestInviteInformation } from './guestDataForm' import AuthenticationMethod from './authenticationMethod' -import { submitJsonOpts } from '../../../utils' +import { splitPhoneNumber, submitJsonOpts } from '../../../utils' +import StepSubmitSuccessGuest from './submitSuccessPage' enum SubmitState { NotSubmitted, @@ -19,41 +25,45 @@ enum SubmitState { SubmittedError, } +enum Step { + RegisterStep = 0, + SubmitSuccessStep = 1, +} + /* * When the guest reaches this page he has already an invite ID set in his session. */ export default function GuestRegister() { const { t } = useTranslation(['common']) const history = useHistory() - // TODO On submit successful the user should be directed to some page telling h + // TODO On submit successful the user should be directed to some page telling // eslint-disable-next-line @typescript-eslint/no-unused-vars const [submitState, setSubmitState] = useState<SubmitState>( SubmitState.NotSubmitted ) const guestRegisterRef = useRef<GuestRegisterCallableMethods>(null) - const REGISTER_STEP = 0 // TODO Set step when user moves between pages // eslint-disable-next-line @typescript-eslint/no-unused-vars 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: '', - passportNationality: '', - authentication_method: AuthenticationMethod.Invite, - }) + const [guestFormData, setGuestFormData] = useState<GuestInviteInformation>({ + 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: '', + passportNationality: '', + countryForCallingCode: '', + authentication_method: AuthenticationMethod.Invite, + }) const [errorState, setErrorState] = useState<string>('') @@ -63,13 +73,29 @@ export default function GuestRegister() { if (response.ok) { response.json().then((jsonResponse) => { - console.log(`Guest data from server: ${JSON.stringify(jsonResponse)}`) + // TODO Remove after development + console.log(`Data from server: ${JSON.stringify(jsonResponse)}`) const authenticationMethod = jsonResponse.meta.session_type === 'invite' ? AuthenticationMethod.Invite : AuthenticationMethod.Feide + const [countryCode, nationalNumber] = jsonResponse.person.mobile_phone + ? splitPhoneNumber(jsonResponse.person.mobile_phone) + : ['', ''] + + let extractedCountryCode = '' + if (countryCode) { + const matchingCountries = getCountries().find( + (value) => getCountryCallingCode(value) === countryCode + ) + + if (matchingCountries && matchingCountries.length > 0) { + extractedCountryCode = matchingCountries.toString() + } + } + setGuestFormData({ first_name: jsonResponse.person.first_name, last_name: jsonResponse.person.last_name, @@ -82,11 +108,14 @@ export default function GuestRegister() { comment: jsonResponse.role.comments, email: jsonResponse.person.email, - mobile_phone: jsonResponse.person.mobile_phone, + feide_id: jsonResponse.person.feide_id, + mobile_phone_country_code: countryCode, + mobile_phone: nationalNumber, fnr: jsonResponse.fnr, passport: jsonResponse.passport, // TODO Separate out nationality based on what is in the server response passportNationality: '', + countryForCallingCode: extractedCountryCode, authentication_method: authenticationMethod, }) @@ -117,9 +146,13 @@ export default function GuestRegister() { const handleForwardFromRegister = ( updateFormData: EnteredGuestData ): void => { + // TODO Should go to consent page here. Submit should after after consent page + const payload: any = {} payload.person = {} - payload.person.mobile_phone = updateFormData.mobilePhone + payload.person.mobile_phone = `+${getCountryCallingCode( + updateFormData.mobilePhoneCountry as CountryCode + )}${updateFormData.mobilePhone}` if (updateFormData.passportNumber && updateFormData.passportNationality) { // The user has entered some passport information, check that both nationality and number are present @@ -149,6 +182,7 @@ export default function GuestRegister() { .then((response) => { if (response.ok) { setSubmitState(SubmitState.Submitted) + setActiveStep(Step.SubmitSuccessStep) } else { setSubmitState(SubmitState.SubmittedError) console.error(`Server responded with status: ${response.status}`) @@ -169,7 +203,7 @@ export default function GuestRegister() { <OverviewGuestButton /> {/* Current page in wizard */} <Box sx={{ width: '100%' }}> - {activeStep === REGISTER_STEP && ( + {activeStep === Step.RegisterStep && ( <GuestRegisterStep nextHandler={handleForwardFromRegister} guestData={guestFormData} @@ -187,7 +221,7 @@ export default function GuestRegister() { paddingBottom: '1rem', }} > - {activeStep === REGISTER_STEP && ( + {activeStep === Step.RegisterStep && ( <Button data-testid="button-next" sx={{ color: 'theme.palette.secondary', mr: 1 }} @@ -197,12 +231,18 @@ export default function GuestRegister() { </Button> )} - <Button onClick={handleCancel}>{t('button.cancel')}</Button> + {activeStep !== Step.SubmitSuccessStep && ( + <> + <Button onClick={handleCancel}>{t('button.cancel')}</Button> - {/* TODO This button is only for testing while developing */} - <Button onClick={handleSave}>{t('button.save')}</Button> + {/* TODO This button is only for testing while developing */} + <Button onClick={handleSave}>{t('button.save')}</Button> + </> + )} </Box> + {activeStep === Step.SubmitSuccessStep && <StepSubmitSuccessGuest />} + {/* TODO Give better feedback to user */} {errorState && <h2>{errorState}</h2>} </Page> diff --git a/frontend/src/routes/guest/register/registerPage.test.tsx b/frontend/src/routes/guest/register/registerPage.test.tsx index ccf4cfc8af9af38370d1c12f3b008adcc9a7ad26..634f2467b358f2858827e208a8c437f4fa8ec2dd 100644 --- a/frontend/src/routes/guest/register/registerPage.test.tsx +++ b/frontend/src/routes/guest/register/registerPage.test.tsx @@ -5,12 +5,10 @@ import { LocalizationProvider } from '@mui/lab' import GuestRegisterStep from './registerPage' import { EnteredGuestData } from './enteredGuestData' import AuthenticationMethod from './authenticationMethod' +import { GuestInviteInformation } from './guestDataForm' -test('Guest register page showing passport field on manual registration', async () => { - const nextHandler = (enteredGuestData: EnteredGuestData) => { - console.log(`Entered data: ${enteredGuestData}`) - } - const guestData = { +function getEmptyGuestData(): GuestInviteInformation { + return { first_name: '', last_name: '', ou_name_en: '', @@ -24,12 +22,22 @@ test('Guest register page showing passport field on manual registration', async mobile_phone: '', fnr: '', passport: '', + countryForCallingCode: '', authentication_method: AuthenticationMethod.Invite, } +} + +test('Guest register page showing passport field on manual registration', async () => { + const nextHandler = (enteredGuestData: EnteredGuestData) => { + console.log(`Entered data: ${enteredGuestData}`) + } render( <LocalizationProvider dateAdapter={AdapterDateFns}> - <GuestRegisterStep nextHandler={nextHandler} guestData={guestData} /> + <GuestRegisterStep + nextHandler={nextHandler} + guestData={getEmptyGuestData()} + /> </LocalizationProvider> ) diff --git a/frontend/src/routes/guest/register/registerPage.tsx b/frontend/src/routes/guest/register/registerPage.tsx index a8f05d5ec1ef9ce4f2c32963f444fc8e3220db07..8ce656e09a49cf9859cf06d8342ca142dd77e119 100644 --- a/frontend/src/routes/guest/register/registerPage.tsx +++ b/frontend/src/routes/guest/register/registerPage.tsx @@ -8,7 +8,13 @@ import { Typography, } from '@mui/material' import { SubmitHandler, useForm } from 'react-hook-form' -import React, { forwardRef, Ref, useImperativeHandle, useState } from 'react' +import React, { + forwardRef, + Ref, + useEffect, + useImperativeHandle, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { CountryCallingCode, @@ -17,7 +23,7 @@ import { getCountryCallingCode, } from 'libphonenumber-js' import { getAlpha2Codes, getName } from 'i18n-iso-countries' -import { ContactInformationBySponsor } from './guestDataForm' +import { GuestInviteInformation } from './guestDataForm' import { EnteredGuestData } from './enteredGuestData' import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods' import { isValidFnr, isValidMobilePhoneNumber } from '../../../utils' @@ -26,7 +32,7 @@ import AuthenticationMethod from './authenticationMethod' interface GuestRegisterProperties { nextHandler(guestData: EnteredGuestData): void - guestData: ContactInformationBySponsor + guestData: GuestInviteInformation } /** @@ -42,19 +48,7 @@ const GuestRegisterStep = forwardRef( const [countryCode, setCountryCode] = useState< CountryCallingCode | undefined >(undefined) - - const handleCountryCodeChange = (event: SelectChangeEvent) => { - if (event.target.value) { - const countryCodeType = getCountries().find( - (value) => value.toString() === event.target.value - ) - if (countryCodeType) { - setCountryCode(getCountryCallingCode(countryCodeType)) - } - } else { - setCountryCode(undefined) - } - } + const [mobilePhone, setMobilePhone] = useState<string>('') const submit: SubmitHandler<EnteredGuestData> = (data) => { nextHandler(data) @@ -64,19 +58,66 @@ const GuestRegisterStep = forwardRef( register, handleSubmit, setValue, - trigger, + setError, + clearErrors, formState: { errors }, } = useForm<EnteredGuestData>() const onSubmit = handleSubmit<EnteredGuestData>(submit) + const handleCountryCodeChange = (event: SelectChangeEvent) => { + if (event.target.value) { + const countryCodeType = event.target.value as CountryCode + setCountryCode(countryCodeType) + setValue('mobilePhoneCountry', countryCodeType) + } else { + setCountryCode(undefined) + } + } + + const handleMobilePhoneChange = (value: any) => { + if (countryCode) { + // The country code and the rest of the mobile number are in two fields, so cannot + // register the field directly in form, but need to have extra logic defined + // to combine the values before writing them to the form handling + + const phoneNumberWithCountryCode = `+${getCountryCallingCode( + countryCode as CountryCode + )}${value.target.value}` + const isValidPhoneNumber = isValidMobilePhoneNumber( + phoneNumberWithCountryCode + ) + + if (isValidPhoneNumber === true) { + setValue('mobilePhone', value.target.value) + clearErrors('mobilePhone') + } else { + setError('mobilePhone', { + type: 'manual', + message: isValidPhoneNumber || undefined, + }) + } + } + + setMobilePhone(value.target.value) + } + + useEffect(() => { + setCountryCode(guestData.countryForCallingCode) + setMobilePhone(guestData.mobile_phone ? guestData.mobile_phone : '') + setValue( + 'mobilePhoneCountry', + guestData.countryForCallingCode ? guestData.countryForCallingCode : '' + ) + }, [guestData]) + function doSubmit() { return onSubmit() } register('mobilePhone', { required: t<string>('validation.mobilePhoneRequired'), - validate: isValidMobilePhoneNumber, }) + register('mobilePhoneCountry') useImperativeHandle(ref, () => ({ doSubmit })) @@ -117,6 +158,16 @@ const GuestRegisterStep = forwardRef( disabled /> + {/* Only show the Feide ID field if the value is present */} + {guestData.feide_id && ( + <TextField + id="feide_id" + label={t('feideId')} + value={guestData.feide_id} + disabled + /> + )} + <Box sx={{ display: 'flex', @@ -132,10 +183,10 @@ const GuestRegisterStep = forwardRef( labelId="phone-number-select" id="phone-number-select" displayEmpty - defaultValue="" onChange={handleCountryCodeChange} + value={countryCode ? countryCode.toString() : ''} renderValue={(selected: any) => { - if (selected.length === 0 || selected === '') { + if (!selected) { return t('input.countryCallingCode') } return `${getName( @@ -143,6 +194,10 @@ const GuestRegisterStep = forwardRef( i18n.language )} (${getCountryCallingCode(selected as CountryCode)})` }} + data-testid="phone-country-code-select" + inputProps={{ + 'data-testid': 'phone-country-code-select-inner', + }} > <MenuItem disabled value=""> {t('input.countryCallingCode')} @@ -159,19 +214,9 @@ const GuestRegisterStep = forwardRef( sx={{ flexGrow: 2 }} label={t('input.mobilePhone')} error={!!errors.mobilePhone} + value={mobilePhone} helperText={errors.mobilePhone && errors.mobilePhone.message} - onChange={(value) => { - if (countryCode) { - // The country code and the rest of the mobile number are in two fields, so cannot - // register the field directly in form, but need to have extra logic defined - // to combine the values before writing them to the form handling - setValue( - 'mobilePhone', - `+${countryCode.toString()}${value.target.value}` - ) - trigger('mobilePhone') - } - }} + onChange={handleMobilePhoneChange} /> </Box> {guestData.authentication_method === diff --git a/frontend/src/routes/guest/register/submitSuccessPage.tsx b/frontend/src/routes/guest/register/submitSuccessPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e139c067b87f0f1cd25dfb97a0ea152918a64aa1 --- /dev/null +++ b/frontend/src/routes/guest/register/submitSuccessPage.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +import { Box, Button } from '@mui/material' + +import { useHistory } from 'react-router-dom' + +const StepSubmitSuccessGuest = () => { + const { t } = useTranslation(['common']) + const history = useHistory() + + return ( + <> + <Box + sx={{ + paddingTop: '1rem', + paddingBottom: '1rem', + typography: 'h3', + }} + > + {t('thankYou')} + </Box> + + <Box sx={{ marginTop: '2rem' }}>{t('guestSubmitSuccessDescription')}</Box> + + <Button + sx={{ + marginTop: '2rem', + color: 'theme.palette.dark', + }} + onClick={() => { + history.push('/') + }} + > + {t('button.backToFrontPage')} + </Button> + </> + ) +} + +export default StepSubmitSuccessGuest diff --git a/frontend/src/routes/invite/index.tsx b/frontend/src/routes/invite/index.tsx index 2edc66658d9b3f67443f542e25f704c525a7fcf1..ba43e2d7c2a31266e2f209d6a838d44099b22f48 100644 --- a/frontend/src/routes/invite/index.tsx +++ b/frontend/src/routes/invite/index.tsx @@ -1,20 +1,29 @@ +import { useTranslation } from 'react-i18next' import Page from 'components/page' -import { useUserContext } from 'contexts' -import { Link } from 'react-router-dom' + +import { styled } from '@mui/material/styles' + +import { HrefButton } from 'components/button' +import { HrefLineButton } from 'components/button/linebutton' + +const FlexDiv = styled('div')(() => ({ + display: 'flex', + gap: '0.5rem', +})) function Invite() { - const { user } = useUserContext() + const { t } = useTranslation(['invite']) return ( <Page> + <h1>{t('header')}</h1> <p> - {user.first_name} {user.last_name} - TODO: Put information about login options, and buttons to them on this - page + {t('description')} </p> - After login or when clicking on the manual registration option, the user - should be sent here: - <Link to="/guestregister/">Guest Registration</Link> + <FlexDiv> + <HrefButton to="/oidc/authenticate/">{t('login')}</HrefButton> + <HrefLineButton to="/guestregister/">{t('manual')}</HrefLineButton> + </FlexDiv> </Page> ) } diff --git a/frontend/src/routes/sponsor/register/index.tsx b/frontend/src/routes/sponsor/register/index.tsx index 035bc953994ac42b0c71da410bbecdd4eb84e2e5..a93b29c9d16d52c50aa965f9de4edbcd4a8769a3 100644 --- a/frontend/src/routes/sponsor/register/index.tsx +++ b/frontend/src/routes/sponsor/register/index.tsx @@ -13,6 +13,13 @@ import { PersonFormMethods } from './personFormMethods' import SubmitState from './submitState' import SponsorGuestButtons from '../../components/sponsorGuestButtons' import { submitJsonOpts } from '../../../utils' +import StepSubmitSuccess from './stepSubmitSuccess' + +enum Steps { + RegisterStep = 0, + SummaryStep = 1, + SuccessStep = 2, +} /** * @@ -34,8 +41,6 @@ export default function StepRegistration() { }) const history = useHistory() - const REGISTER_STEP = 0 - const SUMMARY_STEP = 1 const [activeStep, setActiveStep] = useState(0) const personFormRef = useRef<PersonFormMethods>(null) const [submitState, setSubmitState] = useState(SubmitState.NotSubmitted) @@ -83,6 +88,7 @@ export default function StepRegistration() { if (result !== null) { console.log('result', result) setSubmitState(SubmitState.SubmitSuccess) + setActiveStep(Steps.SuccessStep) } }) .catch((error) => { @@ -111,14 +117,16 @@ export default function StepRegistration() { <SponsorGuestButtons registerNewGuestActive /> {/* Current page in wizard */} <Box sx={{ width: '100%' }}> - {activeStep === REGISTER_STEP && ( + {activeStep === Steps.RegisterStep && ( <StepPersonForm nextHandler={handleForwardFromRegister} formData={formData} ref={personFormRef} /> )} - {activeStep === SUMMARY_STEP && <StepSummary formData={formData} />} + {activeStep === Steps.SummaryStep && ( + <StepSummary formData={formData} /> + )} </Box> <Box @@ -130,7 +138,7 @@ export default function StepRegistration() { paddingBottom: '1rem', }} > - {activeStep === REGISTER_STEP && ( + {activeStep === Steps.RegisterStep && ( <Button data-testid="button-next" sx={{ color: 'theme.palette.secondary', mr: 1 }} @@ -140,7 +148,7 @@ export default function StepRegistration() { </Button> )} - {activeStep === SUMMARY_STEP && ( + {activeStep === Steps.SummaryStep && ( <> <Button onClick={handleBack} @@ -160,21 +168,19 @@ export default function StepRegistration() { </> )} - <Button - onClick={handleCancel} - disabled={submitState === SubmitState.SubmitSuccess} - > - {t('button.cancel')} - </Button> + {activeStep !== Steps.SuccessStep && ( + <Button + onClick={handleCancel} + disabled={submitState === SubmitState.SubmitSuccess} + > + {t('button.cancel')} + </Button> + )} </Box> - {/* TODO For now just showing a heading to give the user some feedback. Should probably go to a different page on success */} - {submitState === SubmitState.SubmitSuccess && ( - <Box> - <h2>Submit success</h2> - </Box> - )} + {activeStep === Steps.SuccessStep && <StepSubmitSuccess />} + {/* TODO For now just showing a heading to give the user some feedback */} {submitState === SubmitState.SubmitFailure && ( <Box> <h2>Submit failure</h2> diff --git a/frontend/src/routes/sponsor/register/stepSubmitSuccess.tsx b/frontend/src/routes/sponsor/register/stepSubmitSuccess.tsx new file mode 100644 index 0000000000000000000000000000000000000000..428390ae01418aa5b577de81c1dd6b9995ff64d3 --- /dev/null +++ b/frontend/src/routes/sponsor/register/stepSubmitSuccess.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +import { Box, Button } from '@mui/material' + +import { useHistory } from 'react-router-dom' + +const StepSubmitSuccess = () => { + const { t } = useTranslation(['common']) + const history = useHistory() + + return ( + <> + <Box + sx={{ + paddingTop: '1rem', + paddingBottom: '1rem', + typography: 'h3', + }} + > + {t('thankYou')} + </Box> + + <Box sx={{ marginTop: '2rem' }}> + {t('sponsorSubmitSuccessDescription')} + </Box> + + <Button + sx={{ + marginTop: '2rem', + color: 'theme.palette.dark', + }} + onClick={() => { + history.push('/') + }} + > + {t('button.backToFrontPage')} + </Button> + </> + ) +} + +export default StepSubmitSuccess diff --git a/frontend/src/setupJest.js b/frontend/src/setupJest.js new file mode 100644 index 0000000000000000000000000000000000000000..1c3dd9b4776ca61a4c51411d1ee07952783c5fc6 --- /dev/null +++ b/frontend/src/setupJest.js @@ -0,0 +1,6 @@ +// adds the 'fetchMock' global variable and rewires 'fetch' global to call 'fetchMock' instead of the real implementation +// eslint-disable-next-line import/no-extraneous-dependencies +require('jest-fetch-mock').enableMocks() +// changes default behavior of fetchMock to use the real 'fetch' implementation and not mock responses +// eslint-disable-next-line no-undef +fetchMock.dontMock() diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 528734dd17370f1f3149f061a59a1a15d5a6e631..fcb7c0da28e37afd12aa65ae24e6ef183cccc783 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,6 +1,6 @@ import validator from '@navikt/fnrvalidator' import i18n from 'i18next' -import { isValidPhoneNumber } from 'libphonenumber-js' +import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js' const validEmailRegex = /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/ @@ -83,3 +83,17 @@ export function isValidEmail(data: string | undefined): boolean | string { } return i18n.t<string>('common:validation.invalidEmail') } + +/** + * Splits a phone number into a country code and the national number. + * + * @param phoneNumber The phone number to split + */ +export function splitPhoneNumber(phoneNumber: string): [string, string] { + const parsedNumber = parsePhoneNumber(phoneNumber) + + return [ + parsedNumber.countryCallingCode.toString(), + parsedNumber.nationalNumber.toString(), + ] +} diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py index 2432ba0dede5c942bf325f90ee38f35656299ad8..98a0bd8e1839fa49675a4d3598b37b3d8b680a71 100644 --- a/gregui/api/views/invitation.py +++ b/gregui/api/views/invitation.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Optional from django.core import exceptions from django.db import transaction @@ -12,7 +13,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView -from greg.models import Identity, InvitationLink +from greg.models import Identity, InvitationLink, Person from greg.permissions import IsSponsor from gregui.api.serializers.guest import GuestRegisterSerializer from gregui.api.serializers.invitation import InviteGuestSerializer @@ -141,19 +142,13 @@ class InvitedGuestView(GenericAPIView): else SessionType.FEIDE.value ) - try: - fnr = person.identities.get( - type=Identity.IdentityType.NORWEGIAN_NATIONAL_ID_NUMBER - ).value - except Identity.DoesNotExist: - fnr = None - - try: - passport = person.identities.get( - type=Identity.IdentityType.PASSPORT_NUMBER - ).value - except Identity.DoesNotExist: - passport = None + fnr = self._get_identity_or_none( + person, Identity.IdentityType.NORWEGIAN_NATIONAL_ID_NUMBER + ) + passport = self._get_identity_or_none( + person, Identity.IdentityType.PASSPORT_NUMBER + ) + feide_id = self._get_identity_or_none(person, Identity.IdentityType.FEIDE_ID) data = { "person": { @@ -163,6 +158,7 @@ class InvitedGuestView(GenericAPIView): "mobile_phone": person.private_mobile and person.private_mobile.value, "fnr": fnr, "passport": passport, + "feide_id": feide_id, }, "sponsor": { "first_name": sponsor.first_name, @@ -247,3 +243,12 @@ class InvitedGuestView(GenericAPIView): ) # Check that there are no other fields filled in return number_of_fields_filled_in == len(person_data.keys()) + + @staticmethod + def _get_identity_or_none( + person: Person, identity_type: Identity.IdentityType + ) -> Optional[str]: + try: + return person.identities.get(type=identity_type).value + except Identity.DoesNotExist: + return None diff --git a/gregui/authentication/auth_backends.py b/gregui/authentication/auth_backends.py index 74b897c1de1035368d5977430e2aa4224348273d..6d8514e4f3dbbf9335ad7639de8842b3bd213f8c 100644 --- a/gregui/authentication/auth_backends.py +++ b/gregui/authentication/auth_backends.py @@ -244,6 +244,7 @@ class GregOIDCBackend(ValidatingOIDCBackend): person=person, source=settings.FEIDE_SOURCE, verified=Identity.Verified.AUTOMATIC, + # TODO Should this be set at this stage or when the sponsor approves the identity? verified_at=timezone.now(), ) identity.save() diff --git a/gregui/tests/api/test_invitation.py b/gregui/tests/api/test_invitation.py index e3f8d7a972ea4304a0558ca87644bd279c377547..c93984183f5b58e67931a880a810fe5ac6234fe8 100644 --- a/gregui/tests/api/test_invitation.py +++ b/gregui/tests/api/test_invitation.py @@ -60,6 +60,7 @@ def test_get_invited_info_session_okay( mobile_phone=None, fnr=None, passport=None, + feide_id=None, ) assert data.get("sponsor") == dict( first_name=sponsor_guy_data["first_name"],