Преглед изворни кода

Merge pull request #18320 from overleaf/dp-add-secondary-prompt-ui

Add secondary email form V2 (with Captcha this time)

GitOrigin-RevId: b06216a2c9cb5b3b09305a17992eca506a0047f5
David пре 2 година
родитељ
комит
635aae7b1f
22 измењених фајлова са 836 додато и 58 уклоњено
  1. 71 3
      services/web/app/src/Features/User/UserEmailsController.js
  2. 14 0
      services/web/app/src/Features/User/UserGetter.js
  3. 9 15
      services/web/app/src/router.js
  4. 14 0
      services/web/app/views/user/addSecondaryEmail.pug
  5. 15 0
      services/web/app/views/user/confirmSecondaryEmail.pug
  6. 7 1
      services/web/frontend/extracted-translations.json
  7. 163 0
      services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx
  8. 296 0
      services/web/frontend/js/features/settings/components/emails/confirm-email.tsx
  9. 24 0
      services/web/frontend/js/features/settings/components/emails/confirm-secondary-email-form.tsx
  10. 12 0
      services/web/frontend/js/pages/user/add-secondary-email.tsx
  11. 10 0
      services/web/frontend/js/pages/user/confirm-secondary-email.tsx
  12. 16 0
      services/web/frontend/stylesheets/app/add-secondary-email-prompt.less
  13. 23 0
      services/web/frontend/stylesheets/app/confirm-email.less
  14. 1 0
      services/web/frontend/stylesheets/components/interstitial.less
  15. 3 0
      services/web/frontend/stylesheets/main-style.less
  16. 0 22
      services/web/frontend/stylesheets/modules/overleaf-integration.less
  17. 7 1
      services/web/locales/en.json
  18. 0 1
      services/web/locales/zh-CN.json
  19. 3 4
      services/web/test/acceptance/src/AddSecondaryEmailTests.js
  20. 107 9
      services/web/test/acceptance/src/PrimaryEmailCheckTests.js
  21. 2 2
      services/web/test/unit/src/User/UserEmailsControllerTests.js
  22. 39 0
      services/web/test/unit/src/User/UserGetterTests.js

+ 71 - 3
services/web/app/src/Features/User/UserEmailsController.js

@@ -17,7 +17,9 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
 const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
 const UserAuditLogHandler = require('./UserAuditLogHandler')
 const { RateLimiter } = require('../../infrastructure/RateLimiter')
+const Features = require('../../infrastructure/Features')
 const tsscmp = require('tsscmp')
+const Modules = require('../../infrastructure/Modules')
 
 const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
 
@@ -208,9 +210,7 @@ async function addWithConfirmationCode(req, res) {
       confirmCodeExpiresTimestamp,
     }
 
