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-07 09:05:50 +00:00
committed by Copybot
parent 2147f1d53d
commit cd133e8240
8 changed files with 328 additions and 68 deletions
@@ -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": "",
@@ -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 }