Răsfoiți Sursa

[web] Use 6-digits verification in project-list notifications (bis) (#25847)

* Pull email context outside of `ResendConfirmationCodeModal`

* Use `loading` prop of button instead of deprecated Icon

* Swap notification order to clarify priority (no change in behaviour)

* Replace confirmation link action by confirmationCodeModal, and simplify code

* Change to secondary button variant in the Notification

* Display errors within the modal

* Increase ratelimit for resend-confirmation

* Copy changes

* Add stories on email confirmation notifications

* Fix other Notification stories

* Update tests

* Update services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx

Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com>

* Remove placeholder on 6-digit code input

---------

Co-authored-by: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com>
GitOrigin-RevId: dad8bfd79505a2e7d065fd48791fd57c8a31e071
Antoine Clausse 1 an în urmă
părinte
comite
f7b6246d41

+ 1 - 1
services/web/app/src/router.mjs

@@ -182,7 +182,7 @@ const rateLimiters = {
     duration: 60,
   }),
   sendConfirmation: new RateLimiter('send-confirmation', {
-    points: 1,
+    points: 2,
     duration: 60,
   }),
   sendChatMessage: new RateLimiter('send-chat-message', {

+ 3 - 5
services/web/frontend/extracted-translations.json

@@ -519,7 +519,6 @@
   "enabling": "",
   "end_of_document": "",
   "ensure_recover_account": "",
-  "enter_6_digit_code": "",
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
   "enter_the_code": "",
@@ -1224,8 +1223,8 @@
   "please_check_your_inbox_to_confirm": "",
   "please_compile_pdf_before_download": "",
   "please_compile_pdf_before_word_count": "",
-  "please_confirm_primary_email": "",
-  "please_confirm_secondary_email": "",
+  "please_confirm_primary_email_or_edit": "",
+  "please_confirm_secondary_email_or_edit": "",
   "please_confirm_your_email_before_making_it_default": "",
   "please_contact_support_to_makes_change_to_your_plan": "",
   "please_enter_confirmation_code": "",
@@ -1375,7 +1374,6 @@
   "remote_service_error": "",
   "remove": "",
   "remove_access": "",
-  "remove_email_address": "",
   "remove_from_group": "",
   "remove_link": "",
   "remove_manager": "",
@@ -1408,7 +1406,6 @@
   "resend_link_sso": "",
   "resend_managed_user_invite": "",
   "resending_confirmation_code": "",
-  "resending_confirmation_email": "",
   "resize": "",
   "resolve_comment": "",
   "resolve_comment_error_message": "",
@@ -1520,6 +1517,7 @@
   "select_user": "",
   "selected": "",
   "selection_deleted": "",
+  "send_confirmation_code": "",
   "send_first_message": "",
   "send_message": "",
   "send_request": "",

+ 93 - 156
services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx

@@ -1,16 +1,10 @@
 import { Trans, useTranslation } from 'react-i18next'
 import Notification from '../notification'
 import getMeta from '../../../../../utils/meta'
-import useAsync from '../../../../../shared/hooks/use-async'
 import { useProjectListContext } from '../../../context/project-list-context'
-import {
-  postJSON,
-  getUserFacingMessage,
-} from '../../../../../infrastructure/fetch-json'
 import { UserEmailData } from '../../../../../../../types/user-email'
-import { debugConsole } from '@/utils/debugging'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import LoadingSpinner from '@/shared/components/loading-spinner'
+import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal'
+import { ReactNode, useState } from 'react'
 
 const ssoAvailable = ({ samlProviderId, affiliation }: UserEmailData) => {
   const { hasSamlFeature, hasSamlBeta } = getMeta('ol-ExposedSettings')
@@ -114,12 +108,17 @@ function getEmailDeletionDate(emailData: UserEmailData, signUpDate: string) {
 function ConfirmEmailNotification({
   userEmail,
   signUpDate,
+  setIsLoading,
+  isLoading,
 }: {
   userEmail: UserEmailData
   signUpDate: string
+  setIsLoading: (loading: boolean) => void
+  isLoading: boolean
 }) {
   const { t } = useTranslation()
-  const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
+  const [isSuccess, setIsSuccess] = useState(false)
+  const emailAddress = userEmail.email
 
   // We consider secondary emails added on or after 22.03.2024 to be trusted for account recovery
   // https://github.com/overleaf/internal/pull/17572
@@ -127,6 +126,7 @@ function ConfirmEmailNotification({
   const emailDeletionDate = getEmailDeletionDate(userEmail, signUpDate)
   const isPrimary = userEmail.default
 
+  const isEmailConfirmed = !!userEmail.lastConfirmedAt
   const isEmailTrusted =
     userEmail.lastConfirmedAt &&
     new Date(userEmail.lastConfirmedAt) >= emailTrustCutoffDate
@@ -134,163 +134,97 @@ function ConfirmEmailNotification({
   const shouldShowCommonsNotification =
     emailHasLicenceAfterConfirming(userEmail) && isOnFreeOrIndividualPlan()
 
-  const handleResendConfirmationEmail = ({ email }: UserEmailData) => {
-    runAsync(
-      postJSON('/user/emails/resend_confirmation', {
-        body: { email },
-      })
-    ).catch(debugConsole.error)
-  }
-
   if (isSuccess) {
     return null
   }
 
-  if (!userEmail.lastConfirmedAt && !shouldShowCommonsNotification) {
-    return (
-      <Notification
-        type="warning"
-        content={
-          <div data-testid="pro-notification-body">
-            {isLoading ? (
-              <div data-testid="loading-resending-confirmation-email">
-                <LoadingSpinner
-                  loadingText={t('resending_confirmation_email')}
-                />
-              </div>
-            ) : isError ? (
-              <div aria-live="polite">{getUserFacingMessage(error)}</div>
-            ) : (
-              <>
-                <p>
-                  {isPrimary
-                    ? t('please_confirm_primary_email', {
-                        emailAddress: userEmail.email,
-                      })
-                    : t('please_confirm_secondary_email', {
-                        emailAddress: userEmail.email,
-                      })}
-                </p>
-                {emailDeletionDate && (
-                  <p>
-                    {t('email_remove_by_date', { date: emailDeletionDate })}
-                  </p>
-                )}
-              </>
-            )}
-          </div>
-        }
-        action={
-          <>
-            <OLButton
-              variant="secondary"
-              onClick={() => handleResendConfirmationEmail(userEmail)}
-            >
-              {t('resend_confirmation_email')}
-            </OLButton>
-            <OLButton variant="link" href="/user/settings">
-              {isPrimary
-                ? t('change_primary_email')
-                : t('remove_email_address')}
-            </OLButton>
-          </>
-        }
-      />
-    )
-  }
+  const confirmationCodeModal = (
+    <ResendConfirmationCodeModal
+      email={emailAddress}
+      onSuccess={() => setIsSuccess(true)}
+      setGroupLoading={setIsLoading}
+      groupLoading={isLoading}
+      triggerVariant="secondary"
+    />
+  )
 
-  if (!isEmailTrusted && !isPrimary && !shouldShowCommonsNotification) {
-    return (
-      <Notification
-        type="warning"
-        content={
-          <div data-testid="not-trusted-notification-body">
-            {isLoading ? (
-              <div data-testid="error-id">
-                <LoadingSpinner
-                  loadingText={t('resending_confirmation_email')}
-                />
-              </div>
-            ) : isError ? (
-              <div aria-live="polite">{getUserFacingMessage(error)}</div>
-            ) : (
-              <>
-                <p>
-                  <b>{t('confirm_secondary_email')}</b>
-                </p>
-                <p>
-                  {t('reconfirm_secondary_email', {
-                    emailAddress: userEmail.email,
-                  })}
-                </p>
-                <p>{t('ensure_recover_account')}</p>
-              </>
-            )}
-          </div>
-        }
-        action={
-          <>
-            <OLButton
-              variant="secondary"
-              onClick={() => handleResendConfirmationEmail(userEmail)}
-            >
-              {t('resend_confirmation_email')}
-            </OLButton>
-            <OLButton
-              variant="link"
-              href="/user/settings"
-              style={{ textDecoration: 'underline' }}
-            >
-              {t('remove_email_address')}
-            </OLButton>
-          </>
-        }
-      />
+  let notificationType: 'info' | 'warning' | undefined
+  let notificationBody: ReactNode | undefined
+
+  if (shouldShowCommonsNotification) {
+    notificationType = 'info'
+    notificationBody = (
+      <>
+        <Trans
+          i18nKey="one_step_away_from_professional_features"
+          components={[<strong />]} // eslint-disable-line react/jsx-key
+        />
+        <br />
+        <Trans
+          i18nKey="institution_has_overleaf_subscription"
+          values={{
+            institutionName: userEmail.affiliation?.institution.name,
+            emailAddress,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[<strong />]} // eslint-disable-line react/jsx-key
+        />
+      </>
+    )
+  } else if (!isEmailConfirmed) {
+    notificationType = 'warning'
+    notificationBody = (
+      <>
+        <p>
+          {isPrimary ? (
+            <Trans
+              i18nKey="please_confirm_primary_email_or_edit"
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+              values={{ emailAddress }}
+              components={[
+                // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                <a href="/user/settings" />,
+              ]}
+            />
+          ) : (
+            <Trans
+              i18nKey="please_confirm_secondary_email_or_edit"
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+              values={{ emailAddress }}
+              components={[
+                // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
+                <a href="/user/settings" />,
+              ]}
+            />
+          )}
+        </p>
+        {emailDeletionDate && (
+          <p>{t('email_remove_by_date', { date: emailDeletionDate })}</p>
+        )}
+      </>
+    )
+  } else if (!isEmailTrusted && !isPrimary) {
+    notificationType = 'warning'
+    notificationBody = (
+      <>
+        <p>
+          <b>{t('confirm_secondary_email')}</b>
+        </p>
+        <p>{t('reconfirm_secondary_email', { emailAddress })}</p>
+        <p>{t('ensure_recover_account')}</p>
+      </>
     )
   }
 
-  // Only show the notification if a) a commons license is available and b) the
-  // user is on a free or individual plan. Users on a group or Commons plan
-  // already have premium features.
-  if (shouldShowCommonsNotification) {
+  if (notificationType) {
     return (
       <Notification
-        type="info"
-        content={
-          <div data-testid="notification-body">
-            {isLoading ? (
-              <LoadingSpinner loadingText={t('resending_confirmation_email')} />
-            ) : isError ? (
-              <div aria-live="polite">{getUserFacingMessage(error)}</div>
-            ) : (
-              <>
-                <Trans
-                  i18nKey="one_step_away_from_professional_features"
-                  components={[<strong />]} // eslint-disable-line react/jsx-key
-                />
-                <br />
-                <Trans
-                  i18nKey="institution_has_overleaf_subscription"
-                  values={{
-                    institutionName: userEmail.affiliation?.institution.name,
-                    emailAddress: userEmail.email,
-                  }}
-                  shouldUnescape
-                  tOptions={{ interpolation: { escapeValue: true } }}
-                  components={[<strong />]} // eslint-disable-line react/jsx-key
-                />
-              </>
-            )}
-          </div>
-        }
-        action={
-          <OLButton
-            variant="secondary"
-            onClick={() => handleResendConfirmationEmail(userEmail)}
-          >
-            {t('resend_email')}
-          </OLButton>
-        }
+        type={notificationType}
+        content={notificationBody}
+        action={confirmationCodeModal}
       />
     )
   }
@@ -302,6 +236,7 @@ function ConfirmEmail() {
   const { totalProjectsCount } = useProjectListContext()
   const userEmails = getMeta('ol-userEmails') || []
   const signUpDate = getMeta('ol-user')?.signUpDate
+  const [isLoading, setIsLoading] = useState(false)
 
   if (!totalProjectsCount || !userEmails.length || !signUpDate) {
     return null
@@ -315,6 +250,8 @@ function ConfirmEmail() {
             key={`confirm-email-${userEmail.email}`}
             userEmail={userEmail}
             signUpDate={signUpDate}
+            isLoading={isLoading}
+            setIsLoading={setIsLoading}
           />
         ) : null
       })}

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

@@ -2,7 +2,7 @@ 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, MouseEventHandler, useState } from 'react'
+import { FormEvent, MouseEventHandler, ReactNode, useState } from 'react'
 import { Trans, useTranslation } from 'react-i18next'
 import LoadingSpinner from '@/shared/components/loading-spinner'
 import MaterialIcon from '@/shared/components/material-icon'
@@ -27,6 +27,7 @@ type ConfirmEmailFormProps = {
   interstitial: boolean
   isModal?: boolean
   onCancel?: () => void
+  outerError?: string
 }
 
 export function ConfirmEmailForm({
@@ -40,15 +41,17 @@ export function ConfirmEmailForm({
   interstitial,
   isModal,
   onCancel,
+  outerError,
 }: 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 [hasResent, setHasResent] = useState(false)
   const [successRedirectPath, setSuccessRedirectPath] = useState('')
   const { isReady } = useWaitForI18n()
-
+  const outerErrorDisplay = (!hasResent && outerError) || null
   const errorHandler = (err: any, actionType?: string) => {
     let errorName = err?.data?.message?.key || 'generic_something_went_wrong'
 
@@ -131,6 +134,7 @@ export function ConfirmEmailForm({
       })
       .finally(() => {
         setIsResending(false)
+        setHasResent(true)
       })
 
     sendMB('email-verification-click', {
@@ -158,8 +162,15 @@ export function ConfirmEmailForm({
     )
   }
 
-  let intro = <h5 className="h5">{t('confirm_your_email')}</h5>
-  if (isModal) intro = <h5 className="h5">{t('we_sent_code')}</h5>
+  let intro: ReactNode | null = (
+    <h5 className="h5">{t('confirm_your_email')}</h5>
+  )
+  if (isModal)
+    intro = outerErrorDisplay ? (
+      <div className="mt-4" />
+    ) : (
+      <h3 className="h5">{outerErrorDisplay ? null : t('we_sent_code')}</h3>
+    )
   if (interstitial)
     intro = (
       <h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
@@ -172,12 +183,14 @@ export function ConfirmEmailForm({
       className="confirm-email-form"
     >
       <div className="confirm-email-form-inner">
-        {feedback?.type === 'alert' && (
+        {(feedback?.type === 'alert' || outerErrorDisplay) && (
           <Notification
             ariaLive="polite"
             className="confirm-email-alert"
-            type={feedback.style}
-            content={<ErrorMessage error={feedback.message} />}
+            type={outerErrorDisplay ? 'error' : feedback!.style}
+            content={
+              outerErrorDisplay || <ErrorMessage error={feedback!.message!} />
+            }
           />
         )}
 
@@ -191,7 +204,6 @@ export function ConfirmEmailForm({
         <input
           id="one-time-code"
           className="form-control"
-          placeholder={t('enter_6_digit_code')}
           inputMode="numeric"
           required
           value={confirmationCode}
@@ -200,6 +212,7 @@ export function ConfirmEmailForm({
           maxLength={6}
           autoComplete="one-time-code"
           autoFocus // eslint-disable-line jsx-a11y/no-autofocus
+          disabled={!!outerErrorDisplay}
         />
         <div aria-live="polite">
           {feedback?.type === 'input' && (
@@ -214,7 +227,7 @@ export function ConfirmEmailForm({
 
         <div className="form-actions">
           <OLButton
-            disabled={isResending}
+            disabled={isResending || !!outerErrorDisplay}
             type="submit"
             isLoading={isConfirming}
             loadingLabel={t('confirming')}

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

@@ -3,6 +3,7 @@ import { UserEmailData } from '../../../../../../types/user-email'
 import { ssoAvailableForInstitution } from '../../utils/sso'
 import OLBadge from '@/features/ui/components/ol/ol-badge'
 import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal'
+import { useUserEmailsContext } from '@/features/settings/context/user-email-context'
 
 type EmailProps = {
   userEmailData: UserEmailData
@@ -10,7 +11,11 @@ type EmailProps = {
 
 function Email({ userEmailData }: EmailProps) {
   const { t } = useTranslation()
-
+  const {
+    state,
+    setLoading: setUserEmailsContextLoading,
+    getEmails,
+  } = useUserEmailsContext()
   const ssoAvailable = ssoAvailableForInstitution(
     userEmailData.affiliation?.institution || null
   )
@@ -30,7 +35,13 @@ function Email({ userEmailData }: EmailProps) {
           <strong>{t('unconfirmed')}.</strong>
           <br />
           {!ssoAvailable && (
-            <ResendConfirmationCodeModal email={userEmailData.email} />
+            <ResendConfirmationCodeModal
+              email={userEmailData.email}
+              setGroupLoading={setUserEmailsContextLoading}
+              groupLoading={state.isLoading}
+              onSuccess={getEmails}
+              triggerVariant="link"
+            />
           )}
         </div>
       )}

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

@@ -1,10 +1,8 @@
 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,
@@ -16,39 +14,33 @@ import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-
 
 type ResendConfirmationEmailButtonProps = {
   email: UserEmailData['email']
+  groupLoading: boolean
+  setGroupLoading: (loading: boolean) => void
+  onSuccess: () => void
+  triggerVariant: 'link' | 'secondary'
 }
 
 function ResendConfirmationCodeModal({
   email,
+  groupLoading,
+  setGroupLoading,
+  onSuccess,
+  triggerVariant,
 }: 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])
+    setGroupLoading(isLoading)
+  }, [isLoading, setGroupLoading])
 
   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;
-      </>
-    )
+      .finally(() => setModalVisible(true))
   }
 
   const rateLimited =
@@ -77,9 +69,16 @@ function ResendConfirmationCodeModal({
               confirmationEndpoint="/user/emails/confirm-code"
               email={email}
               onSuccessfulConfirmation={() => {
-                getEmails()
+                onSuccess()
                 setModalVisible(false)
               }}
+              outerError={
+                isError
+                  ? rateLimited
+                    ? t('too_many_requests')
+                    : t('generic_something_went_wrong')
+                  : undefined
+              }
             />
           </OLModalBody>
           <OLModalFooter>
@@ -94,21 +93,14 @@ function ResendConfirmationCodeModal({
         </OLModal>
       )}
       <OLButton
-        variant="link"
-        disabled={state.isLoading || isLoading}
+        variant={triggerVariant}
+        disabled={groupLoading}
+        isLoading={isLoading}
         onClick={handleResendConfirmationEmail}
-        className="btn-inline-link"
+        className={triggerVariant === 'link' ? 'btn-inline-link' : undefined}
       >
-        {t('resend_confirmation_code')}
+        {t('send_confirmation_code')}
       </OLButton>
-      <br />
-      {isError && (
-        <div className="text-danger">
-          {rateLimited
-            ? t('too_many_requests')
-            : t('generic_something_went_wrong')}
-        </div>
-      )}
     </>
   )
 }

+ 1 - 0
services/web/frontend/stories/hooks/use-fetch-mock.tsx

@@ -10,6 +10,7 @@ export default function useFetchMock(
   fetchMock.mockGlobal()
 
   useLayoutEffect(() => {
+    fetchMock.mockGlobal()
     callback(fetchMock)
     return () => {
       fetchMock.removeRoutes()

+ 9 - 0
services/web/frontend/stories/project-list/notifications.stories.tsx

@@ -14,6 +14,8 @@ import {
   setReconfirmationMeta,
 } from './helpers/emails'
 import { useMeta } from '../hooks/use-meta'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+import React, { ComponentType } from 'react'
 
 export const ProjectInvite = (args: any) => {
   useFetchMock(commonSetupMocks)
@@ -343,4 +345,11 @@ export const ReconfirmedAffiliationSuccess = (args: any) => {
 export default {
   title: 'Project List / Notifications',
   component: UserNotifications,
+  decorators: [
+    (Story: ComponentType) => (
+      <SplitTestProvider>
+        <Story />
+      </SplitTestProvider>
+    ),
+  ],
 }

+ 66 - 0
services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx

@@ -0,0 +1,66 @@
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+import UserNotifications from '../../../js/features/project-list/components/notifications/user-notifications'
+import { ProjectListProvider } from '../../../js/features/project-list/context/project-list-context'
+import { useMeta } from '../../hooks/use-meta'
+import useFetchMock from '../../hooks/use-fetch-mock'
+import ConfirmEmailNotification from '@/features/project-list/components/notifications/groups/confirm-email'
+
+export const ConfirmEmail = (args: any) => {
+  useMeta({
+    'ol-userEmails': [
+      {
+        email: 'erika.mustermann+unconfirmed-primary@example.com',
+        default: true,
+      },
+      { email: 'erika.mustermann+unconfirmed@example.com' },
+      {
+        email: 'erika.mustermann+untrusted@example.com',
+        lastConfirmedAt: '2019-01-01',
+        confirmedAt: '2019-01-01',
+      },
+      {
+        email: 'erika.mustermann+mit@example.com',
+        affiliation: {
+          institution: {
+            id: 123,
+            name: 'Massachusetts Institute of Technology',
+            confirmed: true,
+            commonsAccount: true,
+          },
+        },
+      },
+    ],
+    'ol-user': { signUpDate: '2021-01-01' },
+    'ol-usersBestSubscription': { type: 'free' },
+    'ol-prefetchedProjectsBlob': { totalSize: 20 },
+  })
+  useFetchMock(fetchMock => {
+    fetchMock.post('/user/emails/send-confirmation-code', args.statusCode, {
+      delay: 500,
+    })
+    fetchMock.post('/user/emails/confirm-code', args.statusCode, {
+      delay: 500,
+    })
+    fetchMock.post('/user/emails/resend-confirmation-code', args.statusCode, {
+      delay: 500,
+    })
+  })
+  return (
+    <SplitTestProvider>
+      <ProjectListProvider>
+        <UserNotifications {...args} />
+      </ProjectListProvider>
+    </SplitTestProvider>
+  )
+}
+
+export default {
+  title: 'Project List / Notifications',
+  component: ConfirmEmailNotification,
+  args: {
+    statusCode: 200,
+  },
+  argTypes: {
+    statusCode: { type: 'select', options: [200, 400, 429] },
+  },
+}

+ 3 - 5
services/web/locales/en.json

@@ -669,7 +669,6 @@
   "enabling": "Enabling",
   "end_of_document": "End of document",
   "ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
-  "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__.",
@@ -1627,8 +1626,8 @@
   "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",
   "please_confirm_email": "Please confirm your email __emailAddress__ by clicking on the link in the confirmation email ",
-  "please_confirm_primary_email": "Please confirm your primary email address __emailAddress__ by clicking on the link in the confirmation email.",
-  "please_confirm_secondary_email": "Please confirm your secondary email address __emailAddress__ by clicking on the link in the confirmation email.",
+  "please_confirm_primary_email_or_edit": "Please confirm your primary email address __emailAddress__. To edit it, go to <0>Account settings</0>.",
+  "please_confirm_secondary_email_or_edit": "Please confirm your secondary email address __emailAddress__. To edit it, go to <0>Account settings</0>.",
   "please_confirm_your_email_before_making_it_default": "Please confirm your email before making it the primary.",
   "please_contact_support_to_makes_change_to_your_plan": "Please <0>contact Support</0> to make changes to your plan",
   "please_contact_us_if_you_think_this_is_in_error": "Please <0>contact us</0> if you think this is in error.",
@@ -1810,7 +1809,6 @@
   "remote_service_error": "The remote service produced an error",
   "remove": "Remove",
   "remove_access": "Remove access",
-  "remove_email_address": "Remove email address",
   "remove_from_group": "Remove from group",
   "remove_link": "Remove link",
   "remove_manager": "Remove manager",
@@ -1853,7 +1851,6 @@
   "resend_link_sso": "Resend SSO invite",
   "resend_managed_user_invite": "Resend managed user invite",
   "resending_confirmation_code": "Resending confirmation code",
-  "resending_confirmation_email": "Resending confirmation email",
   "reset_password": "Reset Password",
   "reset_password_link": "Click this link to reset your password",
   "reset_password_sentence_case": "Reset password",
@@ -1987,6 +1984,7 @@
   "selected_by_overleaf_staff": "Selected by Overleaf staff",
   "selection_deleted": "Selection deleted",
   "send": "Send",
+  "send_confirmation_code": "Send confirmation code",
   "send_first_message": "Send your first message to your collaborators",
   "send_message": "Send message",
   "send_request": "Send request",

+ 35 - 43
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -5,7 +5,6 @@ import {
   render,
   screen,
   waitForElementToBeRemoved,
-  within,
 } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import { merge, cloneDeep } from 'lodash'
@@ -672,32 +671,37 @@ describe('<UserNotifications />', function () {
 
         renderWithinProjectListProvider(ConfirmEmail)
         await fetchMock.callHistory.flush(true)
-        fetchMock.post('/user/emails/resend_confirmation', 200)
+        fetchMock.post('/user/emails/send-confirmation-code', 200)
 
         const email = userEmails[0].email
-        const notificationBody = await screen.findByTestId(
-          'pro-notification-body'
-        )
+        const alert = await screen.findByRole('alert')
 
         if (isPrimary) {
-          expect(notificationBody.textContent).to.contain(
-            `Please confirm your primary email address ${email} by clicking on the link in the confirmation email.`
+          expect(alert.textContent).to.contain(
+            `Please confirm your primary email address ${email}. To edit it, go to `
           )
         } else {
-          expect(notificationBody.textContent).to.contain(
-            `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email.`
+          expect(alert.textContent).to.contain(
+            `Please confirm your secondary email address ${email}. To edit it, go to `
           )
         }
 
-        const resendButton = screen.getByRole('button', { name: /resend/i })
-        fireEvent.click(resendButton)
+        expect(
+          screen
+            .getByRole('button', { name: 'Send confirmation code' })
+            .classList.contains('button-loading')
+        ).to.be.false
 
-        await waitForElementToBeRemoved(() =>
-          screen.queryByRole('button', { name: /resend/i })
-        )
+        expect(screen.queryByRole('dialog')).to.be.null
+
+        const sendCodeButton = await screen.findByRole('button', {
+          name: 'Send confirmation code',
+        })
+        fireEvent.click(sendCodeButton)
+
+        await screen.findByRole('dialog')
 
         expect(fetchMock.callHistory.called()).to.be.true
-        expect(screen.queryByRole('alert')).to.be.null
       })
     }
 
@@ -716,25 +720,22 @@ describe('<UserNotifications />', function () {
 
       renderWithinProjectListProvider(ConfirmEmail)
       await fetchMock.callHistory.flush(true)
-      fetchMock.post('/user/emails/resend_confirmation', 200)
+      fetchMock.post('/user/emails/send-confirmation-code', 200)
 
       const email = untrustedUserData.email
-      const notificationBody = await screen.findByTestId(
-        'not-trusted-notification-body'
-      )
-      expect(notificationBody.textContent).to.contain(
+      const alert = await screen.findByRole('alert')
+      expect(alert.textContent).to.contain(
         `To enhance the security of your Overleaf account, please reconfirm your secondary email address ${email}.`
       )
 
-      const resendButton = screen.getByRole('button', { name: /resend/i })
+      const resendButton = screen.getByRole('button', {
+        name: 'Send confirmation code',
+      })
       fireEvent.click(resendButton)
 
-      await waitForElementToBeRemoved(() =>
-        screen.getByRole('button', { name: /resend/i })
-      )
+      await screen.findByRole('dialog')
 
       expect(fetchMock.callHistory.called()).to.be.true
-      expect(screen.queryByRole('alert')).to.be.null
     })
 
     it('fails to send', async function () {
@@ -742,20 +743,15 @@ describe('<UserNotifications />', function () {
 
       renderWithinProjectListProvider(ConfirmEmail)
       await fetchMock.callHistory.flush(true)
-      fetchMock.post('/user/emails/resend_confirmation', 500)
+      fetchMock.post('/user/emails/send-confirmation-code', 500)
 
       const resendButtons = await screen.findAllByRole('button', {
-        name: /resend/i,
+        name: 'Send confirmation code',
       })
       const resendButton = resendButtons[0]
       fireEvent.click(resendButton)
-      const notificationBody = screen.getByTestId('pro-notification-body')
 
-      await waitForElementToBeRemoved(() =>
-        within(notificationBody).getByTestId(
-          'loading-resending-confirmation-email'
-        )
-      )
+      await screen.findByRole('dialog')
 
       expect(fetchMock.callHistory.called()).to.be.true
       screen.getByText(/something went wrong/i)
@@ -773,11 +769,10 @@ describe('<UserNotifications />', function () {
 
         const alert = await screen.findByRole('alert')
         const email = unconfirmedCommonsUserData.email
-        const notificationBody = within(alert).getByTestId('notification-body')
-        expect(notificationBody.textContent).to.contain(
+        expect(alert.textContent).to.contain(
           'You are one step away from accessing Overleaf Professional features'
         )
-        expect(notificationBody.textContent).to.contain(
+        expect(alert.textContent).to.contain(
           `Overleaf has an Overleaf subscription. Click the confirmation link sent to ${email} to upgrade to Overleaf Professional`
         )
       })
@@ -794,17 +789,14 @@ describe('<UserNotifications />', function () {
 
         const alert = await screen.findByRole('alert')
         const email = unconfirmedCommonsUserData.email
-        const notificationBody = within(alert).getByTestId(
-          'pro-notification-body'
-        )
         const isPrimary = unconfirmedCommonsUserData.default
         if (isPrimary) {
-          expect(notificationBody.textContent).to.contain(
-            `Please confirm your primary email address ${email} by clicking on the link in the confirmation email`
+          expect(alert.textContent).to.contain(
+            `Please confirm your primary email address ${email}.`
           )
         } else {
-          expect(notificationBody.textContent).to.contain(
-            `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email`
+          expect(alert.textContent).to.contain(
+            `Please confirm your secondary email address ${email}.`
           )
         }
       })

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

@@ -99,7 +99,7 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
     render(<EmailsSection />)
 
-    await screen.findByRole('button', { name: /resend confirmation code/i })
+    await screen.findByRole('button', { name: 'Send confirmation code' })
   })
 
   it('renders professional label', async function () {
@@ -121,24 +121,24 @@ describe('<EmailsSection />', function () {
     fetchMock.post('/user/emails/send-confirmation-code', 200)
 
     const button = screen.getByRole('button', {
-      name: /resend confirmation code/i,
+      name: 'Send confirmation code',
     })
     fireEvent.click(button)
 
     expect(
       screen.queryByRole('button', {
-        name: /resend confirmation code/i,
+        name: 'Send confirmation code',
       })
     ).to.be.null
 
-    await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
+    await screen.findByRole('dialog')
 
     expect(
       screen.queryByText(/an error has occurred while performing your request/i)
     ).to.be.null
 
     await screen.findAllByRole('button', {
-      name: /resend confirmation code/i,
+      name: 'Resend confirmation code',
     })
   })
 
@@ -151,17 +151,17 @@ describe('<EmailsSection />', function () {
     fetchMock.post('/user/emails/send-confirmation-code', 503)
 
     const button = screen.getByRole('button', {
-      name: /resend confirmation code/i,
+      name: 'Send confirmation code',
     })
     fireEvent.click(button)
 
-    expect(screen.queryByRole('button', { name: /resend confirmation code/i }))
-      .to.be.null
+    expect(screen.queryByRole('button', { name: 'Send confirmation code' })).to
+      .be.null
 
-    await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
+    await screen.findByRole('dialog')
 
-    screen.getByText(/sorry, something went wrong/i)
-    screen.getByRole('button', { name: /resend confirmation code/i })
+    await screen.findByText(/sorry, something went wrong/i)
+    screen.getByRole('button', { name: 'Resend confirmation code' })
   })
 
   it('sorts emails with primary first, then confirmed, then unconfirmed', async function () {