[web] Replace token-link validation on reconfirm affiliation notifications (#27250)

* Update Reconfirm Affiliation (Pug) in Portal page

Change the notification so it directs users to the settings page instead of triggering an email confirmation with token-link

* Update Reconfirm Affiliation (React) in Settings page

Update the component so it sends the 6-digits code and opens the modal to enter it

* Update Reconfirm Affiliation (React) in Projects page

Update the component so it sends the 6-digits code and opens the modal to enter it

* `cleanup_unused_locales`

* `bin/run web npm run extract-translations`

* Update tests

* Minor updates in tests

* Update Pug notifications with the notification mixin

* De-center the "reconfirmed notification"

* Update "Learn more" links to "Learn more about institutional email reconfirmation"

* Update tests

GitOrigin-RevId: cb65623e209217614786eec56f7f5d28b9e8cec5
This commit is contained in:
Antoine Clausse
2025-07-23 14:36:37 +02:00
committed by Copybot
parent 8cb07fdb08
commit 2d022aff12
9 changed files with 130 additions and 286 deletions

View File

@@ -24,6 +24,7 @@ mixin notification(options)
p
b #{title}
| !{content}
block
//- TODO: handle action
//- if action
//- .notification-cta

View File

@@ -1,55 +1,34 @@
include ./notification
mixin reconfirmAffiliationNotification-marketing(userEmail, location)
form(data-ol-async-form action='/user/emails/send-reconfirmation')
input(name='_csrf' type='hidden' value=csrfToken)
input(name='email' type='hidden' value=userEmail.email)
+formMessages
- var ssoEnabled = userEmail.affiliation && userEmail.affiliation.institution && userEmail.affiliation.institution.ssoEnabled
if ssoEnabled
- var institutionId = userEmail.affiliation && userEmail.affiliation.institution && userEmail.affiliation.institution.id
a.btn-reconfirm.btn.btn-sm.btn-info(
data-ol-slow-link
href=`${settings.saml.ukamf.initPath}?university_id=${institutionId}&reconfirm=${location}`
)
span(data-ol-inflight='idle') #{translate("confirm_affiliation")}
span(hidden data-ol-inflight='pending') #{translate("pending")}…
.reconfirm-notification
div(data-ol-not-sent style='width: 100%')
i.fa.fa-warning
else
a.btn-reconfirm.btn.btn-sm.btn-info(data-ol-slow-link href='/user/settings')
span(data-ol-inflight='idle') #{translate("go_to_account_settings")}
span(hidden data-ol-inflight='pending') #{translate("redirecting")}…
- var ssoEnabled = userEmail.affiliation && userEmail.affiliation.institution && userEmail.affiliation.institution.ssoEnabled
if ssoEnabled
- var institutionId = userEmail.affiliation && userEmail.affiliation.institution && userEmail.affiliation.institution.id
a.btn-reconfirm.btn.btn-sm.btn-info(
data-ol-slow-link
href=`${settings.saml.ukamf.initPath}?university_id=${institutionId}&reconfirm=${location}`
)
span(data-ol-inflight='idle') #{translate("confirm_affiliation")}
span(hidden data-ol-inflight='pending') #{translate("pending")}…
| !{translate("are_you_still_at", { institutionName: userEmail.affiliation.institution.name }, ['strong'])} 
else
button.btn-reconfirm.btn.btn-sm.btn-info(
type='submit'
data-ol-disabled-inflight
)
span(data-ol-inflight='idle') #{translate("confirm_affiliation")}
span(hidden data-ol-inflight='pending') #{translate("pending")}…
if location == '/user/settings'
| !{translate('please_reconfirm_institutional_email', {}, [{ name: 'span' }])}
if userEmail.default
span  #{translate('need_to_add_new_primary_before_remove')}
else
| !{translate("please_reconfirm_institutional_email_2")}
| !{translate("are_you_still_at", {institutionName: userEmail.affiliation.institution.name}, ['strong'])} 
if location == '/user/settings'
| !{translate('please_reconfirm_institutional_email', {}, [{ name: 'span' }])}
if userEmail.default
span  #{translate('need_to_add_new_primary_before_remove')}
else
| !{translate("please_reconfirm_institutional_email", {}, [{name: 'a', attrs: {href: '/user/settings?remove=' + userEmail.email}}])}
|  
a(href='/learn/how-to/Institutional_Email_Reconfirmation' target='_blank') #{translate("learn_more")}
div(hidden data-ol-sent)
| !{translate("please_check_your_inbox_to_confirm", {institutionName: userEmail.affiliation.institution.name}, ['strong'])}
|  
button.btn-inline-link(type='submit' data-ol-disabled-inflight)
span(data-ol-inflight='idle') #{translate('resend_confirmation_email')}
span(hidden data-ol-inflight='pending') #{translate("pending")}…
|  
a(href='/learn/how-to/Institutional_Email_Reconfirmation' target='_blank') #{translate("learn_more_about_email_reconfirmation")}
mixin reconfirmedAffiliationNotification-marketing(userEmail)
.alert.alert-info
.reconfirm-notification
div(style='width: 100%')
//- extra div for flex styling
| !{translate("your_affiliation_is_confirmed", {institutionName: userEmail.affiliation.institution.name}, ['strong'])}
|
| #{translate('thank_you_exclamation')}
| !{translate("your_affiliation_is_confirmed", {institutionName: userEmail.affiliation.institution.name}, ['strong'])}
|
| #{translate('thank_you_exclamation')}

View File

@@ -930,6 +930,7 @@
"learn_more": "",
"learn_more_about": "",
"learn_more_about_account": "",
"learn_more_about_email_reconfirmation": "",
"learn_more_about_link_sharing": "",
"learn_more_about_managed_users": "",
"learn_more_about_other_causes_of_compile_timeouts": "",
@@ -1253,7 +1254,6 @@
"please_ask_the_project_owner_to_upgrade_more_collaborators": "",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
"please_change_primary_to_remove": "",
"please_check_your_inbox_to_confirm": "",
"please_compile_pdf_before_download": "",
"please_compile_pdf_before_word_count": "",
"please_confirm_primary_email_or_edit": "",
@@ -1440,7 +1440,6 @@
"republish": "",
"resend": "",
"resend_confirmation_code": "",
"resend_confirmation_email": "",
"resend_group_invite": "",
"resend_link_sso": "",
"resend_managed_user_invite": "",
@@ -1561,7 +1560,6 @@
"send_first_message": "",
"send_message": "",
"send_request": "",
"sending": "",
"server_error": "",
"server_pro_license_entitlement_line_1": "",
"server_pro_license_entitlement_line_2": "",

View File

@@ -1,17 +1,12 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '../../../../../../utils/meta'
import useAsync from '../../../../../../shared/hooks/use-async'
import {
FetchError,
postJSON,
} from '../../../../../../infrastructure/fetch-json'
import { UserEmailData } from '../../../../../../../../types/user-email'
import { Institution } from '../../../../../../../../types/institution'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
import Notification from '@/features/project-list/components/notifications/notification'
import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal'
type ReconfirmAffiliationProps = {
email: UserEmailData['email']
@@ -24,78 +19,13 @@ function ReconfirmAffiliation({
}: ReconfirmAffiliationProps) {
const { t } = useTranslation()
const { samlInitPath } = getMeta('ol-ExposedSettings')
const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
const [hasSent, setHasSent] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isPending, setIsPending] = useState(false)
const ssoEnabled = institution.ssoEnabled
const location = useLocation()
useEffect(() => {
if (isSuccess) {
setHasSent(true)
}
}, [isSuccess])
const handleRequestReconfirmation = () => {
if (ssoEnabled) {
setIsPending(true)
location.assign(
`${samlInitPath}?university_id=${institution.id}&reconfirm=/project`
)
} else {
runAsync(
postJSON('/user/emails/send-reconfirmation', {
body: { email },
})
).catch(debugConsole.error)
}
}
const rateLimited =
error && error instanceof FetchError && error.response?.status === 429
if (hasSent) {
return (
<Notification
type="info"
content={
<>
<Trans
i18nKey="please_check_your_inbox_to_confirm"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: institution.name }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
&nbsp;
{isError && (
<>
<br />
<div>
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
</>
)}
</>
}
action={
<OLButton
variant="link"
onClick={handleRequestReconfirmation}
className="btn-inline-link"
disabled={isLoading}
isLoading={isLoading}
loadingLabel={t('sending') + '…'}
>
{t('resend_confirmation_email')}
</OLButton>
}
/>
)
}
if (isSuccess) return null
return (
<Notification
type="info"
@@ -119,29 +49,34 @@ function ReconfirmAffiliation({
href="/learn/how-to/Institutional_Email_Reconfirmation"
target="_blank"
>
{t('learn_more')}
{t('learn_more_about_email_reconfirmation')}
</a>
{isError && (
<>
<br />
<div>
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
</>
)}
</>
}
action={
<OLButton
variant="secondary"
isLoading={isLoading || isPending}
disabled={isLoading || isPending}
onClick={handleRequestReconfirmation}
>
{t('confirm_affiliation')}
</OLButton>
ssoEnabled ? (
<OLButton
variant="secondary"
isLoading={isPending}
disabled={isPending}
onClick={() => {
setIsPending(true)
location.assign(
`${samlInitPath}?university_id=${institution.id}&reconfirm=/project`
)
}}
>
{t('confirm_affiliation')}
</OLButton>
) : (
<ResendConfirmationCodeModal
email={email}
setGroupLoading={setIsLoading}
groupLoading={isLoading}
onSuccess={() => setIsSuccess(true)}
triggerVariant="secondary"
/>
)
}
/>
)

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useLayoutEffect } from 'react'
import { useState } from 'react'
import { UserEmailData } from '../../../../../../types/user-email'
import getMeta from '../../../../utils/meta'
import ReconfirmationInfoSuccess from './reconfirmation-info/reconfirmation-info-success'
@@ -7,14 +7,11 @@ import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { useUserEmailsContext } from '@/features/settings/context/user-email-context'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { ssoAvailableForInstitution } from '@/features/settings/utils/sso'
import { Trans, useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
import { useLocation } from '@/shared/hooks/use-location'
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'
type ReconfirmationInfoProps = {
userEmailData: UserEmailData
@@ -22,50 +19,21 @@ type ReconfirmationInfoProps = {
function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
const reconfirmedViaSAML = getMeta('ol-reconfirmedViaSAML')
const affiliation = userEmailData.affiliation
const { t } = useTranslation()
const { samlInitPath } = getMeta('ol-ExposedSettings')
const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
const { state, setLoading: setUserEmailsContextLoading } =
useUserEmailsContext()
const [hasSent, setHasSent] = useState(false)
const {
state,
setLoading: setUserEmailsContextLoading,
getEmails,
} = useUserEmailsContext()
const [isPending, setIsPending] = useState(false)
const location = useLocation()
const ssoAvailable = Boolean(
ssoAvailableForInstitution(userEmailData.affiliation?.institution ?? null)
ssoAvailableForInstitution(affiliation?.institution ?? null)
)
const handleRequestReconfirmation = () => {
if (userEmailData.affiliation?.institution && ssoAvailable) {
setIsPending(true)
location.assign(
`${samlInitPath}?university_id=${userEmailData.affiliation.institution.id}&reconfirm=/user/settings`
)
} else {
runAsync(
postJSON('/user/emails/send-reconfirmation', {
body: {
email: userEmailData.email,
},
})
).catch(debugConsole.error)
}
}
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
useLayoutEffect(() => {
if (isSuccess) {
setHasSent(true)
}
}, [isSuccess])
const rateLimited =
isError && error instanceof FetchError && error.response?.status === 429
if (!userEmailData.affiliation) {
if (!affiliation) {
return null
}
@@ -80,7 +48,7 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
type="info"
content={
<ReconfirmationInfoSuccess
institution={userEmailData.affiliation.institution}
institution={affiliation.institution}
/>
}
/>
@@ -89,86 +57,45 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
)
}
if (userEmailData.affiliation.inReconfirmNotificationPeriod) {
return (
<OLRow>
<OLCol lg={12}>
<OLNotification
type="info"
content={
<>
{hasSent ? (
<Trans
i18nKey="please_check_your_inbox_to_confirm"
values={{
institutionName:
userEmailData.affiliation.institution.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
/>
) : (
<ReconfirmationInfoPromptText
institutionName={userEmailData.affiliation.institution.name}
primary={userEmailData.default}
/>
)}
<br />
{isError && (
<div className="text-danger">
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
)}
</>
}
action={
hasSent ? (
<>
{isLoading ? (
<>
<LoadingSpinner loadingText={`${t('sending')}`} />
</>
) : (
<OLButton
variant="link"
disabled={state.isLoading}
onClick={handleRequestReconfirmation}
className="btn-inline-link"
>
{t('resend_confirmation_email')}
</OLButton>
)}
</>
) : (
<OLButton
variant="secondary"
disabled={isPending}
isLoading={isLoading}
onClick={handleRequestReconfirmation}
>
{isLoading ? (
<>
<LoadingSpinner loadingText={`${t('sending')}`} />
</>
) : (
t('confirm_affiliation')
)}
</OLButton>
)
}
/>
</OLCol>
</OLRow>
)
if (!affiliation.inReconfirmNotificationPeriod) {
return null
}
return null
return (
<OLNotification
type="info"
content={
<ReconfirmationInfoPromptText
institutionName={affiliation.institution.name}
primary={userEmailData.default}
/>
}
action={
affiliation?.institution && ssoAvailable ? (
<OLButton
variant="secondary"
disabled={isPending}
onClick={() => {
setIsPending(true)
location.assign(
`${samlInitPath}?university_id=${affiliation.institution.id}&reconfirm=/user/settings`
)
}}
>
{t('confirm_affiliation')}
</OLButton>
) : (
<ResendConfirmationCodeModal
email={userEmailData.email}
setGroupLoading={setUserEmailsContextLoading}
groupLoading={state.isLoading}
onSuccess={getEmails}
triggerVariant="secondary"
/>
)
}
/>
)
}
export default ReconfirmationInfo

View File

@@ -37,7 +37,7 @@ function ReconfirmationInfoPromptText({
href="/learn/how-to/Institutional_Email_Reconfirmation"
target="_blank"
>
{t('learn_more')}
{t('learn_more_about_email_reconfirmation')}
</a>
<br />
{primary ? <i>{t('need_to_add_new_primary_before_remove')}</i> : null}

View File

@@ -1207,6 +1207,7 @@
"learn_more": "Learn more",
"learn_more_about": "Learn more about __appName__",
"learn_more_about_account": "<0>Learn more</0> about managing your __appName__ account.",
"learn_more_about_email_reconfirmation": "Learn more about institutional email reconfirmation.",
"learn_more_about_emails": "<0>Learn more</0> about managing your __appName__ emails.",
"learn_more_about_link_sharing": "Learn more about Link Sharing",
"learn_more_about_managed_users": "Learn more about Managed Users.",
@@ -1648,7 +1649,6 @@
"please_ask_the_project_owner_to_upgrade_more_collaborators": "Please ask the project owner to upgrade their plan to allow more collaborators.",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
"please_change_primary_to_remove": "Please change your primary email in order to remove",
"please_check_your_inbox_to_confirm": "Please check your email inbox to confirm your <0>__institutionName__</0> affiliation.",
"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 ",
@@ -1665,6 +1665,7 @@
"please_provide_a_subject": "Please provide a subject",
"please_provide_a_valid_email_address": "Please provide a valid email address",
"please_reconfirm_institutional_email": "Please take a moment to confirm your institutional email address or <0>remove it</0> from your account.",
"please_reconfirm_institutional_email_2": "Please take a moment to confirm your institutional email address or remove it from your account.",
"please_reconfirm_your_affiliation_before_making_this_primary": "Please confirm your affiliation before making this the primary.",
"please_refresh": "Please refresh the page to continue.",
"please_request_a_new_password_reset_email_and_follow_the_link": "Please request a new password reset email and follow the link",
@@ -1879,7 +1880,6 @@
"required": "Required",
"resend": "Resend",
"resend_confirmation_code": "Resend confirmation code",
"resend_confirmation_email": "Resend confirmation email",
"resend_group_invite": "Resend group invite",
"resend_link_sso": "Resend SSO invite",
"resend_managed_user_invite": "Resend managed user invite",

View File

@@ -842,24 +842,22 @@ describe('<UserNotifications />', function () {
)
const sendReconfirmationMock = fetchMock.post(
'/user/emails/send-reconfirmation',
'/user/emails/send-confirmation-code',
200
)
fireEvent.click(
screen.getByRole('button', { name: /confirm affiliation/i })
screen.getByRole('button', { name: 'Send confirmation code' })
)
await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
screen.getByText(/check your email inbox to confirm/i)
expect(screen.queryByRole('button', { name: /confirm affiliation/i })).to
.be.null
expect(screen.queryByRole('link', { name: /remove it/i })).to.be.null
expect(screen.queryByRole('link', { name: /learn more/i })).to.be.null
screen.getByText(/Enter the 6-digit code sent to foo@overleaf.com/i)
expect(sendReconfirmationMock.callHistory.called()).to.be.true
fireEvent.click(
screen.getByRole('button', { name: /resend confirmation email/i })
screen.getByRole('button', { name: /resend confirmation code/i })
)
await waitForElementToBeRemoved(() =>
screen.getByText('Resending confirmation code')
)
await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
expect(sendReconfirmationMock.callHistory.calls()).to.have.lengthOf(2)
})

View File

@@ -76,7 +76,9 @@ describe('<ReconfirmationInfo/>', function () {
screen.getByText(
/Please take a moment to confirm your institutional email address/
)
screen.getByRole('link', { name: 'Learn more' })
screen.getByRole('link', {
name: 'Learn more about institutional email reconfirmation.',
})
expect(screen.queryByText(/add a new primary email address/)).to.not.exist
})
@@ -121,13 +123,13 @@ describe('<ReconfirmationInfo/>', function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasSamlFeature: false,
})
fetchMock.post('/user/emails/send-reconfirmation', 200)
fetchMock.post('/user/emails/send-confirmation-code', 200)
})
it('sends and resends confirmation email', async function () {
renderReconfirmationInfo(inReconfirmUserData)
const confirmButton = (await screen.findByRole('button', {
name: 'Confirm affiliation',
name: 'Send confirmation code',
})) as HTMLButtonElement
await waitFor(() => {
@@ -141,12 +143,14 @@ describe('<ReconfirmationInfo/>', function () {
expect(fetchMock.callHistory.called()).to.be.true
// the confirmation text should now be displayed
await screen.findByText(/Please check your email inbox to confirm/)
await screen.findByLabelText(
/Enter the 6-digit code sent to sso-prof@sso-university\.edu/
)
// try the resend button
fetchMock.clearHistory()
const resendButton = await screen.findByRole('button', {
name: 'Resend confirmation email',
name: /Resend confirmation code/,
})
fireEvent.click(resendButton)
@@ -154,9 +158,11 @@ describe('<ReconfirmationInfo/>', function () {
// commented out as it's already gone by this point
// await screen.findByText(/Sending/)
expect(fetchMock.callHistory.called()).to.be.true
await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
await waitForElementToBeRemoved(() =>
screen.getByText('Resending confirmation code')
)
await screen.findByRole('button', {
name: 'Resend confirmation email',
name: 'Resend confirmation code',
})
})
})