Просмотр исходного кода

[web] Use 6-digits code to confirm existing email in Account Settings (#23931)

* Rename `checkSecondaryEmailConfirmationCode` to `checkAddSecondaryEmailConfirmationCode`

* Create function `sendCodeAndStoreInSession`

* Create function `sendExistingSecondaryEmailConfirmationCode`

* Create function `_checkConfirmationCode`

* Create function `checkExistingEmailConfirmationCode`

* Rename `resendSecondaryEmailConfirmationCode` to `resendAddSecondaryEmailConfirmationCode`

* Create function `_resendConfirmationCode`

* Create function `resendExistingSecondaryEmailConfirmationCode`

* Add `ResendConfirmationCodeModal`

* Remove `ResendConfirmationEmailButton`

* `bin/run web npm run extract-translations`

* Update frontend test

* Fix: don't throw on render when send-confirmation-code fails!

* Update phrasing in the UI

Per https://docs.google.com/document/d/1PE1vlZWQN--PjmXpyHR9rV2YPd7OIPIsUbnZaHj0cDI/edit?usp=sharing

* Add unit test

* Don't share the "send-confirmation" and "resend-confirmation" rate-limits

* Update frontend test after copy change

* Rename `checkAddSecondaryEmailConfirmationCode` to `checkNewSecondaryEmailConfirmationCode` and `resendAddSecondaryEmailConfirmationCode` to `resendNewSecondaryEmailConfirmationCode`

* Rename `cb` to `beforeConfirmEmail`

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Return `422` on missing session data

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Add `userId` to log

* Replace `isSecondary` param by `welcomeUser`

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Rename `resend-confirm-email-code`'s `existingEmail` to `email`

* Remove "secondary" from rate-limiters

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Remove unnecessary `userId` check behind `AuthenticationController.requireLogin()`

* Only open the modal if the code was sent successfully

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: df892064641d9f722785699777383b2d863124e1
Antoine Clausse 1 год назад
Родитель
Сommit
c4e6dfbbbd

+ 3 - 3
services/web/app/src/Features/Email/EmailBuilder.js

@@ -257,12 +257,12 @@ templates.confirmCode = NoCTAEmailTemplate({
     return 'Confirm your email address'
   },
   message(opts, isPlainText) {
-    const msg = opts.isSecondary
-      ? ['Use this 6-digit code to confirm your email address.']
-      : [
+    const msg = opts.welcomeUser
+      ? [
           `Welcome to Overleaf! We're so glad you joined us.`,
           'Use this 6-digit confirmation code to finish your setup.',
         ]
+      : ['Use this 6-digit code to confirm your email address.']
 
     if (isPlainText && opts.confirmCode) {
       msg.push(opts.confirmCode)

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

@@ -43,7 +43,7 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) {
   )
 }
 
-async function sendConfirmationCode(email, isSecondary) {
+async function sendConfirmationCode(email, welcomeUser) {
   if (!EmailHelper.parseEmail(email)) {
     throw new Error('invalid email')
   }
@@ -55,7 +55,7 @@ async function sendConfirmationCode(email, isSecondary) {
   await EmailHandler.promises.sendEmail('confirmCode', {
     to: email,
     confirmCode,
-    isSecondary,
+    welcomeUser,
     category: ['ConfirmEmail'],
   })
 

+ 239 - 165
services/web/app/src/Features/User/UserEmailsController.js

@@ -24,23 +24,19 @@ const SplitTestHandler = require('../SplitTests/SplitTestHandler')
 
 const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
 
-const sendSecondaryConfirmCodeRateLimiter = new RateLimiter(
-  'send-secondary-confirmation-code',
-  {
-    points: 1,
-    duration: 60,
-  }
-)
-const checkSecondaryConfirmCodeRateLimiter = new RateLimiter(
-  'check-secondary-confirmation-code-per-email',
+const sendConfirmCodeRateLimiter = new RateLimiter('send-confirmation-code', {
+  points: 1,
+  duration: 60,
+})
+const checkConfirmCodeRateLimiter = new RateLimiter(
+  'check-confirmation-code-per-email',
   {
     points: 10,
     duration: 60,
   }
 )
-
-const resendSecondaryConfirmCodeRateLimiter = new RateLimiter(
-  'resend-secondary-confirmation-code',
+const resendConfirmCodeRateLimiter = new RateLimiter(
+  'resend-confirmation-code',
   {
     points: 1,
     duration: 60,
@@ -146,6 +142,23 @@ async function sendReconfirmation(req, res) {
   res.sendStatus(204)
 }
 
+async function sendExistingSecondaryEmailConfirmationCode(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const email = EmailHelper.parseEmail(req.body.email)
+  if (!email) {
+    return res.sendStatus(400)
+  }
+  const user = await UserGetter.promises.getUserByAnyEmail(email, {
+    _id: 1,
+    email,
+  })
+  if (!user || user._id.toString() !== userId) {
+    return res.sendStatus(422)
+  }
+  await sendCodeAndStoreInSession(req, 'pendingExistingEmail', email)
+  res.sendStatus(204)
+}
+
 /**
  * This method is for adding a secondary email to be confirmed via a code.
  * For email link confirmation see the `add` method in this file.
@@ -177,7 +190,7 @@ async function addWithConfirmationCode(req, res) {
   try {
     await UserGetter.promises.ensureUniqueEmailAddress(email)
 
-    await sendSecondaryConfirmCodeRateLimiter.consume(email, 1, {
+    await sendConfirmCodeRateLimiter.consume(email, 1, {
       method: 'email',
     })
 
@@ -191,18 +204,12 @@ async function addWithConfirmationCode(req, res) {
       }
     )
 
-    const { confirmCode, confirmCodeExpiresTimestamp } =
-      await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
-        email,
-        true
-      )
-
-    req.session.pendingSecondaryEmail = {
+    await sendCodeAndStoreInSession(
+      req,
+      'pendingSecondaryEmail',
       email,
-      confirmCode,
-      confirmCodeExpiresTimestamp,
-      affiliationOptions,
-    }
+      affiliationOptions
+    )
 
     return res.sendStatus(200)
   } catch (err) {
@@ -231,186 +238,238 @@ async function addWithConfirmationCode(req, res) {
   }
 }
 
-async function checkSecondaryEmailConfirmationCode(req, res) {
-  const userId = SessionManager.getLoggedInUserId(req.session)
-  const code = req.body.code
-  const user = await UserGetter.promises.getUser(userId, {
-    email: 1,
-    'emails.email': 1,
-  })
-
-  if (!req.session.pendingSecondaryEmail) {
-    logger.err(
-      {},
-      'error checking confirmation code. missing pendingSecondaryEmail'
+/**
+ * @param {import('express').Request} req
+ * @param {string} sessionKey
+ * @param {string} email
+ * @param affiliationOptions
+ * @returns {Promise<void>}
+ */
+async function sendCodeAndStoreInSession(
+  req,
+  sessionKey,
+  email,
+  affiliationOptions
+) {
+  const { confirmCode, confirmCodeExpiresTimestamp } =
+    await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
+      email,
+      false
     )
+  req.session[sessionKey] = {
+    email,
+    confirmCode,
+    confirmCodeExpiresTimestamp,
+    affiliationOptions,
+  }
+}
 
-    return res.status(500).json({
-      message: {
-        key: 'error_performing_request',
-      },
+/**
+ * @param {string} sessionKey
+ * @param {(req: import('express').Request, user: any, email: string, affiliationOptions: any) => Promise<void>} beforeConfirmEmail
+ * @returns {Promise<*>}
+ */
+const _checkConfirmationCode =
+  (sessionKey, beforeConfirmEmail) => async (req, res) => {
+    const userId = SessionManager.getLoggedInUserId(req.session)
+    const code = req.body.code
+    const user = await UserGetter.promises.getUser(userId, {
+      email: 1,
+      'emails.email': 1,
     })
-  }
 
-  const newSecondaryEmail = req.session.pendingSecondaryEmail.email
+    const sessionData = req.session[sessionKey]
 
-  try {
-    await checkSecondaryConfirmCodeRateLimiter.consume(newSecondaryEmail, 1, {
-      method: 'email',
-    })
-  } catch (err) {
-    if (err?.remainingPoints === 0) {
-      return res.sendStatus(429)
-    } else {
-      return res.status(500).json({
+    if (!sessionData) {
+      logger.err({}, `error checking confirmation code. missing ${sessionKey}`)
+
+      return res.status(422).json({
         message: {
           key: 'error_performing_request',
         },
       })
     }
-  }
 
-  if (
-    req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp < Date.now()
-  ) {
-    return res.status(403).json({
-      message: { key: 'expired_confirmation_code' },
-    })
-  }
+    const emailToCheck = sessionData.email
 
-  if (!tsscmp(req.session.pendingSecondaryEmail.confirmCode, code)) {
-    return res.status(403).json({
-      message: { key: 'invalid_confirmation_code' },
-    })
+    try {
+      await checkConfirmCodeRateLimiter.consume(emailToCheck, 1, {
+        method: 'email',
+      })
+    } catch (err) {
+      if (err?.remainingPoints === 0) {
+        return res.sendStatus(429)
+      } else {
+        return res.status(500).json({
+          message: {
+            key: 'error_performing_request',
+          },
+        })
+      }
+    }
+
+    if (sessionData.confirmCodeExpiresTimestamp < Date.now()) {
+      return res.status(403).json({
+        message: { key: 'expired_confirmation_code' },
+      })
+    }
+
+    if (!tsscmp(sessionData.confirmCode, code)) {
+      return res.status(403).json({
+        message: { key: 'invalid_confirmation_code' },
+      })
+    }
+
+    try {
+      await beforeConfirmEmail(
+        req,
+        user,
+        emailToCheck,
+        sessionData.affiliationOptions
+      )
+
+      await UserUpdater.promises.confirmEmail(
+        userId,
+        emailToCheck,
+        sessionData.affiliationOptions
+      )
+
+      delete req.session[sessionKey]
+
+      AnalyticsManager.recordEventForUserInBackground(
+        user._id,
+        'email-verified',
+        {
+          provider: 'email',
+          verification_type: 'token',
+          isPrimary: user.email === emailToCheck,
+        }
+      )
+
+      const redirectUrl =
+        AuthenticationController.getRedirectFromSession(req) || '/project'
+
+      return res.json({
+        redir: redirectUrl,
+      })
+    } catch (error) {
+      if (error.name === 'EmailExistsError') {
+        return res.status(409).json({
+          message: {
+            type: 'error',
+            text: req.i18n.translate('email_already_registered'),
+          },
+        })
+      }
+
+      logger.err({ error }, 'failed to check confirmation code')
+
+      return res.status(500).json({
+        message: {
+          key: 'error_performing_request',
+        },
+      })
+    }
   }
 
-  try {
+const checkNewSecondaryEmailConfirmationCode = _checkConfirmationCode(
+  'pendingSecondaryEmail',
+  async (req, user, email, affiliationOptions) => {
     await UserAuditLogHandler.promises.addEntry(
-      userId,
+      user._id,
       'add-email-via-code',
-      userId,
+      user._id,
       req.ip,
-      { newSecondaryEmail }
+      { newSecondaryEmail: email }
     )
-
-    await _sendSecurityAlertEmail(user, newSecondaryEmail)
-
+    await _sendSecurityAlertEmail(user, email)
     await UserUpdater.promises.addEmailAddress(
-      userId,
-      newSecondaryEmail,
-      req.session.pendingSecondaryEmail.affiliationOptions,
+      user._id,
+      email,
+      affiliationOptions,
       {
         initiatorId: user._id,
         ipAddress: req.ip,
       }
     )
+  }
+)
 
-    await UserUpdater.promises.confirmEmail(
-      userId,
-      newSecondaryEmail,
-      req.session.pendingSecondaryEmail.affiliationOptions
-    )
-
-    delete req.session.pendingSecondaryEmail
-
-    AnalyticsManager.recordEventForUserInBackground(
+const checkExistingEmailConfirmationCode = _checkConfirmationCode(
+  'pendingExistingEmail',
+  async (req, user, email) => {
+    await UserAuditLogHandler.promises.addEntry(
       user._id,
-      'email-verified',
-      {
-        provider: 'email',
-        verification_type: 'token',
-        isPrimary: false,
-      }
+      'confirm-email-via-code',
+      user._id,
+      req.ip,
+      { email }
     )
+  }
+)
 
-    const redirectUrl =
-      AuthenticationController.getRedirectFromSession(req) || '/project'
-
-    return res.json({
-      redir: redirectUrl,
-    })
-  } catch (error) {
-    if (error.name === 'EmailExistsError') {
-      return res.status(409).json({
+const _resendConfirmationCode =
+  (sessionKey, operation, auditLogEmailKey) => async (req, res) => {
+    const sessionData = req.session[sessionKey]
+    if (!sessionData) {
+      logger.err({}, `error resending confirmation code. missing ${sessionKey}`)
+      return res.status(422).json({
         message: {
-          type: 'error',
-          text: req.i18n.translate('email_already_registered'),
+          key: 'error_performing_request',
         },
       })
     }
 
-    logger.err({ error }, 'failed to check confirmation code')
-
-    return res.status(500).json({
-      message: {
-        key: 'error_performing_request',
-      },
-    })
-  }
-}
-
-async function resendSecondaryEmailConfirmationCode(req, res) {
-  if (!req.session.pendingSecondaryEmail) {
-    logger.err(
-      {},
-      'error resending confirmation code. missing pendingSecondaryEmail'
-    )
+    const email = sessionData.email
 
-    return res.status(500).json({
-      message: {
-        key: 'error_performing_request',
-      },
-    })
-  }
-
-  const email = req.session.pendingSecondaryEmail.email
-
-  try {
-    await resendSecondaryConfirmCodeRateLimiter.consume(email, 1, {
-      method: 'email',
-    })
-  } catch (err) {
-    if (err?.remainingPoints === 0) {
-      return res.status(429).json({})
-    } else {
-      throw err
+    try {
+      await resendConfirmCodeRateLimiter.consume(email, 1, { method: 'email' })
+    } catch (err) {
+      if (err?.remainingPoints === 0) {
+        return res.status(429).json({})
+      } else {
+        throw err
+      }
     }
-  }
 
-  try {
     const userId = SessionManager.getLoggedInUserId(req.session)
 
-    await UserAuditLogHandler.promises.addEntry(
-      userId,
-      'resend-add-email-code',
-      userId,
-      req.ip,
-      {
-        newSecondaryEmail: email,
-      }
-    )
-
-    const { confirmCode, confirmCodeExpiresTimestamp } =
-      await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
-        email,
-        true
+    try {
+      await UserAuditLogHandler.promises.addEntry(
+        userId,
+        operation,
+        userId,
+        req.ip,
+        { [auditLogEmailKey]: email }
       )
 
-    req.session.pendingSecondaryEmail.confirmCode = confirmCode
-    req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp =
-      confirmCodeExpiresTimestamp
+      const { confirmCode, confirmCodeExpiresTimestamp } =
+        await UserEmailsConfirmationHandler.promises.sendConfirmationCode(
+          email,
+          false
+        )
 
-    return res.status(200).json({
-      message: { key: 'we_sent_new_code' },
-    })
-  } catch (err) {
-    logger.err({ err, email }, 'failed to send confirmation code')
+      sessionData.confirmCode = confirmCode
+      sessionData.confirmCodeExpiresTimestamp = confirmCodeExpiresTimestamp
 
-    return res.status(500).json({
-      key: 'error_performing_request',
-    })
+      return res.status(200).json({ message: { key: 'we_sent_new_code' } })
+    } catch (err) {
+      logger.err({ err, userId, email }, 'failed to send confirmation code')
+      return res.status(500).json({ key: 'error_performing_request' })
+    }
   }
-}
+
+const resendNewSecondaryEmailConfirmationCode = _resendConfirmationCode(
+  'pendingSecondaryEmail',
+  'resend-add-email-code',
+  'newSecondaryEmail'
+)
+
+const resendExistingSecondaryEmailConfirmationCode = _resendConfirmationCode(
+  'pendingExistingEmail',
+  'resend-confirm-email-code',
+  'email'
+)
 
 async function confirmSecondaryEmailPage(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
@@ -623,12 +682,23 @@ const UserEmailsController = {
   },
 
   add: expressify(add),
+
   addWithConfirmationCode: expressify(addWithConfirmationCode),
-  checkSecondaryEmailConfirmationCode: expressify(
-    checkSecondaryEmailConfirmationCode
+
+  checkNewSecondaryEmailConfirmationCode: expressify(
+    checkNewSecondaryEmailConfirmationCode
   ),
-  resendSecondaryEmailConfirmationCode: expressify(
-    resendSecondaryEmailConfirmationCode
+
+  checkExistingEmailConfirmationCode: expressify(
+    checkExistingEmailConfirmationCode
+  ),
+
+  resendNewSecondaryEmailConfirmationCode: expressify(
+    resendNewSecondaryEmailConfirmationCode
+  ),
+
+  resendExistingSecondaryEmailConfirmationCode: expressify(
+    resendExistingSecondaryEmailConfirmationCode
   ),
 
   remove: expressify(remove),
@@ -660,6 +730,10 @@ const UserEmailsController = {
 
   sendReconfirmation: expressify(sendReconfirmation),
 
+  sendExistingSecondaryEmailConfirmationCode: expressify(
+    sendExistingSecondaryEmailConfirmationCode
+  ),
+
   addSecondaryEmailPage: expressify(addSecondaryEmailPage),
 
   confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage),

+ 26 - 0
services/web/app/src/router.mjs

@@ -180,6 +180,10 @@ const rateLimiters = {
     points: 1,
     duration: 60,
   }),
+  sendConfirmation: new RateLimiter('send-confirmation', {
+    points: 1,
+    duration: 60,
+  }),
   sendChatMessage: new RateLimiter('send-chat-message', {
     points: 100,
     duration: 60,
@@ -335,6 +339,28 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
     RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail),
     UserEmailsController.confirm
   )
+
+  webRouter.post(
+    '/user/emails/send-confirmation-code',
+    AuthenticationController.requireLogin(),
+    RateLimiterMiddleware.rateLimit(rateLimiters.sendConfirmation),
+    UserEmailsController.sendExistingSecondaryEmailConfirmationCode
+  )
+
+  webRouter.post(
+    '/user/emails/resend-confirmation-code',
+    AuthenticationController.requireLogin(),
+    RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmation),
+    UserEmailsController.resendExistingSecondaryEmailConfirmationCode
+  )
+
+  webRouter.post(
+    '/user/emails/confirm-code',
+    AuthenticationController.requireLogin(),
+    RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail),
+    UserEmailsController.checkExistingEmailConfirmationCode
+  )
+
   webRouter.post(
     '/user/emails/resend_confirmation',
     AuthenticationController.requireLogin(),

+ 2 - 1
services/web/frontend/extracted-translations.json

@@ -508,6 +508,7 @@
   "enter_6_digit_code": "",
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
+  "enter_the_code": "",
   "enter_the_confirmation_code": "",
   "enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "",
   "equation_generator": "",
@@ -1184,7 +1185,6 @@
   "please_ask_the_project_owner_to_upgrade_more_editors": "",
   "please_ask_the_project_owner_to_upgrade_to_track_changes": "",
   "please_change_primary_to_remove": "",
-  "please_check_your_inbox": "",
   "please_check_your_inbox_to_confirm": "",
   "please_compile_pdf_before_download": "",
   "please_compile_pdf_before_word_count": "",
@@ -1975,6 +1975,7 @@
   "we_do_not_share_personal_information": "",
   "we_got_your_request": "",
   "we_logged_you_in": "",
+  "we_sent_code": "",
   "we_sent_new_code": "",
   "we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "",
   "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "",

+ 13 - 6
services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx

@@ -25,6 +25,7 @@ type ConfirmEmailFormProps = {
   email?: string
   onSuccessfulConfirmation?: () => void
   interstitial: boolean
+  isModal?: boolean
   onCancel?: () => void
 }
 
@@ -37,6 +38,7 @@ export function ConfirmEmailForm({
   email = getMeta('ol-email'),
   onSuccessfulConfirmation,
   interstitial,
+  isModal,
   onCancel,
 }: ConfirmEmailFormProps) {
   const { t } = useTranslation()
@@ -156,6 +158,13 @@ export function ConfirmEmailForm({
     )
   }
 
+  let intro = <h5 className="h5">{t('confirm_your_email')}</h5>
+  if (isModal) intro = <h5 className="h5">{t('we_sent_code')}</h5>
+  if (interstitial)
+    intro = (
+      <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
+    )
+
   return (
     <form
       onSubmit={submitHandler}
@@ -172,14 +181,12 @@ export function ConfirmEmailForm({
           />
         )}
 
-        {interstitial ? (
-          <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
-        ) : (
-          <h5 className="h5">{t('confirm_your_email')}</h5>
-        )}
+        {intro}
 
         <OLFormLabel htmlFor="one-time-code">
-          {t('enter_the_confirmation_code', { email })}
+          {isModal
+            ? t('enter_the_code', { email })
+            : t('enter_the_confirmation_code', { email })}
         </OLFormLabel>
         <input
           id="one-time-code"

+ 3 - 6
services/web/frontend/js/features/settings/components/emails/email.tsx

@@ -1,10 +1,10 @@
 import { useTranslation } from 'react-i18next'
 import { UserEmailData } from '../../../../../../types/user-email'
-import ResendConfirmationEmailButton from './resend-confirmation-email-button'
 import { ssoAvailableForInstitution } from '../../utils/sso'
 import OLBadge from '@/features/ui/components/ol/ol-badge'
 import { isBootstrap5 } from '@/features/utils/bootstrap-5'
 import classnames from 'classnames'
+import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal'
 
 type EmailProps = {
   userEmailData: UserEmailData
@@ -29,13 +29,10 @@ function Email({ userEmailData }: EmailProps) {
       {userEmailData.email}
       {!userEmailData.confirmedAt && (
         <div className="small">
-          <strong>
-            {t('unconfirmed')}.
-            {!ssoAvailable && <span> {t('please_check_your_inbox')}.</span>}
-          </strong>
+          <strong>{t('unconfirmed')}.</strong>
           <br />
           {!ssoAvailable && (
-            <ResendConfirmationEmailButton email={userEmailData.email} />
+            <ResendConfirmationCodeModal email={userEmailData.email} />
           )}
         </div>
       )}

+ 116 - 0
services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx

@@ -0,0 +1,116 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../shared/components/icon'
+import { FetchError, postJSON } from '@/infrastructure/fetch-json'
+import useAsync from '../../../../shared/hooks/use-async'
+import { UserEmailData } from '../../../../../../types/user-email'
+import { useUserEmailsContext } from '../../context/user-email-context'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
+
+type ResendConfirmationEmailButtonProps = {
+  email: UserEmailData['email']
+}
+
+function ResendConfirmationCodeModal({
+  email,
+}: ResendConfirmationEmailButtonProps) {
+  const { t } = useTranslation()
+  const { error, isLoading, isError, runAsync } = useAsync()
+  const {
+    state,
+    setLoading: setUserEmailsContextLoading,
+    getEmails,
+  } = useUserEmailsContext()
+  const [modalVisible, setModalVisible] = useState(false)
+
+  // Update global isLoading prop
+  useEffect(() => {
+    setUserEmailsContextLoading(isLoading)
+  }, [setUserEmailsContextLoading, isLoading])
+
+  const handleResendConfirmationEmail = async () => {
+    await runAsync(
+      postJSON('/user/emails/send-confirmation-code', { body: { email } })
+    )
+      .then(() => setModalVisible(true))
+      .catch(() => {})
+  }
+
+  if (isLoading) {
+    return (
+      <>
+        <Icon type="refresh" spin fw /> {t('sending')}&hellip;
+      </>
+    )
+  }
+
+  const rateLimited =
+    error && error instanceof FetchError && error.response?.status === 429
+
+  return (
+    <>
+      {modalVisible && (
+        <OLModal
+          animation
+          show={modalVisible}
+          onHide={() => setModalVisible(false)}
+          id="action-project-modal"
+          backdrop="static"
+        >
+          <OLModalHeader closeButton>
+            <OLModalTitle>{t('confirm_your_email')}</OLModalTitle>
+          </OLModalHeader>
+
+          <OLModalBody>
+            <ConfirmEmailForm
+              isModal
+              flow="resend"
+              interstitial={false}
+              resendEndpoint="/user/emails/resend-confirmation-code"
+              confirmationEndpoint="/user/emails/confirm-code"
+              email={email}
+              onSuccessfulConfirmation={() => {
+                getEmails()
+                setModalVisible(false)
+              }}
+            />
+          </OLModalBody>
+          <OLModalFooter>
+            <OLButton
+              variant="secondary"
+              disabled={isLoading}
+              onClick={() => setModalVisible(false)}
+            >
+              {t('cancel')}
+            </OLButton>
+          </OLModalFooter>
+        </OLModal>
+      )}
+      <OLButton
+        variant="link"
+        disabled={state.isLoading || isLoading}
+        onClick={handleResendConfirmationEmail}
+        className="btn-inline-link"
+      >
+        {t('resend_confirmation_code')}
+      </OLButton>
+      <br />
+      {isError && (
+        <div className="text-danger">
+          {rateLimited
+            ? t('too_many_requests')
+            : t('generic_something_went_wrong')}
+        </div>
+      )}
+    </>
+  )
+}
+
+export default ResendConfirmationCodeModal

+ 0 - 70
services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx

@@ -1,70 +0,0 @@
-import { useEffect } from 'react'
-import { useTranslation } from 'react-i18next'
-import Icon from '../../../../shared/components/icon'
-import { FetchError, postJSON } from '../../../../infrastructure/fetch-json'
-import useAsync from '../../../../shared/hooks/use-async'
-import { UserEmailData } from '../../../../../../types/user-email'
-import { useUserEmailsContext } from '../../context/user-email-context'
-import OLButton from '@/features/ui/components/ol/ol-button'
-
-type ResendConfirmationEmailButtonProps = {
-  email: UserEmailData['email']
-}
-
-function ResendConfirmationEmailButton({
-  email,
-}: ResendConfirmationEmailButtonProps) {
-  const { t } = useTranslation()
-  const { error, isLoading, isError, runAsync } = useAsync()
-  const { state, setLoading: setUserEmailsContextLoading } =
-    useUserEmailsContext()
-
-  // Update global isLoading prop
-  useEffect(() => {
-    setUserEmailsContextLoading(isLoading)
-  }, [setUserEmailsContextLoading, isLoading])
-
-  const handleResendConfirmationEmail = () => {
-    runAsync(
-      postJSON('/user/emails/resend_confirmation', {
-        body: {
-          email,
-        },
-      })
-    ).catch(() => {})
-  }
-
-  if (isLoading) {
-    return (
-      <>
-        <Icon type="refresh" spin fw /> {t('sending')}&hellip;
-      </>
-    )
-  }
-
-  const rateLimited =
-    error && error instanceof FetchError && error.response?.status === 429
-
-  return (
-    <>
-      <OLButton
-        variant="link"
-        disabled={state.isLoading || isLoading}
-        onClick={handleResendConfirmationEmail}
-        className="btn-inline-link"
-      >
-        {t('resend_confirmation_email')}
-      </OLButton>
-      <br />
-      {isError && (
-        <div className="text-danger">
-          {rateLimited
-            ? t('too_many_requests')
-            : t('generic_something_went_wrong')}
-        </div>
-      )}
-    </>
-  )
-}
-
-export default ResendConfirmationEmailButton

+ 3 - 2
services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss

@@ -204,8 +204,9 @@
   }
 }
 
-#settings-page-root {
-  .confirm-email-form {
+#settings-page-root,
+#action-project-modal {
+  &#settings-page-root .confirm-email-form {
     background: var(--bg-light-secondary);
   }
 

+ 2 - 1
services/web/locales/en.json

@@ -654,6 +654,7 @@
   "enter_6_digit_code": "Enter 6-digit code",
   "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
   "enter_image_url": "Enter image URL",
+  "enter_the_code": "Enter the 6-digit code sent to __email__.",
   "enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.",
   "enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of users you’d like to add to see the cost breakdown.",
   "enter_your_email_address": "Enter your email address",
@@ -1583,7 +1584,6 @@
   "please_ask_the_project_owner_to_upgrade_more_editors": "Please ask the project owner to upgrade their plan to allow more editors.",
   "please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
   "please_change_primary_to_remove": "Please change your primary email in order to remove",
-  "please_check_your_inbox": "Please check your inbox",
   "please_check_your_inbox_to_confirm": "Please check your email inbox to confirm your <0>__institutionName__</0> affiliation.",
   "please_compile_pdf_before_download": "Please compile your project before downloading the PDF",
   "please_compile_pdf_before_word_count": "Please compile your project before performing a word count",
@@ -2520,6 +2520,7 @@
   "we_got_your_request": "We’ve got your request",
   "we_logged_you_in": "We have logged you in.",
   "we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you</0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
+  "we_sent_code": "We’ve sent you a confirmation code",
   "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
   "we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "We’ll charge you now for the cost of your additional users based on the remaining months of your current subscription.",
   "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "We’ll charge you now for your new plan based on the remaining months of your current subscription.",

+ 11 - 14
services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx

@@ -81,7 +81,7 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
     render(<EmailsSection />)
 
-    await screen.findByText(/please check your inbox/i)
+    await screen.findByText(/unconfirmed/i)
   })
 
   it('hides confirmation status for confirmed users', async function () {
@@ -96,7 +96,7 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
     render(<EmailsSection />)
 
-    await screen.findByRole('button', { name: /resend confirmation email/i })
+    await screen.findByRole('button', { name: /resend confirmation code/i })
   })
 
   it('renders professional label', async function () {
@@ -115,16 +115,16 @@ describe('<EmailsSection />', function () {
     render(<EmailsSection />)
     await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
 
-    fetchMock.post('/user/emails/resend_confirmation', 200)
+    fetchMock.post('/user/emails/send-confirmation-code', 200)
 
     const button = screen.getByRole('button', {
-      name: /resend confirmation email/i,
+      name: /resend confirmation code/i,
     })
     fireEvent.click(button)
 
     expect(
       screen.queryByRole('button', {
-        name: /resend confirmation email/i,
+        name: /resend confirmation code/i,
       })
     ).to.be.null
 
@@ -135,7 +135,7 @@ describe('<EmailsSection />', function () {
     ).to.be.null
 
     await screen.findByRole('button', {
-      name: /resend confirmation email/i,
+      name: /resend confirmation code/i,
     })
   })
 
@@ -145,22 +145,19 @@ describe('<EmailsSection />', function () {
     render(<EmailsSection />)
     await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
 
-    fetchMock.post('/user/emails/resend_confirmation', 503)
+    fetchMock.post('/user/emails/send-confirmation-code', 503)
 
     const button = screen.getByRole('button', {
-      name: /resend confirmation email/i,
+      name: /resend confirmation code/i,
     })
     fireEvent.click(button)
 
-    expect(
-      screen.queryByRole('button', {
-        name: /resend confirmation email/i,
-      })
-    ).to.be.null
+    expect(screen.queryByRole('button', { name: /resend confirmation code/i }))
+      .to.be.null
 
     await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
 
     screen.getByText(/sorry, something went wrong/i)
-    screen.getByRole('button', { name: /resend confirmation email/i })
+    screen.getByRole('button', { name: /resend confirmation code/i })
   })
 })

+ 320 - 20
services/web/test/unit/src/User/UserEmailsControllerTests.js

@@ -311,7 +311,7 @@ describe('UserEmailsController', function () {
           assertCalledWith(
             this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
             this.newEmail,
-            true
+            false
           )
           done()
         },
@@ -360,7 +360,7 @@ describe('UserEmailsController', function () {
     })
   })
 
-  describe('checkSecondaryEmailConfirmationCode', function () {
+  describe('checkNewSecondaryEmailConfirmationCode', function () {
     beforeEach(function () {
       this.newEmail = 'new_email@baz.com'
       this.req.session.pendingSecondaryEmail = {
@@ -378,7 +378,7 @@ describe('UserEmailsController', function () {
       })
 
       it('adds the email', function (done) {
-        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+        this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
           this.req,
           {
             json: () => {
@@ -399,7 +399,7 @@ describe('UserEmailsController', function () {
       })
 
       it('redirects to /project', function (done) {
-        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+        this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
           this.req,
           {
             json: ({ redir }) => {
@@ -419,7 +419,7 @@ describe('UserEmailsController', function () {
         }
         this.req.body.code = '123456'
 
-        await this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+        await this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
           this.req,
           {
             json: sinon.stub().resolves(),
@@ -444,7 +444,7 @@ describe('UserEmailsController', function () {
       })
 
       it('does not add the email', function (done) {
-        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+        this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
           this.req,
           {
             status: () => {
@@ -458,7 +458,7 @@ describe('UserEmailsController', function () {
       })
 
       it('responds with a 403', function (done) {
-        this.UserEmailsController.checkSecondaryEmailConfirmationCode(
+        this.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
           this.req,
           {
             status: code => {
@@ -472,7 +472,7 @@ describe('UserEmailsController', function () {
     })
   })
 
-  describe('resendSecondaryEmailConfirmationCode', function () {
+  describe('resendNewSecondaryEmailConfirmationCode', function () {
     beforeEach(function () {
       this.newEmail = 'new_email@baz.com'
       this.req.session.pendingSecondaryEmail = {
@@ -489,18 +489,21 @@ describe('UserEmailsController', function () {
     })
 
     it('should send the email', function (done) {
-      this.UserEmailsController.resendSecondaryEmailConfirmationCode(this.req, {
-        status: code => {
-          code.should.equal(200)
-          assertCalledWith(
-            this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
-            this.newEmail,
-            true
-          )
-          done()
-          return { json: this.next }
-        },
-      })
+      this.UserEmailsController.resendNewSecondaryEmailConfirmationCode(
+        this.req,
+        {
+          status: code => {
+            code.should.equal(200)
+            assertCalledWith(
+              this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
+              this.newEmail,
+              false
+            )
+            done()
+            return { json: this.next }
+          },
+        }
+      )
     })
   })
 
@@ -906,4 +909,301 @@ describe('UserEmailsController', function () {
       })
     })
   })
+
+  describe('sendExistingSecondaryEmailConfirmationCode', function () {
+    beforeEach(function () {
+      this.email = 'existing-email@example.com'
+      this.req.body.email = this.email
+      this.EmailHelper.parseEmail.returns(this.email)
+      this.UserGetter.promises.getUserByAnyEmail.resolves({
+        _id: this.user._id,
+        email: this.email,
+      })
+      this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
+        .stub()
+        .resolves({
+          confirmCode: '123456',
+          confirmCodeExpiresTimestamp: new Date(),
+        })
+    })
+
+    it('should send confirmation code for existing email', async function () {
+      await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        {
+          sendStatus: code => {
+            code.should.equal(204)
+            assertCalledWith(
+              this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
+              this.email,
+              false
+            )
+          },
+        }
+      )
+    })
+
+    it('should store confirmation code in session', async function () {
+      const confirmCode = '123456'
+      const confirmCodeExpiresTimestamp = new Date()
+      this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves(
+        { confirmCode, confirmCodeExpiresTimestamp }
+      )
+      await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        { sendStatus: sinon.stub() }
+      )
+      expect(this.req.session.pendingExistingEmail).to.deep.equal({
+        email: this.email,
+        confirmCode,
+        confirmCodeExpiresTimestamp,
+        affiliationOptions: undefined,
+      })
+    })
+
+    it('should handle invalid email', async function () {
+      this.EmailHelper.parseEmail.returns(null)
+      await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        {
+          sendStatus: code => {
+            code.should.equal(400)
+            assertNotCalled(
+              this.UserEmailsConfirmationHandler.promises.sendConfirmationCode
+            )
+          },
+        }
+      )
+    })
+
+    it('should handle email not belonging to user', async function () {
+      this.UserGetter.promises.getUserByAnyEmail.resolves({
+        _id: 'another-user-id',
+      })
+      await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        {
+          sendStatus: code => {
+            code.should.equal(422)
+            assertNotCalled(
+              this.UserEmailsConfirmationHandler.promises.sendConfirmationCode
+            )
+          },
+        }
+      )
+    })
+  })
+
+  describe('checkExistingEmailConfirmationCode', function () {
+    beforeEach(function () {
+      this.email = 'existing-email@example.com'
+      this.req.session.pendingExistingEmail = {
+        confirmCode: '123456',
+        email: this.email,
+        confirmCodeExpiresTimestamp: new Date(Math.max),
+      }
+      this.UserUpdater.promises.confirmEmail.resolves()
+      this.res = {
+        json: sinon.stub(),
+        status: sinon.stub().returns({ json: sinon.stub() }),
+      }
+    })
+
+    describe('with a valid confirmation code', function () {
+      beforeEach(function () {
+        this.req.body = { code: '123456' }
+      })
+
+      it('confirms the email', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          {
+            json: () => {
+              assertCalledWith(
+                this.UserUpdater.promises.confirmEmail,
+                this.user._id,
+                this.email
+              )
+            },
+          }
+        )
+      })
+
+      it('adds audit log entry', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          { json: sinon.stub() }
+        )
+        assertCalledWith(
+          this.UserAuditLogHandler.promises.addEntry,
+          this.user._id,
+          'confirm-email-via-code',
+          this.user._id,
+          this.req.ip,
+          { email: this.email }
+        )
+      })
+
+      it('records analytics event', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          { json: sinon.stub() }
+        )
+        assertCalledWith(
+          this.AnalyticsManager.recordEventForUserInBackground,
+          this.user._id,
+          'email-verified',
+          {
+            provider: 'email',
+            verification_type: 'token',
+            isPrimary: this.user.email === this.email,
+          }
+        )
+      })
+
+      it('removes pendingExistingEmail from session', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          { json: sinon.stub() }
+        )
+
+        expect(this.req.session.pendingExistingEmail).to.be.undefined
+      })
+    })
+
+    describe('with an invalid confirmation code', function () {
+      beforeEach(function () {
+        this.req.body = { code: '999999' }
+      })
+
+      it('does not confirm the email', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          {
+            status: () => {
+              assertNotCalled(this.UserUpdater.promises.confirmEmail)
+              return { json: this.next }
+            },
+          }
+        )
+      })
+
+      it('responds with a 403', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          {
+            status: code => {
+              code.should.equal(403)
+              return { json: this.next }
+            },
+          }
+        )
+      })
+    })
+
+    describe('with an expired confirmation code', function () {
+      beforeEach(function () {
+        this.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp =
+          new Date(0)
+        this.req.body = { code: '123456' }
+      })
+
+      it('responds with a 403', async function () {
+        await this.UserEmailsController.checkExistingEmailConfirmationCode(
+          this.req,
+          {
+            status: code => {
+              code.should.equal(403)
+              return { json: this.next }
+            },
+          }
+        )
+      })
+    })
+  })
+
+  describe('resendExistingSecondaryEmailConfirmationCode', function () {
+    beforeEach(function () {
+      this.email = 'existing-email@example.com'
+      this.req.session.pendingExistingEmail = {
+        confirmCode: '123456',
+        email: this.email,
+        confirmCodeExpiresTimestamp: new Date(Math.max),
+      }
+      this.res.status = sinon.stub().returns({ json: sinon.stub() })
+      this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon
+        .stub()
+        .resolves({
+          confirmCode: '654321',
+          confirmCodeExpiresTimestamp: new Date(),
+        })
+    })
+
+    it('should resend confirmation code', async function () {
+      await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        {
+          status: code => {
+            code.should.equal(200)
+            assertCalledWith(
+              this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
+              this.email,
+              false
+            )
+            return { json: sinon.stub() }
+          },
+        }
+      )
+    })
+
+    it('should update session with new code', async function () {
+      const newCode = '654321'
+      const newExpiryTime = new Date()
+      this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves(
+        {
+          confirmCode: newCode,
+          confirmCodeExpiresTimestamp: newExpiryTime,
+        }
+      )
+      await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        { status: () => ({ json: sinon.stub() }) }
+      )
+      expect(this.req.session.pendingExistingEmail.confirmCode).to.equal(
+        newCode
+      )
+      expect(
+        this.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp
+      ).to.equal(newExpiryTime)
+    })
+
+    it('should add audit log entry', async function () {
+      await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        { status: () => ({ json: sinon.stub() }) }
+      )
+
+      assertCalledWith(
+        this.UserAuditLogHandler.promises.addEntry,
+        this.user._id,
+        'resend-confirm-email-code',
+        this.user._id,
+        this.req.ip,
+        { email: this.email }
+      )
+    })
+
+    it('should handle rate limiting', async function () {
+      this.rateLimiter.consume.rejects({ remainingPoints: 0 })
+      await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
+        this.req,
+        {
+          status: code => {
+            code.should.equal(429)
+            return { json: sinon.stub() }
+          },
+        }
+      )
+    })
+  })
 })