-    return res.json({
-      redir: '/user/emails/confirm-secondary',
-    })
+    return res.sendStatus(200)
   } catch (err) {
     if (err.name === 'EmailExistsError') {
       return res.status(409).json({
@@ -418,6 +418,45 @@ async function resendSecondaryEmailConfirmationCode(req, res) {
   }
 }
 
+async function confirmSecondaryEmailPage(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+
+  if (!req.session.pendingSecondaryEmail) {
+    const redirectURL =
+      AuthenticationController.getRedirectFromSession(req) || '/project'
+    return res.redirect(redirectURL)
+  }
+
+  AnalyticsManager.recordEventForUser(
+    userId,
+    'confirm-secondary-email-page-displayed'
+  )
+
+  res.render('user/confirmSecondaryEmail', {
+    email: req.session.pendingSecondaryEmail.email,
+  })
+}
+
+async function addSecondaryEmailPage(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+
+  const confirmedEmails =
+    await UserGetter.promises.getUserConfirmedEmails(userId)
+
+  if (confirmedEmails.length >= 2) {
+    const redirectURL =
+      AuthenticationController.getRedirectFromSession(req) || '/project'
+    return res.redirect(redirectURL)
+  }
+
+  AnalyticsManager.recordEventForUser(
+    userId,
+    'add-secondary-email-page-displayed'
+  )
+
+  res.render('user/addSecondaryEmail')
+}
+
 async function primaryEmailCheckPage(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const user = await UserGetter.promises.getUser(userId, {
@@ -443,10 +482,35 @@ async function primaryEmailCheck(req, res) {
   await UserUpdater.promises.updateUser(userId, {
     $set: { lastPrimaryEmailCheck: new Date() },
   })
+
   AnalyticsManager.recordEventForUserInBackground(
     userId,
     'primary-email-check-done'
   )
+
+  // We want to redirect to prompt a user to add a secondary email if their primary
+  // is an institutional email and they dont' already have a secondary.
+  if (Features.hasFeature('saas') && req.capabilitySet.has('add-affiliation')) {
+    const confirmedEmails =
+      await UserGetter.promises.getUserConfirmedEmails(userId)
+
+    if (confirmedEmails.length < 2) {
+      const { email: primaryEmail } = SessionManager.getSessionUser(req.session)
+      const primaryEmailDomain = EmailHelper.getDomain(primaryEmail)
+
+      const institution = (
+        await Modules.promises.hooks.fire(
+          'getInstitutionViaDomain',
+          primaryEmailDomain
+        )
+      )?.[0]
+
+      if (institution) {
+        return AsyncFormHelper.redirect(req, res, '/user/emails/add-secondary')
+      }
+    }
+  }
+
   AsyncFormHelper.redirect(req, res, '/project')
 }
 
@@ -558,6 +622,10 @@ const UserEmailsController = {
 
   sendReconfirmation,
 
+  addSecondaryEmailPage: expressify(addSecondaryEmailPage),
+
+  confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage),
+
   primaryEmailCheckPage: expressify(primaryEmailCheckPage),
 
   primaryEmailCheck: expressify(primaryEmailCheck),

+ 14 - 0
services/web/app/src/Features/User/UserGetter.js

@@ -79,6 +79,18 @@ async function getUserFullEmails(userId) {
   )
 }
 
+async function getUserConfirmedEmails(userId) {
+  const user = await UserGetter.promises.getUser(userId, {
+    emails: 1,
+  })
+
+  if (!user) {
+    throw new Error('User not Found')
+  }
+
+  return user.emails.filter(email => !!email.confirmedAt)
+}
+
 async function getSsoUsersAtInstitution(institutionId, projection) {
   if (!projection) {
     throw new Error('missing projection')
@@ -124,6 +136,8 @@ const UserGetter = {
 
   getUserFullEmails: callbackify(getUserFullEmails),
 
+  getUserConfirmedEmails: callbackify(getUserConfirmedEmails),
+
   getUserByMainEmail(email, projection, callback) {
     email = email.trim()
     if (arguments.length === 2) {

+ 9 - 15
services/web/app/src/router.js

@@ -336,6 +336,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
   webRouter.post(
     '/user/emails/primary-email-check',
     AuthenticationController.requireLogin(),
+    PermissionsController.useCapabilities(),
     UserEmailsController.primaryEmailCheck
   )
 
@@ -348,6 +349,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
       CaptchaMiddleware.validateCaptcha('addEmail'),
       UserEmailsController.add
     )
+
     webRouter.post(
       '/user/emails/delete',
       AuthenticationController.requireLogin(),
@@ -366,29 +368,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
       RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail),
       UserEmailsController.endorse
     )
+  }
 
-    webRouter.post(
-      '/user/emails/secondary',
+  if (Features.hasFeature('saas')) {
+    webRouter.get(
+      '/user/emails/add-secondary',
       AuthenticationController.requireLogin(),
       PermissionsController.requirePermission('add-secondary-email'),
-      RateLimiterMiddleware.rateLimit(rateLimiters.addEmail),
-      UserEmailsController.addWithConfirmationCode
+      UserEmailsController.addSecondaryEmailPage
     )
 
-    webRouter.post(
+    webRouter.get(
       '/user/emails/confirm-secondary',
       AuthenticationController.requireLogin(),
       PermissionsController.requirePermission('add-secondary-email'),
-      RateLimiterMiddleware.rateLimit(rateLimiters.checkEmailConfirmationCode),
-      UserEmailsController.checkSecondaryEmailConfirmationCode
-    )
-
-    webRouter.post(
-      '/user/emails/resend-secondary-confirmation',
-      AuthenticationController.requireLogin(),
-      PermissionsController.requirePermission('add-secondary-email'),
-      RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmationCode),
-      UserEmailsController.resendSecondaryEmailConfirmationCode
+      UserEmailsController.confirmSecondaryEmailPage
     )
   }
 

+ 14 - 0
services/web/app/views/user/addSecondaryEmail.pug

@@ -0,0 +1,14 @@
+extends ../layout-marketing
+
+block vars
+	- var suppressNavbar = true
+	- var suppressSkipToContent = true
+
+block entrypointVar
+	- entrypoint = 'pages/user/add-secondary-email'
+
+block append meta
+
+block content
+	main.content.content-alt
+		#add-secondary-email

+ 15 - 0
services/web/app/views/user/confirmSecondaryEmail.pug

@@ -0,0 +1,15 @@
+extends ../layout-marketing
+
+block vars
+	- var suppressNavbar = true
+	- var suppressSkipToContent = true
+
+block entrypointVar
+	- entrypoint = 'pages/user/confirm-secondary-email'
+
+block append meta
+	meta(name="ol-email" content=email)
+
+block content
+	main.content.content-alt
+		#confirm-secondary-email

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

@@ -33,6 +33,7 @@
   "actions": "",
   "active": "",
   "add": "",
+  "add_a_recovery_email_address": "",
   "add_additional_certificate": "",
   "add_affiliation": "",
   "add_another_address_line": "",
@@ -41,6 +42,7 @@
   "add_comma_separated_emails_help": "",
   "add_comment": "",
   "add_company_details": "",
+  "add_email_address": "",
   "add_email_to_claim_features": "",
   "add_files": "",
   "add_more_collaborators": "",
@@ -205,7 +207,6 @@
   "confirm_primary_email_change": "",
   "confirm_remove_sso_config_enter_email": "",
   "confirm_your_email": "",
-  "confirmation_code_message": "",
   "confirming": "",
   "conflicting_paths_found": "",
   "congratulations_youve_successfully_join_group": "",
@@ -355,6 +356,7 @@
   "email_confirmed_onboarding_message": "",
   "email_limit_reached": "",
   "email_link_expired": "",
+  "email_must_be_linked_to_institution": "",
   "email_or_password_wrong_try_again": "",
   "emails_and_affiliations_explanation": "",
   "emails_and_affiliations_title": "",
@@ -368,6 +370,7 @@
   "enter_6_digit_code": "",
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
+  "enter_the_confirmation_code": "",
   "error": "",
   "error_opening_document": "",
   "error_opening_document_detail": "",
@@ -637,6 +640,7 @@
   "justify": "",
   "keep_current_plan": "",
   "keep_personal_projects_separate": "",
+  "keep_your_account_safe_add_another_email": "",
   "keybindings": "",
   "labels_help_you_to_easily_reference_your_figures": "",
   "labels_help_you_to_reference_your_tables": "",
@@ -661,6 +665,7 @@
   "layout": "",
   "layout_processing": "",
   "learn_more": "",
+  "learn_more_about_account": "",
   "learn_more_about_link_sharing": "",
   "learn_more_about_managed_users": "",
   "learn_more_about_other_causes_of_compile_timeouts": "",
@@ -1314,6 +1319,7 @@
   "test_configuration_successful": "",
   "tex_live_version": "",
   "thank_you_exclamation": "",
+  "thanks_for_confirming_your_email_address": "",
   "thanks_for_subscribing": "",
   "thanks_for_subscribing_you_help_sl": "",
   "thanks_settings_updated": "",

+ 163 - 0
services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx

@@ -0,0 +1,163 @@
+import { Interstitial } from '@/shared/components/interstitial'
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { Button } from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import EmailInput from './add-email/input'
+import { useState } from 'react'
+import MaterialIcon from '@/shared/components/material-icon'
+import { sendMB } from '@/infrastructure/event-tracking'
+import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
+import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
+
+import { postJSON } from '../../../../infrastructure/fetch-json'
+
+type AddSecondaryEmailError = {
+  name: string
+  data: any
+}
+
+export function AddSecondaryEmailPrompt() {
+  const isReady = useWaitForI18n()
+  const { t } = useTranslation()
+  const [email, setEmail] = useState<string>()
+  const [error, setError] = useState<AddSecondaryEmailError | undefined>()
+  const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
+  const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
+
+  if (!isReady) {
+    return null
+  }
+
+  const onEmailChange = (newEmail: string) => {
+    if (newEmail !== email) {
+      setEmail(newEmail)
+      setError(undefined)
+    }
+  }
+
+  const errorHandler = (err: any) => {
+    let errorName = 'generic_something_went_wrong'
+
+    if (err?.response?.status === 409) {
+      errorName = 'email_already_registered'
+    } else if (err?.response?.status === 429) {
+      errorName = 'too_many_attempts'
+    } else if (err?.response?.status === 422) {
+      errorName = 'email_must_be_linked_to_institution'
+    } else if (err?.data.errorReason === 'cannot_verify_user_not_robot') {
+      errorName = 'cannot_verify_user_not_robot'
+    }
+
+    setError({ name: errorName, data: err?.data })
+    sendMB('add-secondary-email-error', { errorName })
+  }
+
+  const handleSubmit = async (e?: React.FormEvent<HTMLFormElement>) => {
+    if (e) {
+      e.preventDefault()
+    }
+
+    setIsSubmitting(true)
+
+    const token = await getReCaptchaToken()
+
+    await postJSON('/user/emails/secondary', {
+      body: {
+        email,
+        'g-recaptcha-response': token,
+      },
+    })
+      .then(() => {
+        location.assign('/user/emails/confirm-secondary')
+      })
+      .catch(errorHandler)
+      .finally(() => {
+        setIsSubmitting(false)
+      })
+  }
+
+  return (
+    <>
+      <Interstitial showLogo title={t('add_a_recovery_email_address')}>
+        <form className="add-secondary-email" onSubmit={handleSubmit}>
+          <ReCaptcha2 page="addEmail" ref={recaptchaRef} />
+          <p>{t('keep_your_account_safe_add_another_email')}</p>
+
+          <EmailInput
+            onChange={onEmailChange}
+            handleAddNewEmail={handleSubmit}
+          />
+
+          <div aria-live="polite">
+            {error && <ErrorMessage error={error} />}
+          </div>
+
+          <Button
+            bsStyle={null}
+            disabled={isSubmitting}
+            className="btn-primary"
+            type="submit"
+          >
+            {isSubmitting ? <>{t('adding')}&hellip;</> : t('add_email_address')}
+          </Button>
+          <Button
+            bsStyle={null}
+            disabled={isSubmitting}
+            className="btn-secondary"
+            href="/project"
+          >
+            {t('not_now')}
+          </Button>
+          <p className="add-secondary-email-learn-more">
+            <Trans
+              i18nKey="learn_more_about_account"
+              components={[
+                // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
+                <a href="/learn/how-to/Keeping_your_account_secure" />,
+              ]}
+            />
+          </p>
+        </form>
+      </Interstitial>
+    </>
+  )
+}
+
+function ErrorMessage({ error }: { error: AddSecondaryEmailError }) {
+  const { t } = useTranslation()
+
+  let errorText
+
+  switch (error.name) {
+    case 'email_already_registered':
+      errorText = t('email_already_registered')
+      break
+    case 'too_many_attempts':
+      errorText = t('too_many_attempts')
+      break
+    case 'email_must_be_linked_to_institution':
+      errorText = (
+        <Trans
+          i18nKey="email_must_be_linked_to_institution"
+          values={{ institutionName: error?.data?.institutionName }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+          components={[<a href="/account/settings" />]}
+        />
+      )
+      break
+    case 'cannot_verify_user_not_robot':
+      errorText = t('cannot_verify_user_not_robot')
+      break
+    default:
+      errorText = t('generic_something_went_wrong')
+  }
+
+  return (
+    <div className="add-secondary-email-error small text-danger">
+      <MaterialIcon className="icon" type="error" />
+      <div>{errorText}</div>
+    </div>
+  )
+}

+ 296 - 0
services/web/frontend/js/features/settings/components/emails/confirm-email.tsx

@@ -0,0 +1,296 @@
+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 { 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'
+
+type Feedback = {
+  type: 'input' | 'alert'
+  style: 'error' | 'info'
+  message: string
+}
+
+type ConfirmEmailFormProps = {
+  confirmationEndpoint: string
+  flow: string
+  resendEndpoint: string
+  successMessage: React.ReactNode
+  successButtonText: string
+}
+
+export function ConfirmEmailForm({
+  confirmationEndpoint,
+  flow,
+  resendEndpoint,
+  successMessage,
+  successButtonText,
+}: ConfirmEmailFormProps) {
+  const { t } = useTranslation()
+  const [confirmationCode, setConfirmationCode] = useState('')
+  const [feedback, setFeedback] = useState<Feedback | null>(null)
+  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) => {
+    let errorName = err?.data?.message?.key || 'generic_something_went_wrong'
+
+    if (err?.response?.status === 429) {
+      if (actionType === 'confirm') {
+        errorName = 'too_many_confirm_code_verification_attempts'
+      } else if (actionType === 'resend') {
+        errorName = 'too_many_confirm_code_resend_attempts'
+      }
+      setFeedback({
+        type: 'alert',
+        style: 'error',
+        message: errorName,
+      })
+    } else {
+      setFeedback({
+        type: 'input',
+        style: 'error',
+        message: errorName,
+      })
+    }
+
+    sendMB('email-verification-error', {
+      errorName,
+      flow,
+    })
+  }
+
+  const invalidFormHandler = () => {
+    if (!confirmationCode) {
+      return setFeedback({
+        type: 'input',
+        style: 'error',
+        message: 'please_enter_confirmation_code',
+      })
+    }
+  }
+
+  const submitHandler = (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,
+    })
+  }
+
+  const resendHandler = (e: FormEvent<Button>) => {
+    setIsResending(true)
+    setFeedback(null)
+
+    postJSON(resendEndpoint)
+      .then(data => {
+        setIsResending(false)
+        if (data?.message?.key) {
+          setFeedback({
+            type: 'alert',
+            style: 'info',
+            message: data.message.key,
+          })
+        }
+      })
+      .catch(err => {
+        errorHandler(err, 'resend')
+      })
+      .finally(() => {
+        setIsResending(false)
+      })
+
+    sendMB('email-verification-click', {
+      button: 'resend',
+      flow,
+    })
+  }
+
+  const changeHandler = (e: FormEvent<HTMLInputElement>) => {
+    setConfirmationCode(e.currentTarget.value)
+    setFeedback(null)
+  }
+
+  if (!isReady) {
+    return (
+      <Interstitial className="confirm-email" showLogo>
+        <LoadingSpinner />
+      </Interstitial>
+    )
+  }
+
+  if (successRedirectPath) {
+    return (
+      <ConfirmEmailSuccessfullForm
+        successMessage={successMessage}
+        successButtonText={successButtonText}
+        redirectTo={successRedirectPath}
+      />
+    )
+  }
+
+  return (
+    <Interstitial className="confirm-email" showLogo>
+      <form onSubmit={submitHandler} onInvalid={invalidFormHandler}>
+        {feedback?.type === 'alert' && (
+          <Notification
+            ariaLive="polite"
+            className="confirm-email-alert"
+            type={feedback.style}
+            content={<ErrorMessage error={feedback.message} />}
+          />
+        )}
+
+        <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
+
+        <p className="small">{t('enter_the_confirmation_code', { email })}</p>
+        <input
+          className="form-control"
+          placeholder={t('enter_6_digit_code')}
+          inputMode="numeric"
+          required
+          value={confirmationCode}
+          onChange={changeHandler}
+          data-ol-dirty={feedback ? 'true' : undefined}
+          maxLength={6}
+          autoComplete="one-time-code"
+          autoFocus // eslint-disable-line jsx-a11y/no-autofocus
+        />
+        <div aria-live="polite">
+          {feedback?.type === 'input' && (
+            <div className="small text-danger">
+              <MaterialIcon className="icon" type="error" />
+              <div>
+                <ErrorMessage error={feedback.message} />
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="form-actions">
+          <Button
+            disabled={isConfirming || isResending}
+            type="submit"
+            bsStyle={null}
+            className="btn-primary"
+          >
+            {isConfirming ? (
+              <>
+                {t('confirming')}
+                <span>&hellip;</span>
+              </>
+            ) : (
+              t('confirm')
+            )}
+          </Button>
+          <Button
+            disabled={isConfirming || isResending}
+            onClick={resendHandler}
+            bsStyle={null}
+            className="btn-secondary"
+          >
+            {isResending ? (
+              <>
+                {t('resending_confirmation_code')}
+                <span>&hellip;</span>
+              </>
+            ) : (
+              t('resend_confirmation_code')
+            )}
+          </Button>
+        </div>
+      </form>
+    </Interstitial>
+  )
+}
+
+function ConfirmEmailSuccessfullForm({
+  successMessage,
+  successButtonText,
+  redirectTo,
+}: {
+  successMessage: React.ReactNode
+  successButtonText: string
+  redirectTo: string
+}) {
+  const submitHandler = (e: FormEvent<HTMLFormElement>) => {
+    e.preventDefault()
+    location.assign(redirectTo)
+  }
+
+  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>
+  )
+}
+
+function ErrorMessage({ error }: { error: string }) {
+  const { t } = useTranslation()
+
+  switch (error) {
+    case 'invalid_confirmation_code':
+      return <span>{t('invalid_confirmation_code')}</span>
+
+    case 'expired_confirmation_code':
+      return (
+        <Trans
+          i18nKey="expired_confirmation_code"
+          /* eslint-disable-next-line react/jsx-key  */
+          components={[<strong />]}
+        />
+      )
+
+    case 'email_already_registered':
+      return <span>{t('email_already_registered')}</span>
+
+    case 'too_many_confirm_code_resend_attempts':
+      return <span>{t('too_many_confirm_code_resend_attempts')}</span>
+
+    case 'too_many_confirm_code_verification_attempts':
+      return <span>{t('too_many_confirm_code_verification_attempts')}</span>
+
+    case 'we_sent_new_code':
+      return <span>{t('we_sent_new_code')}</span>
+
+    case 'please_enter_confirmation_code':
+      return <span>{t('please_enter_confirmation_code')}</span>
+
+    default:
+      return <span>{t('generic_something_went_wrong')}</span>
+  }
+}

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

@@ -0,0 +1,24 @@
+import { ConfirmEmailForm } from './confirm-email'
+import { useTranslation } from 'react-i18next'
+
+export default function ConfirmSecondaryEmailForm() {
+  const { t } = useTranslation()
+
+  const successMessage = (
+    <>
+      <h1 className="h3 interstitial-header">
+        {t('thanks_for_confirming_your_email_address')}
+      </h1>
+    </>
+  )
+
+  return (
+    <ConfirmEmailForm
+      successMessage={successMessage}
+      successButtonText={t('go_to_overleaf')}
+      confirmationEndpoint="/user/emails/confirm-secondary"
+      resendEndpoint="/user/emails/resend-secondary-confirmation"
+      flow="secondary"
+    />
+  )
+}

+ 12 - 0
services/web/frontend/js/pages/user/add-secondary-email.tsx

@@ -0,0 +1,12 @@
+import '../../marketing'
+
+import ReactDOM from 'react-dom'
+import { AddSecondaryEmailPrompt } from '../../features/settings/components/emails/add-secondary-email-prompt'
+
+const addSecondaryEmailContainer = document.getElementById(
+  'add-secondary-email'
+)
+
+if (addSecondaryEmailContainer) {
+  ReactDOM.render(<AddSecondaryEmailPrompt />, addSecondaryEmailContainer)
+}

+ 10 - 0
services/web/frontend/js/pages/user/confirm-secondary-email.tsx

@@ -0,0 +1,10 @@
+import '../../marketing'
+
+import ReactDOM from 'react-dom'
+import ConfirmSecondaryEmailForm from '../../features/settings/components/emails/confirm-secondary-email-form'
+
+const confirmEmailContainer = document.getElementById('confirm-secondary-email')
+
+if (confirmEmailContainer) {
+  ReactDOM.render(<ConfirmSecondaryEmailForm />, confirmEmailContainer)
+}

+ 16 - 0
services/web/frontend/stylesheets/app/add-secondary-email-prompt.less

@@ -0,0 +1,16 @@
+.add-secondary-email {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  .add-secondary-email-error {
+    display: flex;
+    gap: 6px;
+    padding: 4px;
+  }
+
+  .add-secondary-email-learn-more {
+    margin-top: 12px;
+    margin-bottom: 0;
+  }
+}

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

@@ -0,0 +1,23 @@
+.confirm-email {
+  form {
+    .text-danger {
+      display: flex;
+      gap: 6px;
+      padding: 4px;
+
+      .icon {
+        padding-top: 2px;
+      }
+    }
+    .form-actions {
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      padding-top: 20px;
+    }
+  }
+
+  .confirm-email-alert {
+    margin-bottom: 12px;
+  }
+}

+ 1 - 0
services/web/frontend/stylesheets/components/interstitial.less

@@ -9,6 +9,7 @@
   .logo {
     width: 130px;
     margin: 0 auto;
+    margin-bottom: 24px;
   }
 
   .btn {

+ 3 - 0
services/web/frontend/stylesheets/main-style.less

@@ -136,6 +136,9 @@
 @import 'app/admin-hub.less';
 @import 'app/import.less';
 @import 'app/website-redesign.less';
+@import 'app/add-secondary-email-prompt.less';
+@import 'app/confirm-email.less';
+
 // Pages
 @import 'app/about.less';
 @import 'app/blog-posts.less';

+ 0 - 22
services/web/frontend/stylesheets/modules/overleaf-integration.less

@@ -1,25 +1,3 @@
-.onboarding-confirm-email {
-  gap: 24px;
-
-  form {
-    .text-danger {
-      display: flex;
-      gap: 6px;
-      padding: 4px;
-
-      .icon {
-        padding-top: 2px;
-      }
-    }
-    .form-actions {
-      display: flex;
-      flex-direction: column;
-      gap: 12px;
-      padding-top: 20px;
-    }
-  }
-}
-
 .onboarding-data-collection-wrapper {
   max-width: 720px;
   margin: 0 auto;

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

@@ -52,6 +52,7 @@
   "activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.",
   "active": "Active",
   "add": "Add",
+  "add_a_recovery_email_address": "Add a recovery email address",
   "add_additional_certificate": "Add another certificate",
   "add_affiliation": "Add Affiliation",
   "add_another_address_line": "Add another address line",
@@ -61,6 +62,7 @@
   "add_comment": "Add comment",
   "add_company_details": "Add Company Details",
   "add_email": "Add Email",
+  "add_email_address": "Add email address",
   "add_email_to_claim_features": "Add an institutional email address to claim your features.",
   "add_files": "Add Files",
   "add_more_collaborators": "Add more collaborators",
@@ -297,7 +299,6 @@
   "confirm_primary_email_change": "Confirm primary email change",
   "confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:",
   "confirm_your_email": "Confirm your email address",
-  "confirmation_code_message": "We’ve sent a 6-digit confirmation code to __email__. Please enter it below to confirm your email address.",
   "confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.",
   "confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
   "confirming": "Confirming",
@@ -499,6 +500,7 @@
   "email_does_not_belong_to_university": "We don’t recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
   "email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.",
   "email_link_expired": "Email link expired, please request a new one.",
+  "email_must_be_linked_to_institution": "As a member of __institutionName__, this email address can only be added via single sign-on on your <0>account settings</0> page. Please add a different recovery email address.",
   "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.",
   "email_or_password_wrong_try_again_or_reset": "Your email or password is incorrect. Please try again, or <0>set or reset your password</0>.",
   "email_required": "Email required",
@@ -518,6 +520,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_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.",
   "enter_your_email_address": "Enter your email address",
   "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
   "enter_your_new_password": "Enter your new password",
@@ -930,6 +933,7 @@
   "keep_current_plan": "Keep my current plan",
   "keep_personal_projects_separate": "Keep personal projects separate",
   "keep_your_account_safe": "Keep your account safe",
+  "keep_your_account_safe_add_another_email": "Keep your account safe and make sure you don’t lose access to it by adding another email address.",
   "keep_your_email_updated": "Keep your email updated so that you don’t lose access to your account and data.",
   "keybindings": "Keybindings",
   "knowledge_base": "knowledge base",
@@ -971,6 +975,7 @@
   "ldap_create_admin_instructions": "Choose an email address for the first __appName__ admin account. This should correspond to an account in the LDAP system. You will then be asked to log in with this account.",
   "learn": "Learn",
   "learn_more": "Learn more",
+  "learn_more_about_account": "<0>Learn more</0> about managing your __appName__ account.",
   "learn_more_about_emails": "<0>Learn more</0> about managing your __appName__ emails.",
   "learn_more_about_link_sharing": "Learn more about Link Sharing",
   "learn_more_about_managed_users": "Learn more about Managed Users.",
@@ -1871,6 +1876,7 @@
   "thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our Beta Program, where you can have <0>early access to new features</0> and help us understand your needs better",
   "thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features</0> and help us explore innovative ideas that help you work more quickly and effectively",
   "thanks": "Thanks",
+  "thanks_for_confirming_your_email_address": "Thanks for confirming your email address",
   "thanks_for_subscribing": "Thanks for subscribing!",
   "thanks_for_subscribing_you_help_sl": "Thank you for subscribing to the __planName__ plan. It’s support from people like yourself that allows __appName__ to continue to grow and improve.",
   "thanks_settings_updated": "Thanks, your settings have been updated.",

+ 0 - 1
services/web/locales/zh-CN.json

@@ -297,7 +297,6 @@
   "confirm_primary_email_change": "确认主电子邮件更改",
   "confirm_remove_sso_config_enter_email": "要确认您要删除 SSO 配置,请输入您的电子邮件地址:",
   "confirm_your_email": "确认您的电子邮件地址",
-  "confirmation_code_message": "我们已向__email__发送了一个6位数的验证码。请在下面输入以确认您的电子邮件地址。",
   "confirmation_link_broken": "抱歉,您的确认链接有问题。请尝试复制并粘贴邮件底部的链接。",
   "confirmation_token_invalid": "抱歉,您的确认令牌无效或已过期。请请求新的电子邮件确认链接。",
   "confirming": "确认",

+ 3 - 4
services/web/test/acceptance/src/AddSecondaryEmailTests.js

@@ -18,7 +18,7 @@ describe('Add secondary email address confirmation code email', function () {
   }
 
   beforeEach(async function () {
-    if (!Features.hasFeature('affiliations')) {
+    if (!Features.hasFeature('saas')) {
       this.skip()
     }
 
@@ -39,16 +39,15 @@ describe('Add secondary email address confirmation code email', function () {
   })
 
   afterEach(function () {
-    if (!Features.hasFeature('affiliations')) {
+    if (!Features.hasFeature('saas')) {
       this.skip()
     }
 
     spy.restore()
   })
 
-  it('should redirect to confirm secondary email page', function () {
+  it('should send email with confirmation code', function () {
     expect(res.response.statusCode).to.equal(200)
-    expect(res.body.redir).to.equal('/user/emails/confirm-secondary')
     expect(confirmCode.length).to.equal(6)
   })
 

+ 107 - 9
services/web/test/acceptance/src/PrimaryEmailCheckTests.js

@@ -2,9 +2,16 @@ const UserHelper = require('./helpers/UserHelper')
 const Settings = require('@overleaf/settings')
 const { expect } = require('chai')
 const Features = require('../../../app/src/infrastructure/Features')
+const MockV1ApiClass = require('./mocks/MockV1Api')
+const Subscription = require('./helpers/Subscription').promises
 
 describe('PrimaryEmailCheck', function () {
   let userHelper
+  let MockV1Api
+
+  before(function () {
+    MockV1Api = MockV1ApiClass.instance()
+  })
 
   beforeEach(async function () {
     userHelper = await UserHelper.createUser()
@@ -200,18 +207,109 @@ describe('PrimaryEmailCheck', function () {
       await UserHelper.updateUser(userHelper.user._id, {
         $set: { lastPrimaryEmailCheck: new Date(time) },
       })
+    })
 
-      checkResponse = await userHelper.fetch(
-        '/user/emails/primary-email-check',
-        { method: 'POST' }
-      )
+    describe('when the user has a secondary email address', function () {
+      before(async function () {
+        if (!Features.hasFeature('saas')) {
+          this.skip()
+        }
+      })
+
+      beforeEach(async function () {
+        await userHelper.confirmEmail(
+          userHelper.user._id,
+          userHelper.user.email
+        )
+        await userHelper.addEmailAndConfirm(
+          userHelper.user._id,
+          'secondary@overleaf.com'
+        )
+
+        checkResponse = await userHelper.fetch(
+          '/user/emails/primary-email-check',
+          { method: 'POST' }
+        )
+      })
+
+      it('should be redirected to the project list page', function () {
+        expect(checkResponse.status).to.equal(302)
+        expect(checkResponse.headers.get('location')).to.equal(
+          UserHelper.url('/project').toString()
+        )
+      })
     })
 
-    it('should be redirected to the project list page', function () {
-      expect(checkResponse.status).to.equal(302)
-      expect(checkResponse.headers.get('location')).to.equal(
-        UserHelper.url('/project').toString()
-      )
+    describe('when the user has an institutional email and no secondary', function () {
+      before(async function () {
+        if (!Features.hasFeature('saas')) {
+          this.skip()
+        }
+
+        if (!Features.hasFeature('saml')) {
+          this.skip()
+        }
+      })
+
+      beforeEach(async function () {
+        MockV1Api.createInstitution({
+          name: 'Exampe Institution',
+          hostname: 'example.com',
+          licence: 'pro_plus',
+          confirmed: true,
+        })
+        MockV1Api.addAffiliation(userHelper.user._id, userHelper.user.email)
+      })
+
+      it('should be redirected to the add secondary email page', async function () {
+        const response = await userHelper.fetch(
+          '/user/emails/primary-email-check',
+          { method: 'POST' }
+        )
+        expect(response.status).to.equal(302)
+        expect(response.headers.get('location')).to.equal(
+          UserHelper.url('/user/emails/add-secondary').toString()
+        )
+      })
+    })
+
+    describe('when the user is a managed user', function () {
+      beforeEach(async function () {
+        const adminUser = await UserHelper.createUser()
+        this.subscription = new Subscription({
+          adminId: adminUser._id,
+          memberIds: [userHelper.user._id],
+          groupPlan: true,
+          planCode: 'group_professional_5_enterprise',
+        })
+        await this.subscription.ensureExists()
+        await this.subscription.enableManagedUsers()
+      })
+
+      it('should be redirected to the project list page', async function () {
+        const response = await userHelper.fetch(
+          '/user/emails/primary-email-check',
+          { method: 'POST' }
+        )
+
+        expect(response.status).to.equal(302)
+        expect(response.headers.get('location')).to.equal(
+          UserHelper.url('/project').toString()
+        )
+      })
+    })
+  })
+
+  describe('when user has checked their primary email address', function () {
+    beforeEach(async function () {
+      const time = Date.now() - Settings.primary_email_check_expiration * 2
+      await UserHelper.updateUser(userHelper.user._id, {
+        $set: { lastPrimaryEmailCheck: new Date(time) },
+      })
+
+      await userHelper.fetch('/user/emails/primary-email-check', {
+        method: 'POST',
+      })
     })
 
     it("shouldn't be redirected from project list to the primary email check page any longer", async function () {

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

@@ -301,8 +301,8 @@ describe('UserEmailsController', function () {
 
     it('sends an email confirmation', function (done) {
       this.UserEmailsController.addWithConfirmationCode(this.req, {
-        json: ({ redir }) => {
-          redir.should.equal('/user/emails/confirm-secondary')
+        sendStatus: code => {
+          code.should.equal(200)
           assertCalledWith(
             this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
             this.newEmail,

+ 39 - 0
services/web/test/unit/src/User/UserGetterTests.js

@@ -1008,6 +1008,45 @@ describe('UserGetter', function () {
     })
   })
 
+  describe('getUserConfirmedEmails', function () {
+    beforeEach(function () {
+      this.fakeUser = {
+        emails: [
+          {
+            email: 'email1@foo.bar',
+            reversedHostname: 'rab.oof',
+            confirmedAt: new Date(),
+          },
+          { email: 'email2@foo.bar', reversedHostname: 'rab.oof' },
+          {
+            email: 'email3@foo.bar',
+            reversedHostname: 'rab.oof',
+            confirmedAt: new Date(),
+          },
+        ],
+      }
+      this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
+    })
+
+    it('should get user', async function () {
+      const projection = { emails: 1 }
+      await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id)
+
+      this.UserGetter.promises.getUser
+        .calledWith(this.fakeUser._id, projection)
+        .should.equal(true)
+    })
+
+    it('should return only confirmed emails', async function () {
+      const confirmedEmails =
+        await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id)
+
+      expect(confirmedEmails.length).to.equal(2)
+      expect(confirmedEmails[0].email).to.equal('email1@foo.bar')
+      expect(confirmedEmails[1].email).to.equal('email3@foo.bar')
+    })
+  })
+
   describe('getUserbyMainEmail', function () {
     it('query user by main email', function (done) {
       const email = 'hello@world.com'