Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • andretol/greg
1 result
Show changes
Commits on Source (24)
Showing
with 804 additions and 48 deletions
......@@ -29,6 +29,7 @@
"date-fns": "^2.24.0",
"fetch-intercept": "^2.4.0",
"http-proxy-middleware": "^2.0.1",
"i18n-iso-countries": "^6.8.0",
"i18next": "^20.6.0",
"i18next-browser-languagedetector": "^6.1.2",
"i18next-http-backend": "^1.3.1",
......@@ -8460,6 +8461,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"node_modules/diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E="
},
"node_modules/diff-sequences": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz",
......@@ -11485,6 +11491,17 @@
"node": ">=8.12.0"
}
},
"node_modules/i18n-iso-countries": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-6.8.0.tgz",
"integrity": "sha512-jJs/+CA6+VUICFxqGcB0vFMERGfhfvyNk+8Vb9EagSZkl7kSpm/kT0VyhvzM/zixDWEV/+oN9L7v/GT9BwzoGg==",
"dependencies": {
"diacritics": "1.3.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/i18next": {
"version": "20.6.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
......@@ -16002,9 +16019,9 @@
}
},
"node_modules/libphonenumber-js": {
"version": "1.9.37",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.37.tgz",
"integrity": "sha512-RnUR4XwiVhMLnT7uFSdnmLeprspquuDtaShAgKTA+g/ms9/S4hQU3/QpFdh3iXPHtxD52QscXLm2W2+QBmvYAg=="
"version": "1.9.38",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.38.tgz",
"integrity": "sha512-7CCl9NZPYtX4JNXdvV5RnrQqrXp7LsLOTpYSUfEJBiySEnC5hysdwouXAuWgPDB55D/fpKm4RjM2/tUUh8kuoA=="
},
"node_modules/lines-and-columns": {
"version": "1.1.6",
......@@ -31314,6 +31331,11 @@
}
}
},
"diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha1-PvqHMj67hj5mls67AILUj/PW96E="
},
"diff-sequences": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz",
......@@ -33610,6 +33632,14 @@
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
"integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw=="
},
"i18n-iso-countries": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-6.8.0.tgz",
"integrity": "sha512-jJs/+CA6+VUICFxqGcB0vFMERGfhfvyNk+8Vb9EagSZkl7kSpm/kT0VyhvzM/zixDWEV/+oN9L7v/GT9BwzoGg==",
"requires": {
"diacritics": "1.3.0"
}
},
"i18next": {
"version": "20.6.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
......@@ -36935,9 +36965,9 @@
}
},
"libphonenumber-js": {
"version": "1.9.37",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.37.tgz",
"integrity": "sha512-RnUR4XwiVhMLnT7uFSdnmLeprspquuDtaShAgKTA+g/ms9/S4hQU3/QpFdh3iXPHtxD52QscXLm2W2+QBmvYAg=="
"version": "1.9.38",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.38.tgz",
"integrity": "sha512-7CCl9NZPYtX4JNXdvV5RnrQqrXp7LsLOTpYSUfEJBiySEnC5hysdwouXAuWgPDB55D/fpKm4RjM2/tUUh8kuoA=="
},
"lines-and-columns": {
"version": "1.1.6",
......@@ -17,13 +17,14 @@
"@types/jest": "^26.0.24",
"@types/node": "^12.20.24",
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9",
"@types/react-datepicker": "^4.1.7",
"@types/react-dom": "^17.0.9",
"@types/react-helmet": "^6.1.2",
"@types/react-router-dom": "^5.1.8",
"date-fns": "^2.24.0",
"fetch-intercept": "^2.4.0",
"http-proxy-middleware": "^2.0.1",
"i18n-iso-countries": "^6.8.0",
"i18next": "^20.6.0",
"i18next-browser-languagedetector": "^6.1.2",
"i18next-http-backend": "^1.3.1",
......
......@@ -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",
......@@ -79,5 +81,13 @@
"guestDepartment": "Department"
},
"yourGuests": "Your guests",
"registerNewGuest": "Register new guest"
"registerNewGuest": "Register new guest",
"guestOverview": "Guest overview",
"guestRegisterWizardText": {
"yourContactInformation": "Your contact information",
"contactInformationDescription": "Fill in your mobile phone number.",
"yourGuestPeriod": "Your guest period",
"guestPeriodDescription": "Period registered for your guest role."
},
"yourGuestAccount": "Your guest account"
}
......@@ -19,7 +19,8 @@
"comment": "Kommentar",
"email": "E-post",
"fullName": "Fullt navn",
"mobilePhone": "Mobilnummer"
"mobilePhone": "Mobilnummer",
"passportNumber": "Passport number"
},
"sponsor": {
"contactInfo": "Kontaktinformasjon",
......@@ -56,7 +57,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",
......@@ -78,5 +80,13 @@
"guestDepartment": "Avdeling"
},
"yourGuests": "Dine gjester",
"registerNewGuest": "Registrer ny gjest"
"registerNewGuest": "Registrer ny gjest",
"guestOverview": "Oversikt over gjest",
"guestRegisterWizardText": {
"yourContactInformation": "Din kontaktinfo",
"contactInformationDescription": "Fyll inn ditt mobilnummer.",
"yourGuestPeriod": "Din gjesteperiode",
"guestPeriodDescription": "Registrert periode for din gjesterolle."
},
"yourGuestAccount": "Din gjestekonto"
}
......@@ -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",
......@@ -79,5 +81,13 @@
"guestDepartment": "Avdeling"
},
"yourGuests": "Dine gjestar",
"registerNewGuest": "Registrer ny gjest"
"registerNewGuest": "Registrer ny gjest",
"guestOverview": "Oversikt over gjest",
"guestRegisterWizardText": {
"yourContactInformation": "Din kontaktinfo",
"contactInformationDescription": "Fyll inn ditt mobilnummer.",
"yourGuestPeriod": "Din gjesteperiode",
"guestPeriodDescription": "Registrert periode for di gjesterolle."
},
"yourGuestAccount": "Din gjestekonto"
}
import PersonIcon from '@mui/icons-material/Person'
import { Box, IconButton } from '@mui/material'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useHistory } from 'react-router-dom'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
export default function OverviewGuestButton() {
const { t } = useTranslation(['common'])
const history = useHistory()
const goToGuestOverview = () => {
history.push('/guestregister')
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'space-evenly',
}}
>
{/* TODO Where should the back arrow point to? */}
<ArrowBackIcon />
<Box
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
>
<IconButton onClick={goToGuestOverview}>
<PersonIcon
fontSize="large"
sx={{
borderRadius: '2rem',
borderStyle: 'solid',
borderColor: 'primary.main',
fill: 'white',
backgroundColor: 'primary.main',
}}
/>
</IconButton>
<Box
sx={{
typography: 'caption',
}}
>
{t('yourGuestAccount')}
</Box>
</Box>
<IconButton disabled>
<ArrowForwardIcon visibility="hidden" />
</IconButton>
</Box>
)
}
......@@ -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 />
......
/**
* Controls what is shown in the registration form
*/
enum AuthenticationMethod {
Feide,
Invite,
}
export default AuthenticationMethod
/**
* 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
}
/**
* 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
ou_name_nb: string
ou_name_en: string
role_name_en: string
role_name_nb: string
role_start: string
role_end: string
comment?: 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
authentication_method: AuthenticationMethod
}
/**
* Contains methods that GuestRegister can call on in GuestRegisterStep
*/
export interface GuestRegisterCallableMethods {
doSubmit: () => void
}
/**
* Contains methods that GuestRegisterStep can call on in GuestRegister
*/
export interface GuestRegisterMethods {
handleNext: () => void
}
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Box, Button } from '@mui/material'
import Page from 'components/page'
import { useHistory } from 'react-router-dom'
import OverviewGuestButton from '../../components/overviewGuestButton'
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.
*/
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
// 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: '',
authentication_method: AuthenticationMethod.Invite,
})
const guestContactInfo = async () => {
try {
const response = await fetch('/api/ui/v1/invited')
if (response.ok) {
response.json().then((jsonResponse) => {
console.log(`Guest data from server: ${JSON.stringify(jsonResponse)}`)
const authenticationMethod =
jsonResponse.meta.session_type === 'invite'
? AuthenticationMethod.Invite
: AuthenticationMethod.Feide
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,
authentication_method: authenticationMethod,
})
})
}
} catch (error) {
console.log(error)
}
}
useEffect(() => {
guestContactInfo()
}, [])
const handleNext = () => {
// TODO Go to consent page
// setActiveStep((prevActiveStep) => prevActiveStep + 1)
}
const handleSave = () => {
if (activeStep === 0) {
if (guestRegisterRef.current) {
guestRegisterRef.current.doSubmit()
}
}
}
const handleForwardFromRegister = (
updateFormData: EnteredGuestData
): void => {
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 />
{/* Current page in wizard */}
<Box sx={{ width: '100%' }}>
{activeStep === REGISTER_STEP && (
<GuestRegisterStep
nextHandler={handleForwardFromRegister}
guestData={guestFormData}
ref={guestRegisterRef}
/>
)}
</Box>
<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}
>
{t('button.next')}
</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>
)
}
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) => {
console.log(`Entered data: ${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: '',
authentication_method: AuthenticationMethod.Invite,
}
render(
<LocalizationProvider dateAdapter={AdapterDateFns}>
<GuestRegisterStep nextHandler={nextHandler} guestData={guestData} />
</LocalizationProvider>
)
await waitFor(() => {
expect(screen.queryByTestId('passport_number_input')).toBeInTheDocument()
})
})
import {
Box,
MenuItem,
Select,
SelectChangeEvent,
Stack,
TextField,
Typography,
} from '@mui/material'
import { SubmitHandler, useForm } from 'react-hook-form'
import React, { forwardRef, Ref, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
CountryCallingCode,
getCountries,
getCountryCallingCode,
} from 'libphonenumber-js'
import { getName } from 'i18n-iso-countries'
import { ContactInformationBySponsor } from './guestDataForm'
import { EnteredGuestData } from './enteredGuestData'
import { GuestRegisterCallableMethods } from './guestRegisterCallableMethods'
import { isValidFnr, isValidMobilePhoneNumber } from '../../../utils'
import AuthenticationMethod from './authenticationMethod'
interface GuestRegisterProperties {
nextHandler(guestData: EnteredGuestData): void
guestData: ContactInformationBySponsor
}
/**
* 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 [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 submit: SubmitHandler<EnteredGuestData> = (data) => {
nextHandler(data)
}
const {
register,
handleSubmit,
setValue,
trigger,
formState: { errors },
} = useForm()
const onSubmit = handleSubmit<EnteredGuestData>(submit)
function doSubmit() {
return onSubmit()
}
register('mobilePhone', {
required: t<string>('validation.roleTypeRequired'),
validate: isValidMobilePhoneNumber,
})
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
/>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
paddingBottom: '2rem',
}}
>
<Select
sx={{
maxHeight: '2.5rem',
minWidth: '5rem',
marginRight: '0.5rem',
}}
labelId="phone-number-select"
id="phone-number-select"
onChange={handleCountryCodeChange}
>
{getCountries().map((country) => (
<MenuItem key={country} value={country}>
{getName(country, i18n.language)} +
{getCountryCallingCode(country)}
</MenuItem>
))}
</Select>
<TextField
sx={{ flexGrow: 2 }}
label={t('input.mobilePhone')}
error={!!errors.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')
}
}}
/>
</Box>
{guestData.authentication_method ===
AuthenticationMethod.Invite && (
<>
<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')}
error={!!errors.national_id_number}
helperText={
errors.nationalIdNumber && errors.nationalIdNumber.message
}
{...register('nationalIdNumber', {
validate: isValidFnr,
})}
/>
</>
)}
{guestData.authentication_method ===
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>
</>
)
}
)
export default GuestRegisterStep
......@@ -16,6 +16,11 @@ import Footer from 'routes/components/footer'
import Header from 'routes/components/header'
import NotFound from 'routes/components/notFound'
import ProtectedRoute from 'components/protectedRoute'
import { registerLocale } from 'i18n-iso-countries'
import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json'
import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json'
import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json'
import GuestRegister from './guest/register'
const AppWrapper = styled('div')({
display: 'flex',
......@@ -28,6 +33,11 @@ const AppWrapper = styled('div')({
export default function App() {
const { user, clearUserInfo } = useUserContext()
// Load country names for the supported languages
registerLocale(i18n_iso_countries_en)
registerLocale(i18n_iso_countries_nb)
registerLocale(i18n_iso_countries_nn)
// Intercept fetch responses
fetchIntercept.register({
response(response) {
......@@ -56,6 +66,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>
......
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>
)
}
......
......@@ -268,3 +268,5 @@ SCHEDULE_TASKS = {
INSTANCE_NAME = "local"
INTERNAL_RK_PREFIX = "no.{instance}.greg".format(instance=INSTANCE_NAME)
FEIDE_SOURCE = "feide"
import phonenumbers
from rest_framework import serializers
from greg.models import Identity, Person
from greg.utils import is_valid_norwegian_national_id_number
def _validateNorwegianNationalIdNumber(value):
# Not excepted that D-numbers will be entered through the form, so only
# accept national ID numbers
if not is_valid_norwegian_national_id_number(value, False):
raise serializers.ValidationError("Not a valid Norwegian national ID number")
def _validatePhoneNumber(value):
if not phonenumbers.is_valid_number(phonenumbers.parse(value)):
raise serializers.ValidationError("Invalid phone number")
class GuestRegisterSerializer(serializers.ModelSerializer):
first_name = serializers.CharField(required=True)
last_name = serializers.CharField(required=True)
email = serializers.CharField(required=True)
mobile_phone = serializers.CharField(required=True)
# TODO first_name and last_name set as not required to throwing an exception if they are not included in what is sent back from the frontend. It is perhaps not required that they are in the reponse from the client if the guest should be allowed to change them
first_name = serializers.CharField(required=False)
last_name = serializers.CharField(required=False)
# E-mail set to not required to avoid raising exception if it is not included in input. It is not given that
# the guest should be allowed to update it
email = serializers.CharField(required=False)
mobile_phone = serializers.CharField(
required=True, validators=[_validatePhoneNumber]
)
fnr = serializers.CharField(
required=False, validators=[_validateNorwegianNationalIdNumber]
)
def update(self, instance, validated_data):
email = validated_data.pop("email")
mobile_phone = validated_data.pop("mobile_phone")
if not instance.private_email:
Identity.objects.create(
person=instance,
type=Identity.IdentityType.PRIVATE_EMAIL,
value=email,
)
else:
instance.private_email.value = email
instance.private_email.save()
if "email" in validated_data:
email = validated_data.pop("email")
if not instance.private_email:
Identity.objects.create(
person=instance,
type=Identity.IdentityType.PRIVATE_EMAIL,
value=email,
)
else:
private_email = instance.private_email
private_email.value = email
private_email.save()
if not instance.private_mobile:
Identity.objects.create(
......@@ -30,17 +54,32 @@ class GuestRegisterSerializer(serializers.ModelSerializer):
value=mobile_phone,
)
else:
instance.private_mobile.value = mobile_phone
instance.private_mobile.save()
private_mobile = instance.private_mobile
private_mobile.value = mobile_phone
private_mobile.save()
if "fnr" in validated_data:
fnr = validated_data.pop("fnr")
if not instance.fnr:
Identity.objects.create(
person=instance,
type=Identity.IdentityType.NORWEGIAN_NATIONAL_ID_NUMBER,
value=fnr,
)
else:
fnr_existing = instance.fnr
fnr_existing.value = fnr
fnr_existing.save()
# TODO: we only want to allow changing the name if we don't have one
# from a reliable source (Feide/KORR)
instance.first_name = validated_data["first_name"]
instance.last_name = validated_data["last_name"]
# TODO Comment back in after it is decided if name updates are allowed
# instance.first_name = validated_data["first_name"]
# instance.last_name = validated_data["last_name"]
return instance
class Meta:
model = Person
fields = ("id", "first_name", "last_name", "email", "mobile_phone")
fields = ("id", "first_name", "last_name", "email", "mobile_phone", "fnr")
read_only_fields = ("id",)
import json
import datetime
from uuid import uuid4
from enum import Enum
from django.core import exceptions
from django.db import transaction
from django.http.response import JsonResponse
from django.utils import timezone
from rest_framework import serializers, status
from rest_framework import status
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
from rest_framework.generics import CreateAPIView, GenericAPIView
from rest_framework.parsers import JSONParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from greg.models import Identity, Invitation, InvitationLink, Person, Role, Sponsor
from greg.models import Identity, InvitationLink
from greg.permissions import IsSponsor
from gregui.api.serializers.guest import GuestRegisterSerializer
from gregui.api.serializers.invitation import InviteGuestSerializer
from gregui.models import GregUserProfile
......@@ -101,12 +97,21 @@ class CheckInvitationView(APIView):
return Response(status=status.HTTP_200_OK)
class SessionType(Enum):
INVITE = "invite"
FEIDE = "feide"
class InvitedGuestView(GenericAPIView):
authentication_classes = [SessionAuthentication, BasicAuthentication]
# The endpoint is only for invited guests, but the authorization happens in the actual method
permission_classes = [AllowAny]
parser_classes = [JSONParser]
serializer_class = GuestRegisterSerializer
# TODO Update to make dynamic based on where we get the information from. If we get some from Feide, then the user should not be allowed to change it
fields_allowed_to_update = ["email", "fnr", "mobile_phone", "passport"]
def get(self, request, *args, **kwargs):
"""
Endpoint for fetching data related to an invite
......@@ -129,12 +134,24 @@ class InvitedGuestView(GenericAPIView):
person = role.person
sponsor = role.sponsor_id
# If the user is not logged in then tell the client to take him through the manual registration process
session_type = (
SessionType.INVITE.value
if request.user.is_anonymous
else SessionType.FEIDE.value
)
try:
fnr = person.identities.get(type="norwegian_national_id_number").value
fnr = person.identities.get(
type=Identity.IdentityType.NORWEGIAN_NATIONAL_ID_NUMBER
).value
except Identity.DoesNotExist:
fnr = None
try:
passport = person.identities.get(type="passport_number").value
passport = person.identities.get(
type=Identity.IdentityType.PASSPORT_NUMBER
).value
except Identity.DoesNotExist:
passport = None
......@@ -160,6 +177,7 @@ class InvitedGuestView(GenericAPIView):
"end": role.end_date,
"comments": role.comments,
},
"meta": {"session_type": session_type},
}
return JsonResponse(data=data, status=status.HTTP_200_OK)
......@@ -172,7 +190,6 @@ class InvitedGuestView(GenericAPIView):
the guest.
"""
invite_id = request.session.get("invite_id")
data = request.data
# Ensure the invitation link is valid and not expired
try:
......@@ -184,8 +201,21 @@ class InvitedGuestView(GenericAPIView):
person = invite_link.invitation.role.person
data = request.data
if self._verified_fnr_already_exists(person) and "fnr" in data:
# The user should not be allowed to change a verified fnr
return Response(status=status.HTTP_400_BAD_REQUEST)
if not self._only_allowed_fields_in_request(data):
return Response(status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
serializer = self.get_serializer(instance=person, data=request.data)
# Note this only serializes data for the person, it does not look at other sections
# in the response that happen to be there
serializer = self.get_serializer(
instance=person, data=request.data["person"]
)
serializer.is_valid(raise_exception=True)
person = serializer.save()
......@@ -198,3 +228,22 @@ class InvitedGuestView(GenericAPIView):
invite_link.save()
# TODO: Send an email to the sponsor?
return Response(status=status.HTTP_200_OK)
def _verified_fnr_already_exists(self, person) -> bool:
try:
person.identities.get(
type=Identity.IdentityType.NORWEGIAN_NATIONAL_ID_NUMBER,
verified=Identity.Verified.AUTOMATIC,
)
return True
except Identity.DoesNotExist:
return False
def _only_allowed_fields_in_request(self, request_data) -> bool:
# Check how many of the allowed fields are filled in
person_data = request_data["person"]
number_of_fields_filled_in = sum(
map(lambda x: x in person_data.keys(), self.fields_allowed_to_update)
)
# Check that there are no other fields filled in
return number_of_fields_filled_in == len(person_data.keys())