Kaynağa Gözat

[web] Update secondary email addition: confirm with 6 digits code (#22851)

* Remove `Interstitial` from `ConfirmEmailForm`

* Allow adding `affiliationOptions` in `addWithConfirmationCode`

* Add confirmationStep in add-email.tsx

* Call `getEmails` once a secondary email is added

* Fix tests

* Lint fix

* Style confirm-email-form

Figma: https://www.figma.com/design/TWyeImDSZHhkl9akYaGmeb/24.5-Secondary-email-reconfirmation?node-id=1-449&p=f&m=dev

* Remove unnecessary `successMessage` and `successButtonText` from hidden ConfirmEmailForm

* Remove icon padding

* Rename file to confirm-email-form.tsx

* Use `OLButton`

* Add Cancel button

* Update loading states

* Remove redundant `className` with variants

GitOrigin-RevId: 62b1729cf2299da38f20fa3946273ad0193c7d54
Antoine Clausse 1 yıl önce
ebeveyn
işleme
4738961f51

+ 9 - 2
services/web/app/src/Features/User/UserEmailsController.js

@@ -155,6 +155,12 @@ async function addWithConfirmationCode(req, res) {
 
   const userId = SessionManager.getLoggedInUserId(req.session)
   const email = EmailHelper.parseEmail(req.body.email)
+  const affiliationOptions = {
+    university: req.body.university,
+    role: req.body.role,
+    department: req.body.department,
+  }
+
   if (!email) {
     return res.sendStatus(422)
   }
@@ -195,6 +201,7 @@ async function addWithConfirmationCode(req, res) {
       email,
       confirmCode,
       confirmCodeExpiresTimestamp,
+      affiliationOptions,
     }
 
     return res.sendStatus(200)
@@ -291,7 +298,7 @@ async function checkSecondaryEmailConfirmationCode(req, res) {
     await UserUpdater.promises.addEmailAddress(
       userId,
       req.session.pendingSecondaryEmail.email,
-      {},
+      req.session.pendingSecondaryEmail.affiliationOptions,
       {
         initiatorId: user._id,
         ipAddress: req.ip,
@@ -301,7 +308,7 @@ async function checkSecondaryEmailConfirmationCode(req, res) {
     await UserUpdater.promises.confirmEmail(
       userId,
       req.session.pendingSecondaryEmail.email,
-      {}
+      req.session.pendingSecondaryEmail.affiliationOptions
     )
 
     delete req.session.pendingSecondaryEmail

+ 21 - 2
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -19,6 +19,7 @@ import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
 import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
 import OLCol from '@/features/ui/components/ol/ol-col'
 import { bsVersion } from '@/features/utils/bootstrap-5'
+import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
 
 function AddEmail() {
   const { t } = useTranslation()
@@ -26,6 +27,7 @@ function AddEmail() {
     () => window.location.hash === '#add-email'
   )
   const [newEmail, setNewEmail] = useState('')
+  const [confirmationStep, setConfirmationStep] = useState(false)
   const [newEmailMatchedDomain, setNewEmailMatchedDomain] =
     useState<DomainInfo | null>(null)
   const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
@@ -90,7 +92,7 @@ function AddEmail() {
     runAsync(
       (async () => {
         const token = await getReCaptchaToken()
-        await postJSON('/user/emails', {
+        await postJSON('/user/emails/secondary', {
           body: {
             email: newEmail,
             ...knownUniversityData,
@@ -101,11 +103,28 @@ function AddEmail() {
       })()
     )
       .then(() => {
-        getEmails()
+        setConfirmationStep(true)
       })
       .catch(() => {})
   }
 
+  if (confirmationStep) {
+    return (
+      <ConfirmEmailForm
+        confirmationEndpoint="/user/emails/confirm-secondary"
+        resendEndpoint="/user/emails/resend-secondary-confirmation"
+        flow="secondary"
+        email={newEmail}
+        onSuccessfulConfirmation={getEmails}
+        interstitial={false}
+        onCancel={() => {
+          setConfirmationStep(false)
+          setIsFormVisible(false)
+        }}
+      />
+    )
+  }
+
   if (!isFormVisible) {
     return (
       <Layout isError={isError} error={error}>

+ 97 - 73
services/web/frontend/js/features/settings/components/emails/confirm-email.tsx → services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx

@@ -2,13 +2,13 @@ import { postJSON } from '@/infrastructure/fetch-json'
 import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
 import Notification from '@/shared/components/notification'
 import getMeta from '@/utils/meta'
-import { FormEvent, useState } from 'react'
-import { Button } from 'react-bootstrap'
+import { FormEvent, MouseEventHandler, useState } from 'react'
 import { Trans, useTranslation } from 'react-i18next'
 import LoadingSpinner from '@/shared/components/loading-spinner'
 import MaterialIcon from '@/shared/components/material-icon'
 import { sendMB } from '@/infrastructure/event-tracking'
-import { Interstitial } from '@/shared/components/interstitial'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 type Feedback = {
   type: 'input' | 'alert'
@@ -20,8 +20,12 @@ type ConfirmEmailFormProps = {
   confirmationEndpoint: string
   flow: string
   resendEndpoint: string
-  successMessage: React.ReactNode
-  successButtonText: string
+  successMessage?: React.ReactNode
+  successButtonText?: string
+  email?: string
+  onSuccessfulConfirmation?: () => void
+  interstitial: boolean
+  onCancel?: () => void
 }
 
 export function ConfirmEmailForm({
@@ -30,6 +34,10 @@ export function ConfirmEmailForm({
   resendEndpoint,
   successMessage,
   successButtonText,
+  email = getMeta('ol-email'),
+  onSuccessfulConfirmation,
+  interstitial,
+  onCancel,
 }: ConfirmEmailFormProps) {
   const { t } = useTranslation()
   const [confirmationCode, setConfirmationCode] = useState('')
@@ -37,7 +45,6 @@ export function ConfirmEmailForm({
   const [isConfirming, setIsConfirming] = useState(false)
   const [isResending, setIsResending] = useState(false)
   const [successRedirectPath, setSuccessRedirectPath] = useState('')
-  const email = getMeta('ol-email')
   const { isReady } = useWaitForI18n()
 
   const errorHandler = (err: any, actionType?: string) => {
@@ -78,33 +85,31 @@ export function ConfirmEmailForm({
     }
   }
 
-  const submitHandler = (e: FormEvent<HTMLFormElement>) => {
+  const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
     e.preventDefault()
     setIsConfirming(true)
     setFeedback(null)
-
-    postJSON(confirmationEndpoint, {
-      body: {
-        code: confirmationCode,
-      },
-    })
-      .then(data => {
-        setSuccessRedirectPath(data?.redir || '/')
-      })
-      .catch(err => {
-        errorHandler(err, 'confirm')
-      })
-      .finally(() => {
-        setIsConfirming(false)
-      })
-
     sendMB('email-verification-click', {
       button: 'verify',
       flow,
     })
+    try {
+      const data = await postJSON(confirmationEndpoint, {
+        body: { code: confirmationCode },
+      })
+      if (onSuccessfulConfirmation) {
+        onSuccessfulConfirmation()
+      } else {
+        setSuccessRedirectPath(data?.redir || '/')
+      }
+    } catch (err) {
+      errorHandler(err, 'confirm')
+    } finally {
+      setIsConfirming(false)
+    }
   }
 
-  const resendHandler = (e: FormEvent<Button>) => {
+  const resendHandler: MouseEventHandler<HTMLButtonElement> = () => {
     setIsResending(true)
     setFeedback(null)
 
@@ -138,14 +143,10 @@ export function ConfirmEmailForm({
   }
 
   if (!isReady) {
-    return (
-      <Interstitial className="confirm-email" showLogo>
-        <LoadingSpinner />
-      </Interstitial>
-    )
+    return <LoadingSpinner />
   }
 
-  if (successRedirectPath) {
+  if (successRedirectPath && successButtonText && successMessage) {
     return (
       <ConfirmEmailSuccessfullForm
         successMessage={successMessage}
@@ -156,8 +157,12 @@ export function ConfirmEmailForm({
   }
 
   return (
-    <Interstitial className="confirm-email" showLogo>
-      <form onSubmit={submitHandler} onInvalid={invalidFormHandler}>
+    <form
+      onSubmit={submitHandler}
+      onInvalid={invalidFormHandler}
+      className="confirm-email-form"
+    >
+      <div className="confirm-email-form-inner">
         {feedback?.type === 'alert' && (
           <Notification
             ariaLive="polite"
@@ -167,10 +172,17 @@ export function ConfirmEmailForm({
           />
         )}
 
-        <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
+        {interstitial ? (
+          <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
+        ) : (
+          <h5 className="h5">{t('confirm_your_email')}</h5>
+        )}
 
-        <p className="small">{t('enter_the_confirmation_code', { email })}</p>
+        <OLFormLabel htmlFor="one-time-code">
+          {t('enter_the_confirmation_code', { email })}
+        </OLFormLabel>
         <input
+          id="one-time-code"
           className="form-control"
           placeholder={t('enter_6_digit_code')}
           inputMode="numeric"
@@ -194,39 +206,53 @@ export function ConfirmEmailForm({
         </div>
 
         <div className="form-actions">
-          <Button
-            disabled={isConfirming || isResending}
+          <OLButton
+            disabled={isResending}
             type="submit"
-            bsStyle={null}
-            className="btn-primary"
+            isLoading={isConfirming}
+            bs3Props={{
+              loading: isConfirming ? (
+                <>
+                  {t('confirming')}
+                  <span>&hellip;</span>
+                </>
+              ) : (
+                t('confirm')
+              ),
+            }}
           >
-            {isConfirming ? (
-              <>
-                {t('confirming')}
-                <span>&hellip;</span>
-              </>
-            ) : (
-              t('confirm')
-            )}
-          </Button>
-          <Button
-            disabled={isConfirming || isResending}
+            {t('confirm')}
+          </OLButton>
+          <OLButton
+            variant="secondary"
+            disabled={isConfirming}
             onClick={resendHandler}
-            bsStyle={null}
-            className="btn-secondary"
+            isLoading={isResending}
+            bs3Props={{
+              loading: isResending ? (
+                <>
+                  {t('resending_confirmation_code')}
+                  <span>&hellip;</span>
+                </>
+              ) : (
+                t('resend_confirmation_code')
+              ),
+            }}
           >
-            {isResending ? (
-              <>
-                {t('resending_confirmation_code')}
-                <span>&hellip;</span>
-              </>
-            ) : (
-              t('resend_confirmation_code')
-            )}
-          </Button>
+            {t('resend_confirmation_code')}
+          </OLButton>
+          {onCancel && (
+            <OLButton
+              variant="danger-ghost"
+              disabled={isConfirming || isResending}
+              onClick={onCancel}
+            >
+              {t('cancel')}
+            </OLButton>
+          )}
         </div>
-      </form>
-    </Interstitial>
+      </div>
+    </form>
   )
 }
 
@@ -245,17 +271,15 @@ function ConfirmEmailSuccessfullForm({
   }
 
   return (
-    <Interstitial className="confirm-email" showLogo>
-      <form onSubmit={submitHandler}>
-        <div aria-live="polite">{successMessage}</div>
-
-        <div className="form-actions">
-          <Button type="submit" bsStyle={null} className="btn-primary">
-            {successButtonText}
-          </Button>
-        </div>
-      </form>
-    </Interstitial>
+    <form onSubmit={submitHandler}>
+      <div aria-live="polite">{successMessage}</div>
+
+      <div className="form-actions">
+        <OLButton type="submit" variant="primary">
+          {successButtonText}
+        </OLButton>
+      </div>
+    </form>
   )
 }
 

+ 12 - 8
services/web/frontend/js/features/settings/components/emails/confirm-secondary-email-form.tsx

@@ -1,5 +1,6 @@
-import { ConfirmEmailForm } from './confirm-email'
+import { ConfirmEmailForm } from './confirm-email-form'
 import { useTranslation } from 'react-i18next'
+import { Interstitial } from '@/shared/components/interstitial'
 
 export default function ConfirmSecondaryEmailForm() {
   const { t } = useTranslation()
@@ -13,12 +14,15 @@ export default function ConfirmSecondaryEmailForm() {
   )
 
   return (
-    <ConfirmEmailForm
-      successMessage={successMessage}
-      successButtonText={t('go_to_overleaf')}
-      confirmationEndpoint="/user/emails/confirm-secondary"
-      resendEndpoint="/user/emails/resend-secondary-confirmation"
-      flow="secondary"
-    />
+    <Interstitial className="confirm-email" showLogo>
+      <ConfirmEmailForm
+        successMessage={successMessage}
+        successButtonText={t('go_to_overleaf')}
+        confirmationEndpoint="/user/emails/confirm-secondary"
+        resendEndpoint="/user/emails/resend-secondary-confirmation"
+        flow="secondary"
+        interstitial
+      />
+    </Interstitial>
   )
 }

+ 4 - 0
services/web/frontend/stylesheets/app/confirm-email.less

@@ -15,6 +15,10 @@
       gap: 12px;
       padding-top: 20px;
     }
+
+    label {
+      font-weight: normal;
+    }
   }
 
   .confirm-email-alert {

+ 37 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss

@@ -203,3 +203,40 @@
     }
   }
 }
+
+#settings-page-root {
+  .confirm-email-form {
+    background: var(--bg-light-secondary);
+  }
+
+  .confirm-email-form-inner {
+    margin: auto;
+    padding: var(--spacing-08);
+    max-width: 480px;
+
+    label {
+      overflow-wrap: anywhere;
+    }
+
+    .text-danger {
+      display: flex;
+      gap: var(--spacing-03);
+      padding: var(--spacing-02);
+    }
+
+    .form-actions {
+      margin-top: var(--spacing-05);
+      display: flex;
+      flex-direction: column;
+      gap: var(--spacing-05);
+
+      button {
+        white-space: normal;
+      }
+
+      .btn-danger-ghost:not(:hover) {
+        background: transparent;
+      }
+    }
+  }
+}

+ 33 - 7
services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx

@@ -60,6 +60,19 @@ function resetFetchMock() {
   fetchMock.get('express:/institutions/domains', [])
 }
 
+async function confirmCodeForEmail(email: string) {
+  screen.getByText(`Enter the 6-digit confirmation code sent to ${email}.`)
+  const inputCode = screen.getByLabelText(/6-digit confirmation code/i)
+  fireEvent.change(inputCode, { target: { value: '123456' } })
+  const submitCodeBtn = screen.getByRole<HTMLButtonElement>('button', {
+    name: 'Confirm',
+  })
+  fireEvent.click(submitCodeBtn)
+  await waitForElementToBeRemoved(() =>
+    screen.getByRole('button', { name: /confirming/i })
+  )
+}
+
 describe('<EmailsSection />', function () {
   beforeEach(function () {
     Object.assign(getMeta('ol-ExposedSettings'), {
@@ -181,7 +194,8 @@ describe('<EmailsSection />', function () {
     resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailData])
-      .post('/user/emails', 200)
+      .post('/user/emails/secondary', 200)
+      .post('/user/emails/confirm-secondary', 200)
 
     fireEvent.click(addAnotherEmailBtn)
     const input = screen.getByLabelText(/email/i)
@@ -206,6 +220,7 @@ describe('<EmailsSection />', function () {
       })
     )
 
+    await confirmCodeForEmail(userEmailData.email)
     screen.getByText(userEmailData.email)
   })
 
@@ -222,7 +237,7 @@ describe('<EmailsSection />', function () {
     resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [])
-      .post('/user/emails', 400)
+      .post('/user/emails/secondary', 400)
 
     fireEvent.click(addAnotherEmailBtn)
     const input = screen.getByLabelText(/email/i)
@@ -340,7 +355,8 @@ describe('<EmailsSection />', function () {
 
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
-      .post(/\/user\/emails/, 200)
+      .post('/user/emails/secondary', 200)
+      .post('/user/emails/confirm-secondary', 200)
 
     await userEvent.click(
       screen.getByRole('button', {
@@ -359,8 +375,12 @@ describe('<EmailsSection />', function () {
       department: customDepartment,
     })
 
-    screen.getByText(userEmailData.email)
-    screen.getByText(userEmailData.affiliation.institution.name)
+    screen.getByText(
+      `Enter the 6-digit confirmation code sent to ${userEmailData.email}.`
+    )
+
+    await confirmCodeForEmail(userEmailData.email)
+
     screen.getByText(userEmailData.affiliation.role!, { exact: false })
     screen.getByText(customDepartment, { exact: false })
   })
@@ -485,7 +505,8 @@ describe('<EmailsSection />', function () {
 
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
-      .post(/\/user\/emails/, 200)
+      .post('/user/emails/secondary', 200)
+      .post('/user/emails/confirm-secondary', 200)
 
     await userEvent.click(
       screen.getByRole('button', {
@@ -493,6 +514,8 @@ describe('<EmailsSection />', function () {
       })
     )
 
+    await confirmCodeForEmail(userEmailData.email)
+
     const [[, request]] = fetchMock.calls(/\/user\/emails/)
 
     expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
@@ -635,7 +658,8 @@ describe('<EmailsSection />', function () {
 
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
-      .post('/user/emails', 200)
+      .post('/user/emails/secondary', 200)
+      .post('/user/emails/confirm-secondary', 200)
 
     await userEvent.type(
       screen.getByRole('textbox', { name: /role/i }),
@@ -651,6 +675,8 @@ describe('<EmailsSection />', function () {
       })
     )
 
+    await confirmCodeForEmail('user@autocomplete.edu')
+
     await fetchMock.flush(true)
     fetchMock.reset()