[web] Use 6-digits code to confirm existing email in Account Settings (#23931)

* Rename `checkSecondaryEmailConfirmationCode` to `checkAddSecondaryEmailConfirmationCode`

* Create function `sendCodeAndStoreInSession`

* Create function `sendExistingSecondaryEmailConfirmationCode`

* Create function `_checkConfirmationCode`

* Create function `checkExistingEmailConfirmationCode`

* Rename `resendSecondaryEmailConfirmationCode` to `resendAddSecondaryEmailConfirmationCode`

* Create function `_resendConfirmationCode`

* Create function `resendExistingSecondaryEmailConfirmationCode`

* Add `ResendConfirmationCodeModal`

* Remove `ResendConfirmationEmailButton`

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

* Update frontend test

* Fix: don't throw on render when send-confirmation-code fails!

* Update phrasing in the UI

Per https://docs.google.com/document/d/1PE1vlZWQN--PjmXpyHR9rV2YPd7OIPIsUbnZaHj0cDI/edit?usp=sharing

* Add unit test

* Don't share the "send-confirmation" and "resend-confirmation" rate-limits

* Update frontend test after copy change

* Rename `checkAddSecondaryEmailConfirmationCode` to `checkNewSecondaryEmailConfirmationCode` and `resendAddSecondaryEmailConfirmationCode` to `resendNewSecondaryEmailConfirmationCode`

* Rename `cb` to `beforeConfirmEmail`

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Return `422` on missing session data

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Add `userId` to log

* Replace `isSecondary` param by `welcomeUser`

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Rename `resend-confirm-email-code`'s `existingEmail` to `email`

* Remove "secondary" from rate-limiters

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* Remove unnecessary `userId` check behind `AuthenticationController.requireLogin()`

* Only open the modal if the code was sent successfully

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: df892064641d9f722785699777383b2d863124e1
This commit is contained in:
Antoine Clausse
2025-03-07 09:06:50 +00:00
committed by Copybot
co-authored by Jakob Ackermann
parent e52f3543a7
commit c4e6dfbbbd
13 changed files with 743 additions and 293 deletions
@@ -25,6 +25,7 @@ type ConfirmEmailFormProps = {
email?: string
onSuccessfulConfirmation?: () => void
interstitial: boolean
isModal?: boolean
onCancel?: () => void
}
@@ -37,6 +38,7 @@ export function ConfirmEmailForm({
email = getMeta('ol-email'),
onSuccessfulConfirmation,
interstitial,
isModal,
onCancel,
}: ConfirmEmailFormProps) {
const { t } = useTranslation()
@@ -156,6 +158,13 @@ export function ConfirmEmailForm({
)
}
let intro = <h5 className="h5">{t('confirm_your_email')}</h5>
if (isModal) intro = <h5 className="h5">{t('we_sent_code')}</h5>
if (interstitial)
intro = (
<h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
)
return (
<form
onSubmit={submitHandler}
@@ -172,14 +181,12 @@ export function ConfirmEmailForm({
/>
)}
{interstitial ? (
<h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
) : (
<h5 className="h5">{t('confirm_your_email')}</h5>
)}
{intro}
<OLFormLabel htmlFor="one-time-code">
{t('enter_the_confirmation_code', { email })}
{isModal
? t('enter_the_code', { email })
: t('enter_the_confirmation_code', { email })}
</OLFormLabel>
<input
id="one-time-code"
@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next'
import { UserEmailData } from '../../../../../../types/user-email'
import ResendConfirmationEmailButton from './resend-confirmation-email-button'
import { ssoAvailableForInstitution } from '../../utils/sso'
import OLBadge from '@/features/ui/components/ol/ol-badge'
import { isBootstrap5 } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal'
type EmailProps = {
userEmailData: UserEmailData
@@ -29,13 +29,10 @@ function Email({ userEmailData }: EmailProps) {
{userEmailData.email}
{!userEmailData.confirmedAt && (
<div className="small">
<strong>
{t('unconfirmed')}.
{!ssoAvailable && <span> {t('please_check_your_inbox')}.</span>}
</strong>
<strong>{t('unconfirmed')}.</strong>
<br />
{!ssoAvailable && (
<ResendConfirmationEmailButton email={userEmailData.email} />
<ResendConfirmationCodeModal email={userEmailData.email} />
)}
</div>
)}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import useAsync from '../../../../shared/hooks/use-async'
import { UserEmailData } from '../../../../../../types/user-email'
import { useUserEmailsContext } from '../../context/user-email-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
type ResendConfirmationEmailButtonProps = {
email: UserEmailData['email']
}
function ResendConfirmationCodeModal({
email,
}: ResendConfirmationEmailButtonProps) {
const { t } = useTranslation()
const { error, isLoading, isError, runAsync } = useAsync()
const {
state,
setLoading: setUserEmailsContextLoading,
getEmails,
} = useUserEmailsContext()
const [modalVisible, setModalVisible] = useState(false)
// Update global isLoading prop
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
const handleResendConfirmationEmail = async () => {
await runAsync(
postJSON('/user/emails/send-confirmation-code', { body: { email } })
)
.then(() => setModalVisible(true))
.catch(() => {})
}
if (isLoading) {
return (
<>
<Icon type="refresh" spin fw /> {t('sending')}&hellip;
</>
)
}
const rateLimited =
error && error instanceof FetchError && error.response?.status === 429
return (
<>
{modalVisible && (
<OLModal
animation
show={modalVisible}
onHide={() => setModalVisible(false)}
id="action-project-modal"
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('confirm_your_email')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<ConfirmEmailForm
isModal
flow="resend"
interstitial={false}
resendEndpoint="/user/emails/resend-confirmation-code"
confirmationEndpoint="/user/emails/confirm-code"
email={email}
onSuccessfulConfirmation={() => {
getEmails()
setModalVisible(false)
}}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setModalVisible(false)}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
<OLButton
variant="link"
disabled={state.isLoading || isLoading}
onClick={handleResendConfirmationEmail}
className="btn-inline-link"
>
{t('resend_confirmation_code')}
</OLButton>
<br />
{isError && (
<div className="text-danger">
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
)}
</>
)
}
export default ResendConfirmationCodeModal
@@ -1,70 +0,0 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import { FetchError, postJSON } from '../../../../infrastructure/fetch-json'
import useAsync from '../../../../shared/hooks/use-async'
import { UserEmailData } from '../../../../../../types/user-email'
import { useUserEmailsContext } from '../../context/user-email-context'
import OLButton from '@/features/ui/components/ol/ol-button'
type ResendConfirmationEmailButtonProps = {
email: UserEmailData['email']
}
function ResendConfirmationEmailButton({
email,
}: ResendConfirmationEmailButtonProps) {
const { t } = useTranslation()
const { error, isLoading, isError, runAsync } = useAsync()
const { state, setLoading: setUserEmailsContextLoading } =
useUserEmailsContext()
// Update global isLoading prop
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
const handleResendConfirmationEmail = () => {
runAsync(
postJSON('/user/emails/resend_confirmation', {
body: {
email,
},
})
).catch(() => {})
}
if (isLoading) {
return (
<>
<Icon type="refresh" spin fw /> {t('sending')}&hellip;
</>
)
}
const rateLimited =
error && error instanceof FetchError && error.response?.status === 429
return (
<>
<OLButton
variant="link"
disabled={state.isLoading || isLoading}
onClick={handleResendConfirmationEmail}
className="btn-inline-link"
>
{t('resend_confirmation_email')}
</OLButton>
<br />
{isError && (
<div className="text-danger">
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
)}
</>
)
}
export default ResendConfirmationEmailButton