diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 469f3a796c..8d8380cfc1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx index 6a3f2d6e33..ca73d87a0c 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx +++ b/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) } -function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) { +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, + 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 ( + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
{getUserFacingMessage(error)}
+ ) : ( + <> +

+ {isPrimary + ? t('please_confirm_primary_email', { + emailAddress: userEmail.email, + }) + : t('please_confirm_secondary_email', { + emailAddress: userEmail.email, + })} +

+ {emailDeletionDate && ( +

+ {t('email_remove_by_date', { date: emailDeletionDate })} +

+ )} + + )} + + } + action={ + <> + handleResendConfirmationEmail(userEmail)} + > + {t('resend_confirmation_email')} + + + {isPrimary + ? t('change_primary_email') + : t('remove_email_address')} + + + } + /> + ) + } + + if (!isEmailTrusted && !isPrimary && !shouldShowCommonsNotification) { + return ( + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
{getUserFacingMessage(error)}
+ ) : ( + <> +

+ {t('confirm_secondary_email')} +

+

+ {t('reconfirm_secondary_email', { + emailAddress: userEmail.email, + })} +

+

{t('ensure_recover_account')}

+ + )} + + } + action={ + <> + handleResendConfirmationEmail(userEmail)} + > + {t('resend_confirmation_email')} + + + {t('remove_email_address')} + + + } + /> + ) + } + // 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 ( {isLoading ? ( - <> - {t('resending_confirmation_email')} - … - + ) : isError ? (
{getUserFacingMessage(error)}
) : ( @@ -141,43 +295,15 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) { ) } - return ( - - {isLoading ? ( - <> - {t('resending_confirmation_email')} - … - - ) : isError ? ( -
{getUserFacingMessage(error)}
- ) : ( - <> - {t('please_confirm_email', { - emailAddress: userEmail.email, - })}{' '} - handleResendConfirmationEmail(userEmail)} - className="btn-inline-link" - > - {t('resend_confirmation_email')} - - - )} - - } - /> - ) + 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() { ) : null })} @@ -196,3 +323,4 @@ function ConfirmEmail() { } export default ConfirmEmail +export { getEmailDeletionDate } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index eab016a98b..034d1e3c15 100644 --- a/services/web/locales/en.json +++ b/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 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.", + "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 to make changes to your plan", "please_contact_us_if_you_think_this_is_in_error": "Please <0>contact us 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", diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx index 2728a5efdf..43a3791197 100644 --- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx +++ b/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('', 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('', 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('', 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('', 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` + ) + } }) } }) diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index 45cfe5fad5..09b186cece 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx @@ -92,7 +92,7 @@ describe('', 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('', 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' - ) - }) - }) }) }) diff --git a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx index ba814d6ebf..7d9544ffe7 100644 --- a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx +++ b/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, diff --git a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts b/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts index ecec525706..857a3dc77d 100644 --- a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts +++ b/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 }, ] diff --git a/services/web/types/user-email.ts b/services/web/types/user-email.ts index f6dc33a00f..62ffbea372 100644 --- a/services/web/types/user-email.ts +++ b/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