From 0da941b902de112cfb8440a20764b56556dd0349 Mon Sep 17 00:00:00 2001 From: Tore Brede <Tore.Brede@uib.no> Date: Mon, 8 Nov 2021 14:09:54 +0100 Subject: [PATCH] GREG-94: Setting up save of e-mail --- frontend/public/locales/en/common.json | 3 +- frontend/public/locales/nb/common.json | 3 +- frontend/public/locales/nn/common.json | 3 +- .../routes/sponsor/guest/guestInfo/index.tsx | 182 +++++++++++++++--- frontend/src/routes/sponsor/guest/index.tsx | 48 ++++- gregui/api/views/person.py | 14 ++ gregui/mailutils.py | 3 +- 7 files changed, 215 insertions(+), 41 deletions(-) diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index f2db99b3..4d6d0bf2 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -126,6 +126,7 @@ "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.", "confirmationDialog": { - "cancelInvitation": "Cancel invitation?" + "cancelInvitation": "Cancel invitation?", + "cancelInvitationDescription": "Do you want to cancel the invitation?" } } diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index df018736..00542731 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -126,6 +126,7 @@ "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.", "confirmationDialog": { - "cancelInvitation": "Kanseller invitasjon?" + "cancelInvitation": "Kanseller invitasjon?", + "cancelInvitationDescription": "Vil du kansellere invitasjonen?" } } diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index 7b6f41b6..cc1da0d4 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -127,6 +127,7 @@ "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.", "confirmationDialog": { - "cancelInvitation": "Kanseller invitasjon?" + "cancelInvitation": "Kanseller invitasjon?", + "cancelInvitationDescription": "Vil du kansellere invitasjonen?" } } diff --git a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx index 1de3f3da..737741f8 100644 --- a/frontend/src/routes/sponsor/guest/guestInfo/index.tsx +++ b/frontend/src/routes/sponsor/guest/guestInfo/index.tsx @@ -11,12 +11,19 @@ import { TableHead, TableRow, Paper, + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, } from '@mui/material' import { Guest, Role } from 'interfaces' import SponsorInfoButtons from 'routes/components/sponsorInfoButtons' import { format } from 'date-fns' -import React from 'react' -import { submitJsonOpts } from '../../../../utils' +import React, { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { isValidEmail, submitJsonOpts } from '../../../../utils' type GuestInfoParams = { pid: string @@ -30,6 +37,8 @@ interface RoleLineProps { type GuestInfoProps = { guest: Guest roles: Role[] + updateEmail: (validEmail: string) => void + resend: () => void } const RoleLine = ({ role, pid }: RoleLineProps) => { @@ -59,31 +68,84 @@ const RoleLine = ({ role, pid }: RoleLineProps) => { ) } -export default function GuestInfo({ guest, roles }: GuestInfoProps) { +type CancelConfirmationDialogProps = { + open: boolean + onClose: (ok: boolean) => void +} + +function CancelConfirmationDialog({ + open, + onClose, +}: CancelConfirmationDialogProps) { + const [t] = useTranslation(['common']) + + const handleCancel = () => { + onClose(false) + } + + const handleOk = () => { + onClose(true) + } + + return ( + <Dialog open={open}> + <DialogTitle>{t('confirmationDialog.cancelInvitation')}</DialogTitle> + + <DialogContent> + {t('confirmationDialog.cancelInvitationDescription')} + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleCancel}> + {t('button.cancel')} + </Button> + <Button autoFocus onClick={handleOk}> + {t('button.ok')} + </Button> + </DialogActions> + </Dialog> + ) +} + +type Email = { + email: string +} + +export default function GuestInfo({ + guest, + roles, + updateEmail, + resend, +}: GuestInfoProps) { const { pid } = useParams<GuestInfoParams>() const [t] = useTranslation(['common']) const history = useHistory() + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false) + const [emailDirty, setEmailDirty] = useState(false) - // Resending the invitation does not cause a change in the state, so nothing needs to be updated after the call - const resend = () => { - fetch(`/api/ui/v1/invite/${guest.pid}/resend`, submitJsonOpts('PATCH', {})) - .then((res) => { - if (!res.ok) { - return null - } - return res.text() - }) - .then((result) => { - if (result !== null) { - console.log('result', result) - } - }) - .catch((error) => { - console.log('error', error) - }) + // Using useForm even though only the e-mail is allow to change at present, since useForm makes setup and validation easier + const { + register, + handleSubmit, + setValue, + formState: { errors }, + } = useForm<Email>({ mode: 'onChange' }) + + useEffect(() => { + setValue('email', guest.email) + // E-mail just updated so it is not dirty + setEmailDirty(false) + }, [guest]) + + const submit: SubmitHandler<Email> = (data) => { + updateEmail(data.email) } + const onSubmit = handleSubmit(submit) const handleCancel = () => { + setConfirmationDialogOpen(true) + } + + const cancelInvitation = () => { // There is no body for this request, but using submitJsonOpts still to // set the CSRF-token fetch( @@ -98,6 +160,8 @@ export default function GuestInfo({ guest, roles }: GuestInfoProps) { }) .then((result) => { if (result !== null) { + // TODO Need to trigger refresh of the sponsor page + // The invite for the guest has been cancelled, send the user back to the sponsor front page history.push('/sponsor') } @@ -108,11 +172,26 @@ export default function GuestInfo({ guest, roles }: GuestInfoProps) { }) } + const handleDialogClose = (ok: boolean) => { + setConfirmationDialogOpen(false) + if (ok) { + cancelInvitation() + } + } + + const emailFieldChange = (event: any) => { + if (event.target.value !== guest.email) { + setEmailDirty(true) + } else { + setEmailDirty(false) + } + } + return ( <Page> <SponsorInfoButtons to="/sponsor" name={`${guest.first} ${guest.last}`} /> <h4>{t('guestInfo.contactInfo')}</h4> - <TableContainer component={Paper}> + <TableContainer sx={{ marginBottom: '0.8rem' }} component={Paper}> <Table sx={{ minWidth: 650 }} aria-label="simple table"> <TableHead sx={{ backgroundColor: 'primary.light' }}> <TableRow> @@ -129,7 +208,53 @@ export default function GuestInfo({ guest, roles }: GuestInfoProps) { </TableRow> <TableRow> <TableCell align="left">{t('input.email')}</TableCell> - <TableCell align="left">{guest.email}</TableCell> + <TableCell align="left"> + <Box + sx={{ + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-start', + }} + > + <TextField + id="email" + error={!!errors.email} + helperText={errors.email && errors.email.message} + {...register(`email`, { + validate: isValidEmail, + })} + onChange={emailFieldChange} + /> + + {/* If the guest has not completed the registration process, he should have an invitation he has not responded to */} + {!guest.registered && ( + <Box + sx={{ + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }} + > + <Button + sx={{ maxHeight: '2.3rem', marginLeft: '1rem' }} + onClick={resend} + > + {t('button.resendInvitation')} + </Button> + <Button + sx={{ maxHeight: '2.3rem', marginLeft: '0.5rem' }} + onClick={handleCancel} + > + {t('button.cancelInvitation')} + </Button> + </Box> + )} + </Box> + <CancelConfirmationDialog + open={confirmationDialogOpen} + onClose={handleDialogClose} + /> + </TableCell> </TableRow> <TableRow> <TableCell align="left">{t('input.nationalIdNumber')}</TableCell> @@ -142,16 +267,9 @@ export default function GuestInfo({ guest, roles }: GuestInfoProps) { </TableBody> </Table> </TableContainer> - - {/* If the guest has not completed the registration process, he should have an invitation he has not responded to */} - {!guest.registered && ( - <> - <Button sx={{ marginTop: '0.5rem' }} onClick={resend}> - {t('button.resendInvitation')} - </Button> - <Button onClick={handleCancel}>{t('button.cancelInvitation')}</Button> - </> - )} + <Button disabled={!errors.email && !emailDirty} onClick={onSubmit}> + {t('button.save')} + </Button> <h4>{t('guestInfo.roleInfoHead')}</h4> <h5> diff --git a/frontend/src/routes/sponsor/guest/index.tsx b/frontend/src/routes/sponsor/guest/index.tsx index 62efcc74..d3ba9e7e 100644 --- a/frontend/src/routes/sponsor/guest/index.tsx +++ b/frontend/src/routes/sponsor/guest/index.tsx @@ -1,7 +1,7 @@ import { FetchedRole, Guest, Role } from 'interfaces' import { useEffect, useState } from 'react' import { Route, useParams } from 'react-router-dom' -import { parseRole } from 'utils' +import { parseRole, submitJsonOpts } from 'utils' import GuestInfo from './guestInfo' import GuestRoleInfo from './guestRoleInfo' import NewGuestRole from './newGuestRole' @@ -31,9 +31,8 @@ function GuestRoutes() { try { const response = await fetch(`/api/ui/v1/person/${id}`) const rjson = await response.json() - if (response.ok) { - console.log('Test30: ' + JSON.stringify(rjson)) + if (response.ok) { setGuest({ pid: rjson.pid, first: rjson.first, @@ -53,6 +52,42 @@ function GuestRoutes() { } } + const updateEmail = (validEmail: string) => { + fetch( + `/api/ui/v1/person/${pid}`, + submitJsonOpts('PATCH', { email: validEmail }) + ) + .then((res) => { + if (res.ok) { + // Just reload data from the server, it will cause the children + // to be rerendered + getPerson(pid) + } + }) + .catch((error) => { + console.log('error', error) + }) + } + + // Resending the invitation does not cause a change in the state, so nothing needs to be updated after the call + const resend = () => { + fetch(`/api/ui/v1/invite/${pid}/resend`, submitJsonOpts('PATCH', {})) + .then((res) => { + if (!res.ok) { + return null + } + return res.text() + }) + .then((result) => { + if (result !== null) { + console.log('result', result) + } + }) + .catch((error) => { + console.log('error', error) + }) + } + useEffect(() => { getPerson(pid) }, []) @@ -66,7 +101,12 @@ function GuestRoutes() { <NewGuestRole guest={guestInfo} /> </Route> <Route exact path="/sponsor/guest/:pid"> - <GuestInfo guest={guestInfo} roles={roles} /> + <GuestInfo + guest={guestInfo} + roles={roles} + updateEmail={updateEmail} + resend={resend} + /> </Route> </> ) diff --git a/gregui/api/views/person.py b/gregui/api/views/person.py index 8e8e1bbd..8972a467 100644 --- a/gregui/api/views/person.py +++ b/gregui/api/views/person.py @@ -1,11 +1,15 @@ from django.http.response import JsonResponse from django.utils.timezone import now +from rest_framework import status from rest_framework.authentication import SessionAuthentication, BasicAuthentication from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.views import APIView from greg.models import Identity, Person, InvitationLink from greg.permissions import IsSponsor +from gregui.api.serializers.guest import create_identity_or_update +from gregui.validation import validate_email class PersonView(APIView): @@ -49,6 +53,16 @@ class PersonView(APIView): } return JsonResponse(response) + def patch(self, request, id): + person = Person.objects.get(id=id) + # For now only the e-mail is allowed to be updated + email = request.data["email"] + validate_email(email) + # The following line will raise an exception if the e-mail is not valid + create_identity_or_update(Identity.IdentityType.PRIVATE_EMAIL, email, person) + + return Response(status=status.HTTP_200_OK) + class PersonSearchView(APIView): """Search for persons using email or phone number""" diff --git a/gregui/mailutils.py b/gregui/mailutils.py index c9bfd68f..ca1bf4f5 100644 --- a/gregui/mailutils.py +++ b/gregui/mailutils.py @@ -72,12 +72,11 @@ def send_confirmation_mail(mail_to: str, guest: str) -> str: def send_invite_mail(link: InvitationLink) -> Optional[str]: email_address = link.invitation.role.person.private_email - sponsor = link.invitation.role.sponsor - if not email_address: logger.warning(f"No e-mail address found for invitation link with ID: {link.id}") return None + sponsor = link.invitation.role.sponsor if not sponsor: logger.warning("Unable to determine sponsor for invitation link with ID: {link.id}") return None -- GitLab