Эх сурвалжийг харах

Show dashboard notification for unconfirmed emails and untrusted secondary emails (#23919)

* Show an aggressive dashboard notification for unconfirmed emails
Show a persistent dashboard notification for untrusted secondary emails

* For emails before the cutoffDate, start displaying the notification on the deletionDate and show the notification for 90 days

* Update the email deletion logic for displaying the email notification and update test

* Update test

GitOrigin-RevId: 1b0e44f79592292d428c634dc1ec4df9e6ceaeb4
Rebeka Dekany 1 жил өмнө
parent
commit
cd133e8240

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

@@ -305,6 +305,7 @@
   "confirm_reject_selected_changes": "",
   "confirm_reject_selected_changes_plural": "",
   "confirm_remove_sso_config_enter_email": "",
+  "confirm_secondary_email": "",
   "confirm_your_email": "",
   "confirming": "",
   "conflicting_paths_found": "",
@@ -488,6 +489,7 @@
   "email_link_expired": "",
   "email_must_be_linked_to_institution": "",
   "email_or_password_wrong_try_again": "",
+  "email_remove_by_date": "",
   "emails_and_affiliations_explanation": "",
   "emails_and_affiliations_title": "",
   "empty": "",
@@ -502,6 +504,7 @@
   "enables_real_time_syntax_checking_in_the_editor": "",
   "enabling": "",
   "end_of_document": "",
+  "ensure_recover_account": "",
   "enter_6_digit_code": "",
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
@@ -1185,7 +1188,8 @@
   "please_check_your_inbox_to_confirm": "",
   "please_compile_pdf_before_download": "",
   "please_compile_pdf_before_word_count": "",
-  "please_confirm_email": "",
+  "please_confirm_primary_email": "",
+  "please_confirm_secondary_email": "",
   "please_confirm_your_email_before_making_it_default": "",
   "please_contact_support_to_makes_change_to_your_plan": "",
   "please_enter_confirmation_code": "",
@@ -1300,6 +1304,7 @@
   "recompile": "",
   "recompile_from_scratch": "",
   "recompile_pdf": "",
+  "reconfirm_secondary_email": "",
   "reconnect": "",
   "reconnecting": "",
   "reconnecting_in_x_secs": "",
@@ -1333,6 +1338,7 @@
   "remove": "",
   "remove_access": "",
   "remove_add_on": "",
+  "remove_email_address": "",
   "remove_from_group": "",
   "remove_link": "",
   "remove_manager": "",

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

@@ -1,6 +1,5 @@
 import { Trans, useTranslation } from 'react-i18next'
 import Notification from '../notification'
-import Icon from '../../../../../shared/components/icon'
 import getMeta from '../../../../../utils/meta'
 import useAsync from '../../../../../shared/hooks/use-async'
 import { useProjectListContext } from '../../../context/project-list-context'
@@ -11,6 +10,7 @@ import {
 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'
 
 const ssoAvailable = ({ samlProviderId, affiliation }: UserEmailData) => {
   const { hasSamlFeature, hasSamlBeta } = getMeta('ol-ExposedSettings')
@@ -69,17 +69,71 @@ function isOnFreeOrIndividualPlan() {
 const showConfirmEmail = (userEmail: UserEmailData) => {
   const { emailConfirmationDisabled } = getMeta('ol-ExposedSettings')
 
-  return (
-    !emailConfirmationDisabled &&
-    !userEmail.confirmedAt &&
-    !ssoAvailable(userEmail)
-  )
+  return !emailConfirmationDisabled && !ssoAvailable(userEmail)
+}
+
+const EMAIL_DELETION_SCHEDULE = {
+  '2025-06-03': '2017-12-31',
+  '2025-07-03': '2019-12-31',
+  '2025-08-03': '2021-12-31',
+  '2025-09-03': '2025-03-03',
+}
+
+// Emails that remain unconfirmed after 90 days will be removed from the account
+function getEmailDeletionDate(emailData: UserEmailData, signUpDate: string) {
+  if (emailData.default) return false
+  if (emailData.confirmedAt) return false
+
+  if (!signUpDate) return false
+
+  const currentDate = new Date()
+
+  for (const [deletionDate, cutoffDate] of Object.entries(
+    EMAIL_DELETION_SCHEDULE
+  )) {
+    const emailSignupDate = new Date(signUpDate)
+    const emailCutoffDate = new Date(cutoffDate)
+    const emailDeletionDate = new Date(deletionDate)
+
+    if (emailSignupDate < emailCutoffDate) {
+      const notificationStartDate = new Date(
+        emailDeletionDate.getTime() - 90 * 24 * 60 * 60 * 1000
+      )
+      if (currentDate >= notificationStartDate) {
+        if (currentDate > emailDeletionDate) {
+          return new Date().toLocaleDateString()
+        }
+        return emailDeletionDate.toLocaleDateString()
+      }
+    }
+  }
+
+  return false
 }
 
-function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
+function ConfirmEmailNotification({
+  userEmail,
+  signUpDate,
+}: {
+  userEmail: UserEmailData
+  signUpDate: string
+}) {
   const { t } = useTranslation()
   const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
 
+  // We consider secondary emails added on or after 22.03.2024 to be trusted for account recovery
+  // https://github.com/overleaf/internal/pull/17572
+  const emailTrustCutoffDate = new Date('2024-03-22')
+  const emailDeletionDate = getEmailDeletionDate(userEmail, signUpDate)
+  const isPrimary = userEmail.default
+
+  const isEmailTrusted =
+    userEmail.lastConfirmedAt &&
+    new Date(userEmail.lastConfirmedAt) >= emailTrustCutoffDate
+
+  const shouldShowCommonsNotification =
+    emailHasLicenceAfterConfirming(userEmail) && isOnFreeOrIndividualPlan()
+
   const handleResendConfirmationEmail = ({ email }: UserEmailData) => {
     runAsync(
       postJSON('/user/emails/resend_confirmation', {
@@ -92,20 +146,120 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
     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>
+          </>
+        }
+      />
+    )
+  }
+
+  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>
+          </>
+        }
+      />
+    )
+  }
+
   // 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 (emailHasLicenceAfterConfirming(userEmail) && isOnFreeOrIndividualPlan()) {
+  if (shouldShowCommonsNotification) {
     return (
       <Notification
         type="info"
         content={
           <div data-testid="notification-body">
             {isLoading ? (
-              <>
-                <Icon type="spinner" spin /> {t('resending_confirmation_email')}
-                &hellip;
-              </>
+              <LoadingSpinner loadingText={t('resending_confirmation_email')} />
             ) : isError ? (
               <div aria-live="polite">{getUserFacingMessage(error)}</div>
             ) : (
@@ -141,43 +295,15 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
     )
   }
 
-  return (
-    <Notification
-      type="warning"
-      content={
-        <div data-testid="pro-notification-body">
-          {isLoading ? (
-            <>
-              <Icon type="spinner" spin /> {t('resending_confirmation_email')}
-              &hellip;
-            </>
-          ) : isError ? (
-            <div aria-live="polite">{getUserFacingMessage(error)}</div>
-          ) : (
-            <>
-              {t('please_confirm_email', {
-                emailAddress: userEmail.email,
-              })}{' '}
-              <OLButton
-                variant="link"
-                onClick={() => handleResendConfirmationEmail(userEmail)}
-                className="btn-inline-link"
-              >
-                {t('resend_confirmation_email')}
-              </OLButton>
-            </>
-          )}
-        </div>
-      }
-    />
-  )
+  return null
 }
 
 function ConfirmEmail() {
   const { totalProjectsCount } = useProjectListContext()
   const userEmails = getMeta('ol-userEmails') || []
+  const signUpDate = getMeta('ol-user')?.signUpDate
 
-  if (!totalProjectsCount || !userEmails.length) {
+  if (!totalProjectsCount || !userEmails.length || !signUpDate) {
     return null
   }
 
@@ -188,6 +314,7 @@ function ConfirmEmail() {
           <ConfirmEmailNotification
             key={`confirm-email-${userEmail.email}`}
             userEmail={userEmail}
+            signUpDate={signUpDate}
           />
         ) : null
       })}
@@ -196,3 +323,4 @@ function ConfirmEmail() {
 }
 
 export default ConfirmEmail
+export { getEmailDeletionDate }

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

@@ -394,6 +394,7 @@
   "confirm_reject_selected_changes": "Are you sure you want to reject the selected change?",
   "confirm_reject_selected_changes_plural": "Are you sure you want to reject the selected __count__ changes?",
   "confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:",
+  "confirm_secondary_email": "Confirm secondary email",
   "confirm_your_email": "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.",
@@ -629,6 +630,7 @@
   "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_remove_by_date": "If this is not done by __date__, it will be removed from the account.",
   "email_required": "Email required",
   "email_sent": "Email Sent",
   "emails": "Emails",
@@ -648,6 +650,7 @@
   "enables_real_time_syntax_checking_in_the_editor": "Enables real-time syntax checking in the editor",
   "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",
@@ -1585,6 +1588,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_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.",
@@ -1719,6 +1724,7 @@
   "reconfirm": "reconfirm",
   "reconfirm_account": "Reconfirm account",
   "reconfirm_explained": "We need to reconfirm your account. Please request a password reset link via the form below to reconfirm your account. If you have any problems reconfirming your account, please contact us at",
+  "reconfirm_secondary_email": "To enhance the security of your __appName__ account, please reconfirm your secondary email address __emailAddress__.",
   "reconnect": "Try again",
   "reconnecting": "Reconnecting",
   "reconnecting_in_x_secs": "Reconnecting in __seconds__ secs",
@@ -1764,6 +1770,7 @@
   "remove": "Remove",
   "remove_access": "Remove access",
   "remove_add_on": "Remove add-on",
+  "remove_email_address": "Remove email address",
   "remove_from_group": "Remove from group",
   "remove_link": "Remove link",
   "remove_manager": "Remove manager",

+ 133 - 14
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -12,7 +12,9 @@ import { merge, cloneDeep } from 'lodash'
 import {
   professionalUserData,
   unconfirmedUserData,
+  untrustedUserData,
   unconfirmedCommonsUserData,
+  confirmedUserData,
 } from '../../settings/fixtures/test-user-email-data'
 import {
   notificationDropboxDuplicateProjectNames,
@@ -24,7 +26,9 @@ import {
 } from '../fixtures/notifications-data'
 import Common from '../../../../../frontend/js/features/project-list/components/notifications/groups/common'
 import Institution from '../../../../../frontend/js/features/project-list/components/notifications/groups/institution'
-import ConfirmEmail from '../../../../../frontend/js/features/project-list/components/notifications/groups/confirm-email'
+import ConfirmEmail, {
+  getEmailDeletionDate,
+} from '../../../../../frontend/js/features/project-list/components/notifications/groups/confirm-email'
 import ReconfirmationInfo from '../../../../../frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirmation-info'
 import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
 import { SplitTestProvider } from '@/shared/context/split-test-context'
@@ -597,40 +601,142 @@ describe('<UserNotifications />', function () {
     })
   })
 
+  describe('getEmailDeletionDate', function () {
+    beforeEach(async function () {
+      window.metaAttributesCache.set('ol-userEmails', [
+        confirmedUserData,
+        untrustedUserData,
+      ])
+      this.clock = sinon.useFakeTimers(new Date('2025-07-01').getTime())
+    })
+
+    afterEach(function () {
+      this.clock.restore()
+    })
+
+    it('returns deletion date for unconfirmed email within notification window', function () {
+      window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+      const signUpDate = '2022-01-01' // Before cutoff '2025-03-03'
+      const emailDeletionDate = getEmailDeletionDate(
+        unconfirmedUserData,
+        signUpDate
+      )
+      expect(emailDeletionDate).to.equal(
+        new Date('2025-09-03').toLocaleDateString()
+      )
+    })
+
+    it('returns false for primary email', function () {
+      const primaryUserData = { ...unconfirmedUserData, default: true }
+      const signUpDate = '2022-01-01'
+      const emailDeletionDate = getEmailDeletionDate(
+        primaryUserData,
+        signUpDate
+      )
+      expect(emailDeletionDate).to.be.false
+    })
+
+    it('returns false for already confirmed email', function () {
+      window.metaAttributesCache.set('ol-userEmails', [confirmedUserData])
+      const signUpDate = '2022-01-01'
+      const emailDeletionDate = getEmailDeletionDate(
+        confirmedUserData,
+        signUpDate
+      )
+      expect(emailDeletionDate).to.be.false
+    })
+  })
+
   describe('<ConfirmEmail/>', function () {
     beforeEach(async function () {
       Object.assign(getMeta('ol-ExposedSettings'), {
         emailConfirmationDisabled: false,
       })
-      window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+      window.metaAttributesCache.set('ol-userEmails', [
+        confirmedUserData,
+        untrustedUserData,
+      ])
       window.metaAttributesCache.set(
         'ol-usersBestSubscription',
         freeSubscription
       )
+      window.metaAttributesCache.set('ol-user', {
+        signUpDate: new Date('2024-01-01').toISOString(),
+      })
+      this.clock = sinon.useFakeTimers(new Date('2025-07-01').getTime())
     })
 
     afterEach(function () {
       fetchMock.reset()
+      this.clock.restore()
     })
 
-    it('sends successfully', async function () {
+    function testUnconfirmedNotification(
+      userEmails: any[],
+      isPrimary: boolean
+    ) {
+      it(`sends unconfirmed notification email successfully when email is ${isPrimary ? 'primary' : 'secondary'}`, async function () {
+        window.metaAttributesCache.set('ol-userEmails', userEmails)
+
+        renderWithinProjectListProvider(ConfirmEmail)
+        await fetchMock.flush(true)
+        fetchMock.post('/user/emails/resend_confirmation', 200)
+
+        const email = userEmails[0].email
+        const notificationBody = screen.getByTestId('pro-notification-body')
+
+        if (isPrimary) {
+          expect(notificationBody.textContent).to.contain(
+            `Please confirm your primary email address ${email} by clicking on the link in the confirmation email.`
+          )
+        } else {
+          expect(notificationBody.textContent).to.contain(
+            `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email.`
+          )
+        }
+
+        const resendButton = screen.getByRole('button', { name: /resend/i })
+        fireEvent.click(resendButton)
+
+        await waitForElementToBeRemoved(() =>
+          screen.getByRole('button', { name: /resend/i })
+        )
+
+        expect(fetchMock.called()).to.be.true
+        expect(screen.queryByRole('alert')).to.be.null
+      })
+    }
+
+    testUnconfirmedNotification(
+      [{ email: 'baz@overleaf.com', default: true }],
+      true
+    )
+
+    testUnconfirmedNotification(
+      [{ email: 'baz@overleaf.com', default: false }],
+      false
+    )
+
+    it('sends untrusted notification email successfully', async function () {
+      window.metaAttributesCache.set('ol-userEmails', [untrustedUserData])
+
       renderWithinProjectListProvider(ConfirmEmail)
       await fetchMock.flush(true)
       fetchMock.post('/user/emails/resend_confirmation', 200)
 
-      const email = unconfirmedUserData.email
-      const notificationBody = screen.getByTestId('pro-notification-body')
+      const email = untrustedUserData.email
+      const notificationBody = screen.getByTestId(
+        'not-trusted-notification-body'
+      )
       expect(notificationBody.textContent).to.contain(
-        `Please confirm your email ${email} by clicking on the link in the confirmation email`
+        `To enhance the security of your Overleaf account, please reconfirm your secondary email address ${email}.`
       )
 
       const resendButton = screen.getByRole('button', { name: /resend/i })
       fireEvent.click(resendButton)
 
-      expect(screen.queryByRole('button', { name: /resend/i })).to.be.null
-
       await waitForElementToBeRemoved(() =>
-        screen.getByText(/resending confirmation email/i)
+        screen.getByRole('button', { name: /resend/i })
       )
 
       expect(fetchMock.called()).to.be.true
@@ -638,15 +744,21 @@ describe('<UserNotifications />', function () {
     })
 
     it('fails to send', async function () {
+      window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+
       renderWithinProjectListProvider(ConfirmEmail)
       await fetchMock.flush(true)
       fetchMock.post('/user/emails/resend_confirmation', 500)
 
-      const resendButton = screen.getByRole('button', { name: /resend/i })
+      const resendButtons = screen.getAllByRole('button', { name: /resend/i })
+      const resendButton = resendButtons[0]
       fireEvent.click(resendButton)
+      const notificationBody = screen.getByTestId('pro-notification-body')
 
       await waitForElementToBeRemoved(() =>
-        screen.getByText(/resending confirmation email/i)
+        within(notificationBody).getByTestId(
+          'loading-resending-confirmation-email'
+        )
       )
 
       expect(fetchMock.called()).to.be.true
@@ -689,9 +801,16 @@ describe('<UserNotifications />', function () {
         const notificationBody = within(alert).getByTestId(
           'pro-notification-body'
         )
-        expect(notificationBody.textContent).to.contain(
-          `Please confirm your email ${email} by clicking on the link in the confirmation email`
-        )
+        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`
+          )
+        } else {
+          expect(notificationBody.textContent).to.contain(
+            `Please confirm your secondary email address ${email} by clicking on the link in the confirmation email`
+          )
+        }
       })
     }
   })

+ 1 - 9
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -92,7 +92,7 @@ describe('<ProjectListRoot />', function () {
     it('the email confirmation alert is not displayed', async function () {
       expect(
         screen.queryByText(
-          'Please confirm your email test@overleaf.com by clicking on the link in the confirmation email'
+          'Please confirm your primary email address test@overleaf.com by clicking on the link in the confirmation email.'
         )
       ).to.be.null
     })
@@ -1141,13 +1141,5 @@ describe('<ProjectListRoot />', function () {
         await screen.findByText(copiedProjectName)
       })
     })
-
-    describe('notifications', function () {
-      it('email confirmation alert is displayed', async function () {
-        screen.getByText(
-          'Please confirm your email test@overleaf.com by clicking on the link in the confirmation email'
-        )
-      })
-    })
   })
 })

+ 2 - 1
services/web/test/frontend/features/settings/context/user-email-context.test.tsx

@@ -13,6 +13,7 @@ import {
   unconfirmedUserData,
   fakeUsersData,
   unconfirmedCommonsUserData,
+  untrustedUserData,
 } from '../fixtures/test-user-email-data'
 import localStorage from '@/infrastructure/local-storage'
 
@@ -51,7 +52,7 @@ describe('UserEmailContext', function () {
       await fetchMock.flush(true)
       expect(fetchMock.calls()).to.have.lengthOf(1)
       expect(result.current.state.data.byId).to.deep.equal({
-        'bar@overleaf.com': confirmedUserData,
+        'bar@overleaf.com': { ...untrustedUserData, ...confirmedUserData },
         'baz@overleaf.com': unconfirmedUserData,
         'foo@overleaf.com': professionalUserData,
         'qux@overleaf.com': unconfirmedCommonsUserData,

+ 6 - 0
services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts

@@ -12,6 +12,11 @@ export const unconfirmedUserData: UserEmailData = {
   default: false,
 }
 
+export const untrustedUserData = {
+  ...confirmedUserData,
+  lastConfirmedAt: '2024-01-01T10:59:44.139Z',
+}
+
 export const professionalUserData: UserEmailData & {
   affiliation: Affiliation
 } = {
@@ -112,6 +117,7 @@ export const ssoUserData: UserEmailData = {
 export const fakeUsersData = [
   { ...confirmedUserData },
   { ...unconfirmedUserData },
+  { ...untrustedUserData },
   { ...professionalUserData },
   { ...unconfirmedCommonsUserData },
 ]

+ 1 - 0
services/web/types/user-email.ts

@@ -3,6 +3,7 @@ import { Affiliation } from './affiliation'
 export type UserEmailData = {
   affiliation?: Affiliation
   confirmedAt?: string
+  lastConfirmedAt?: string | null
   email: string
   default: boolean
   samlProviderId?: string