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
This commit is contained in:
Rebeka Dekany
2025-03-06 12:55:34 +01:00
committed by Copybot
parent 2147f1d53d
commit cd133e8240
8 changed files with 328 additions and 68 deletions

View File

@@ -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": "",

View File

@@ -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 (
<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 }

View File

@@ -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",

View File

@@ -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`
)
}
})
}
})

View File

@@ -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'
)
})
})
})
})

View File

@@ -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,

View File

@@ -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 },
]

View File

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