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