From a3a7410d6f07628f4ce8a6b216f2821f795b55c2 Mon Sep 17 00:00:00 2001 From: Andreas Ellewsen <ae@uio.no> Date: Mon, 22 Nov 2021 15:02:42 +0100 Subject: [PATCH] Add identity verification to frontend The profile page of a guest now shows a verification button if the guest has a passport or national identificaiton number that has not been verified. Clicking the button shows a dialog, with a confirmation button which triggers a PATCH request to the backend and reloads the page when it returns. Resolves: GREG-101 --- .../src/components/confirmDialog/index.tsx | 50 +++++++++++++++++ .../src/components/identityLine/index.tsx | 56 +++++++++++++++++++ frontend/src/hooks/useGuest/index.tsx | 8 ++- frontend/src/hooks/useGuests/index.tsx | 7 ++- frontend/src/interfaces/index.ts | 20 ++++++- .../routes/sponsor/guest/guestInfo/index.tsx | 18 ++++-- frontend/src/utils/index.ts | 16 +++++- gregui/api/views/person.py | 15 ++++- gregui/views.py | 15 ++++- 9 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/confirmDialog/index.tsx create mode 100644 frontend/src/components/identityLine/index.tsx diff --git a/frontend/src/components/confirmDialog/index.tsx b/frontend/src/components/confirmDialog/index.tsx new file mode 100644 index 00000000..9bbff7da --- /dev/null +++ b/frontend/src/components/confirmDialog/index.tsx @@ -0,0 +1,50 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material' +import React from 'react' + +type ConfirmDialogProps = { + title: string + open: boolean + setOpen: (value: boolean) => void + onConfirm: () => void + children: React.ReactNode +} + +const ConfirmDialog = (props: ConfirmDialogProps) => { + const { title, children, open, setOpen, onConfirm } = props + return ( + <Dialog + open={open} + onClose={() => setOpen(false)} + aria-labelledby="confirm-dialog" + > + <DialogTitle id="confirm-dialog">{title}</DialogTitle> + <DialogContent>{children}</DialogContent> + <DialogActions> + <Button + variant="contained" + onClick={() => setOpen(false)} + color="secondary" + > + No + </Button> + <Button + variant="contained" + onClick={() => { + setOpen(false) + onConfirm() + }} + color="error" + > + Yes + </Button> + </DialogActions> + </Dialog> + ) +} +export default ConfirmDialog diff --git a/frontend/src/components/identityLine/index.tsx b/frontend/src/components/identityLine/index.tsx new file mode 100644 index 00000000..c36ce0cc --- /dev/null +++ b/frontend/src/components/identityLine/index.tsx @@ -0,0 +1,56 @@ +import { Button, TableCell, TableRow } from '@mui/material' +import ConfirmDialog from 'components/confirmDialog' +import { Identity } from 'interfaces' +import { useState } from 'react' +import { submitJsonOpts } from 'utils' +import CheckIcon from '@mui/icons-material/Check' +import { useHistory } from 'react-router-dom' + +interface IdentityLineProps { + text: string + identity: Identity | null +} +const IdentityLine = ({ text, identity }: IdentityLineProps) => { + // Make a line with a confirmation button if the identity has not been verified + const [confirmOpen, setConfirmOpen] = useState(false) + const history = useHistory() + const verifyIdentity = (id: string) => async () => { + await fetch(`/api/ui/v1/identity/${id}`, submitJsonOpts('PATCH', {})) + history.go(0) + } + + if (identity == null) { + return <></> + } + return ( + <TableRow> + <TableCell align="left">{text}</TableCell> + <TableCell align="left">{identity ? identity.value : ''}</TableCell> + <TableCell> + {!identity.verified_at ? ( + <div> + <Button + aria-label="verify" + onClick={() => setConfirmOpen(true)} + disabled={!identity} + > + Verify + </Button> + <ConfirmDialog + title="TODO transaltion Confirm?" + open={confirmOpen} + setOpen={setConfirmOpen} + onConfirm={verifyIdentity(identity.id)} + > + TODO: translation Are you sure you want to verify this identity? + </ConfirmDialog> + </div> + ) : ( + <CheckIcon sx={{ fill: 'green' }} /> + )} + </TableCell> + </TableRow> + ) +} + +export default IdentityLine diff --git a/frontend/src/hooks/useGuest/index.tsx b/frontend/src/hooks/useGuest/index.tsx index b719ca0a..62cf6e5b 100644 --- a/frontend/src/hooks/useGuest/index.tsx +++ b/frontend/src/hooks/useGuest/index.tsx @@ -1,6 +1,6 @@ import { FetchedRole, Guest } from 'interfaces' import { useEffect, useState } from 'react' -import { parseRole } from 'utils' +import { parseRole, parseIdentity } from 'utils' const useGuest = (pid: string) => { const [guestInfo, setGuest] = useState<Guest>({ @@ -8,7 +8,8 @@ const useGuest = (pid: string) => { first: '', last: '', email: '', - fnr: '', + fnr: null, + passport: null, mobile: '', active: false, registered: false, @@ -28,7 +29,8 @@ const useGuest = (pid: string) => { last: rjson.last, email: rjson.email, mobile: rjson.mobile, - fnr: rjson.fnr, + fnr: parseIdentity(rjson.fnr), + passport: parseIdentity(rjson.passport), active: rjson.active, registered: rjson.registered, verified: rjson.verified, diff --git a/frontend/src/hooks/useGuests/index.tsx b/frontend/src/hooks/useGuests/index.tsx index d18b2465..58a7b1c3 100644 --- a/frontend/src/hooks/useGuests/index.tsx +++ b/frontend/src/hooks/useGuests/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { FetchedGuest, Guest } from '../../interfaces' -import { parseRole } from '../../utils' +import { FetchedGuest, Guest } from 'interfaces' +import { parseIdentity, parseRole } from 'utils' const useGuests = () => { const [guests, setGuests] = useState<Guest[]>([]) @@ -19,7 +19,8 @@ const useGuests = () => { last: person.last, email: person.email, mobile: person.mobile, - fnr: person.fnr, + fnr: parseIdentity(person.fnr), + passport: parseIdentity(person.passport), active: person.active, roles: person.roles.map((role) => parseRole(role)), registered: person.registered, diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 242b6785..9cd159ff 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -4,20 +4,36 @@ export type Guest = { last: string email: string mobile: string - fnr: string + fnr: Identity | null + passport: Identity | null active: boolean registered: boolean verified: boolean roles: Role[] } +export type Identity = { + id: string + type: string + verified_at: Date | null + value: string +} + +export type FetchedIdentity = { + id: string + type: string + verified_at: string | null + value: string +} + export interface FetchedGuest { pid: string first: string last: string email: string mobile: string - fnr: string + fnr: FetchedIdentity | null + passport: FetchedIdentity | null active: boolean registered: boolean verified: boolean diff --git a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx index d01f6f02..73b83d10 100644 --- a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx +++ b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx @@ -23,7 +23,8 @@ import { Guest } from 'interfaces' import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { isValidEmail, submitJsonOpts } from '../../../../utils' +import IdentityLine from 'components/identityLine' +import { isValidEmail, submitJsonOpts } from 'utils' type GuestInfoParams = { pid: string @@ -164,6 +165,7 @@ export default function GuestInfo({ <TableRow> <TableCell align="left">{t('guestInfo.contactInfo')}</TableCell> <TableCell /> + <TableCell /> </TableRow> </TableHead> <TableBody> @@ -172,6 +174,7 @@ export default function GuestInfo({ <TableCell align="left"> {`${guest.first} ${guest.last}`} </TableCell> + <TableCell /> </TableRow> <TableRow> <TableCell align="left">{t('input.email')}</TableCell> @@ -224,11 +227,16 @@ export default function GuestInfo({ onClose={handleDialogClose} /> </TableCell> + <TableCell /> </TableRow> - <TableRow> - <TableCell align="left">{t('input.nationalIdNumber')}</TableCell> - <TableCell align="left">{guest.fnr}</TableCell> - </TableRow> + <IdentityLine + text={t('input.nationalIdNumber')} + identity={guest.fnr} + /> + <IdentityLine + text={t('input.passportNumber')} + identity={guest.passport} + /> <TableRow> <TableCell align="left">{t('input.mobilePhone')}</TableCell> <TableCell align="left">{guest.mobile}</TableCell> diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 08dfed2f..88b23193 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,7 +1,7 @@ import validator from '@navikt/fnrvalidator' import { parseISO } from 'date-fns' import i18n from 'i18next' -import { FetchedRole, Role } from 'interfaces' +import { FetchedIdentity, FetchedRole, Identity, Role } from 'interfaces' import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js' const validEmailRegex = @@ -122,3 +122,17 @@ export function parseRole(role: FetchedRole): Role { max_days: role.max_days, } } + +export function parseIdentity( + identity: FetchedIdentity | null +): Identity | null { + if (identity == null) { + return null + } + return { + id: identity.id, + type: identity.type, + value: identity.value, + verified_at: identity.verified_at ? parseISO(identity.verified_at) : null, + } +} diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py index 50ed2921..efcd19b9 100644 --- a/gregui/api/views/person.py +++ b/gregui/api/views/person.py @@ -33,7 +33,20 @@ class PersonView(APIView): "last": person.last_name, "email": person.private_email and person.private_email.value, "mobile": person.private_mobile and person.private_mobile.value, - "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), + "fnr": person.fnr + and { + "id": person.fnr.id, + "value": person.fnr.value, + "type": person.fnr.type, + "verified_at": person.fnr.verified_at, + }, + "passport": person.passport + and { + "id": person.passport.id, + "value": person.passport.value, + "type": person.passport.type, + "verified_at": person.passport.verified_at, + }, "active": person.is_registered and person.is_verified, "registered": person.is_registered, "verified": person.is_verified, diff --git a/gregui/views.py b/gregui/views.py index 4721cccb..11ce1973 100644 --- a/gregui/views.py +++ b/gregui/views.py @@ -104,7 +104,20 @@ class GuestInfoView(APIView): "last": person.last_name, "email": person.private_email and person.private_email.value, "mobile": person.private_mobile and person.private_mobile.value, - "fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")), + "fnr": person.fnr + and { + "id": person.fnr.id, + "value": "".join((person.fnr.value[:-5], "*****")), + "type": person.fnr.type, + "verified_at": person.fnr.verified_at, + }, + "passport": person.passport + and { + "id": person.passport.id, + "value": person.passport.value, + "type": person.passport.type, + "verified_at": person.passport.verified_at, + }, "active": person.is_registered and person.is_verified, "registered": person.is_registered, "verified": person.is_verified, -- GitLab