[web] Update secondary email addition: confirm with 6 digits code (#22851)

* Remove `Interstitial` from `ConfirmEmailForm`

* Allow adding `affiliationOptions` in `addWithConfirmationCode`

* Add confirmationStep in add-email.tsx

* Call `getEmails` once a secondary email is added

* Fix tests

* Lint fix

* Style confirm-email-form

Figma: https://www.figma.com/design/TWyeImDSZHhkl9akYaGmeb/24.5-Secondary-email-reconfirmation?node-id=1-449&p=f&m=dev

* Remove unnecessary `successMessage` and `successButtonText` from hidden ConfirmEmailForm

* Remove icon padding

* Rename file to confirm-email-form.tsx

* Use `OLButton`

* Add Cancel button

* Update loading states

* Remove redundant `className` with variants

GitOrigin-RevId: 62b1729cf2299da38f20fa3946273ad0193c7d54
This commit is contained in:
Antoine Clausse
2025-01-31 09:05:23 +00:00
committed by Copybot
parent e01e3960c3
commit 858d31bcd0
7 changed files with 212 additions and 91 deletions
@@ -155,6 +155,12 @@ async function addWithConfirmationCode(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email) const email = EmailHelper.parseEmail(req.body.email)
const affiliationOptions = {
university: req.body.university,
role: req.body.role,
department: req.body.department,
}
if (!email) { if (!email) {
return res.sendStatus(422) return res.sendStatus(422)
} }
@@ -195,6 +201,7 @@ async function addWithConfirmationCode(req, res) {
email, email,
confirmCode, confirmCode,
confirmCodeExpiresTimestamp, confirmCodeExpiresTimestamp,
affiliationOptions,
} }
return res.sendStatus(200) return res.sendStatus(200)
@@ -291,7 +298,7 @@ async function checkSecondaryEmailConfirmationCode(req, res) {
await UserUpdater.promises.addEmailAddress( await UserUpdater.promises.addEmailAddress(
userId, userId,
req.session.pendingSecondaryEmail.email, req.session.pendingSecondaryEmail.email,
{}, req.session.pendingSecondaryEmail.affiliationOptions,
{ {
initiatorId: user._id, initiatorId: user._id,
ipAddress: req.ip, ipAddress: req.ip,
@@ -301,7 +308,7 @@ async function checkSecondaryEmailConfirmationCode(req, res) {
await UserUpdater.promises.confirmEmail( await UserUpdater.promises.confirmEmail(
userId, userId,
req.session.pendingSecondaryEmail.email, req.session.pendingSecondaryEmail.email,
{} req.session.pendingSecondaryEmail.affiliationOptions
) )
delete req.session.pendingSecondaryEmail delete req.session.pendingSecondaryEmail
@@ -19,6 +19,7 @@ import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha' import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
import OLCol from '@/features/ui/components/ol/ol-col' import OLCol from '@/features/ui/components/ol/ol-col'
import { bsVersion } from '@/features/utils/bootstrap-5' import { bsVersion } from '@/features/utils/bootstrap-5'
import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
function AddEmail() { function AddEmail() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -26,6 +27,7 @@ function AddEmail() {
() => window.location.hash === '#add-email' () => window.location.hash === '#add-email'
) )
const [newEmail, setNewEmail] = useState('') const [newEmail, setNewEmail] = useState('')
const [confirmationStep, setConfirmationStep] = useState(false)
const [newEmailMatchedDomain, setNewEmailMatchedDomain] = const [newEmailMatchedDomain, setNewEmailMatchedDomain] =
useState<DomainInfo | null>(null) useState<DomainInfo | null>(null)
const [countryCode, setCountryCode] = useState<CountryCode | null>(null) const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
@@ -90,7 +92,7 @@ function AddEmail() {
runAsync( runAsync(
(async () => { (async () => {
const token = await getReCaptchaToken() const token = await getReCaptchaToken()
await postJSON('/user/emails', { await postJSON('/user/emails/secondary', {
body: { body: {
email: newEmail, email: newEmail,
...knownUniversityData, ...knownUniversityData,
@@ -101,11 +103,28 @@ function AddEmail() {
})() })()
) )
.then(() => { .then(() => {
getEmails() setConfirmationStep(true)
}) })
.catch(() => {}) .catch(() => {})
} }
if (confirmationStep) {
return (
<ConfirmEmailForm
confirmationEndpoint="/user/emails/confirm-secondary"
resendEndpoint="/user/emails/resend-secondary-confirmation"
flow="secondary"
email={newEmail}
onSuccessfulConfirmation={getEmails}
interstitial={false}
onCancel={() => {
setConfirmationStep(false)
setIsFormVisible(false)
}}
/>
)
}
if (!isFormVisible) { if (!isFormVisible) {
return ( return (
<Layout isError={isError} error={error}> <Layout isError={isError} error={error}>
@@ -2,13 +2,13 @@ import { postJSON } from '@/infrastructure/fetch-json'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import Notification from '@/shared/components/notification' import Notification from '@/shared/components/notification'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import { FormEvent, useState } from 'react' import { FormEvent, MouseEventHandler, useState } from 'react'
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import LoadingSpinner from '@/shared/components/loading-spinner' import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import { sendMB } from '@/infrastructure/event-tracking' import { sendMB } from '@/infrastructure/event-tracking'
import { Interstitial } from '@/shared/components/interstitial' import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
type Feedback = { type Feedback = {
type: 'input' | 'alert' type: 'input' | 'alert'
@@ -20,8 +20,12 @@ type ConfirmEmailFormProps = {
confirmationEndpoint: string confirmationEndpoint: string
flow: string flow: string
resendEndpoint: string resendEndpoint: string
successMessage: React.ReactNode successMessage?: React.ReactNode
successButtonText: string successButtonText?: string
email?: string
onSuccessfulConfirmation?: () => void
interstitial: boolean
onCancel?: () => void
} }
export function ConfirmEmailForm({ export function ConfirmEmailForm({
@@ -30,6 +34,10 @@ export function ConfirmEmailForm({
resendEndpoint, resendEndpoint,
successMessage, successMessage,
successButtonText, successButtonText,
email = getMeta('ol-email'),
onSuccessfulConfirmation,
interstitial,
onCancel,
}: ConfirmEmailFormProps) { }: ConfirmEmailFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [confirmationCode, setConfirmationCode] = useState('') const [confirmationCode, setConfirmationCode] = useState('')
@@ -37,7 +45,6 @@ export function ConfirmEmailForm({
const [isConfirming, setIsConfirming] = useState(false) const [isConfirming, setIsConfirming] = useState(false)
const [isResending, setIsResending] = useState(false) const [isResending, setIsResending] = useState(false)
const [successRedirectPath, setSuccessRedirectPath] = useState('') const [successRedirectPath, setSuccessRedirectPath] = useState('')
const email = getMeta('ol-email')
const { isReady } = useWaitForI18n() const { isReady } = useWaitForI18n()
const errorHandler = (err: any, actionType?: string) => { const errorHandler = (err: any, actionType?: string) => {
@@ -78,33 +85,31 @@ export function ConfirmEmailForm({
} }
} }
const submitHandler = (e: FormEvent<HTMLFormElement>) => { const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
setIsConfirming(true) setIsConfirming(true)
setFeedback(null) setFeedback(null)
postJSON(confirmationEndpoint, {
body: {
code: confirmationCode,
},
})
.then(data => {
setSuccessRedirectPath(data?.redir || '/')
})
.catch(err => {
errorHandler(err, 'confirm')
})
.finally(() => {
setIsConfirming(false)
})
sendMB('email-verification-click', { sendMB('email-verification-click', {
button: 'verify', button: 'verify',
flow, flow,
}) })
try {
const data = await postJSON(confirmationEndpoint, {
body: { code: confirmationCode },
})
if (onSuccessfulConfirmation) {
onSuccessfulConfirmation()
} else {
setSuccessRedirectPath(data?.redir || '/')
}
} catch (err) {
errorHandler(err, 'confirm')
} finally {
setIsConfirming(false)
}
} }
const resendHandler = (e: FormEvent<Button>) => { const resendHandler: MouseEventHandler<HTMLButtonElement> = () => {
setIsResending(true) setIsResending(true)
setFeedback(null) setFeedback(null)
@@ -138,14 +143,10 @@ export function ConfirmEmailForm({
} }
if (!isReady) { if (!isReady) {
return ( return <LoadingSpinner />
<Interstitial className="confirm-email" showLogo>
<LoadingSpinner />
</Interstitial>
)
} }
if (successRedirectPath) { if (successRedirectPath && successButtonText && successMessage) {
return ( return (
<ConfirmEmailSuccessfullForm <ConfirmEmailSuccessfullForm
successMessage={successMessage} successMessage={successMessage}
@@ -156,8 +157,12 @@ export function ConfirmEmailForm({
} }
return ( return (
<Interstitial className="confirm-email" showLogo> <form
<form onSubmit={submitHandler} onInvalid={invalidFormHandler}> onSubmit={submitHandler}
onInvalid={invalidFormHandler}
className="confirm-email-form"
>
<div className="confirm-email-form-inner">
{feedback?.type === 'alert' && ( {feedback?.type === 'alert' && (
<Notification <Notification
ariaLive="polite" ariaLive="polite"
@@ -167,10 +172,17 @@ export function ConfirmEmailForm({
/> />
)} )}
<h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1> {interstitial ? (
<h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
) : (
<h5 className="h5">{t('confirm_your_email')}</h5>
)}
<p className="small">{t('enter_the_confirmation_code', { email })}</p> <OLFormLabel htmlFor="one-time-code">
{t('enter_the_confirmation_code', { email })}
</OLFormLabel>
<input <input
id="one-time-code"
className="form-control" className="form-control"
placeholder={t('enter_6_digit_code')} placeholder={t('enter_6_digit_code')}
inputMode="numeric" inputMode="numeric"
@@ -194,39 +206,53 @@ export function ConfirmEmailForm({
</div> </div>
<div className="form-actions"> <div className="form-actions">
<Button <OLButton
disabled={isConfirming || isResending} disabled={isResending}
type="submit" type="submit"
bsStyle={null} isLoading={isConfirming}
className="btn-primary" bs3Props={{
loading: isConfirming ? (
<>
{t('confirming')}
<span>&hellip;</span>
</>
) : (
t('confirm')
),
}}
> >
{isConfirming ? ( {t('confirm')}
<> </OLButton>
{t('confirming')} <OLButton
<span>&hellip;</span> variant="secondary"
</> disabled={isConfirming}
) : (
t('confirm')
)}
</Button>
<Button
disabled={isConfirming || isResending}
onClick={resendHandler} onClick={resendHandler}
bsStyle={null} isLoading={isResending}
className="btn-secondary" bs3Props={{
loading: isResending ? (
<>
{t('resending_confirmation_code')}
<span>&hellip;</span>
</>
) : (
t('resend_confirmation_code')
),
}}
> >
{isResending ? ( {t('resend_confirmation_code')}
<> </OLButton>
{t('resending_confirmation_code')} {onCancel && (
<span>&hellip;</span> <OLButton
</> variant="danger-ghost"
) : ( disabled={isConfirming || isResending}
t('resend_confirmation_code') onClick={onCancel}
)} >
</Button> {t('cancel')}
</OLButton>
)}
</div> </div>
</form> </div>
</Interstitial> </form>
) )
} }
@@ -245,17 +271,15 @@ function ConfirmEmailSuccessfullForm({
} }
return ( return (
<Interstitial className="confirm-email" showLogo> <form onSubmit={submitHandler}>
<form onSubmit={submitHandler}> <div aria-live="polite">{successMessage}</div>
<div aria-live="polite">{successMessage}</div>
<div className="form-actions"> <div className="form-actions">
<Button type="submit" bsStyle={null} className="btn-primary"> <OLButton type="submit" variant="primary">
{successButtonText} {successButtonText}
</Button> </OLButton>
</div> </div>
</form> </form>
</Interstitial>
) )
} }
@@ -1,5 +1,6 @@
import { ConfirmEmailForm } from './confirm-email' import { ConfirmEmailForm } from './confirm-email-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Interstitial } from '@/shared/components/interstitial'
export default function ConfirmSecondaryEmailForm() { export default function ConfirmSecondaryEmailForm() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -13,12 +14,15 @@ export default function ConfirmSecondaryEmailForm() {
) )
return ( return (
<ConfirmEmailForm <Interstitial className="confirm-email" showLogo>
successMessage={successMessage} <ConfirmEmailForm
successButtonText={t('go_to_overleaf')} successMessage={successMessage}
confirmationEndpoint="/user/emails/confirm-secondary" successButtonText={t('go_to_overleaf')}
resendEndpoint="/user/emails/resend-secondary-confirmation" confirmationEndpoint="/user/emails/confirm-secondary"
flow="secondary" resendEndpoint="/user/emails/resend-secondary-confirmation"
/> flow="secondary"
interstitial
/>
</Interstitial>
) )
} }
@@ -15,6 +15,10 @@
gap: 12px; gap: 12px;
padding-top: 20px; padding-top: 20px;
} }
label {
font-weight: normal;
}
} }
.confirm-email-alert { .confirm-email-alert {
@@ -203,3 +203,40 @@
} }
} }
} }
#settings-page-root {
.confirm-email-form {
background: var(--bg-light-secondary);
}
.confirm-email-form-inner {
margin: auto;
padding: var(--spacing-08);
max-width: 480px;
label {
overflow-wrap: anywhere;
}
.text-danger {
display: flex;
gap: var(--spacing-03);
padding: var(--spacing-02);
}
.form-actions {
margin-top: var(--spacing-05);
display: flex;
flex-direction: column;
gap: var(--spacing-05);
button {
white-space: normal;
}
.btn-danger-ghost:not(:hover) {
background: transparent;
}
}
}
}
@@ -60,6 +60,19 @@ function resetFetchMock() {
fetchMock.get('express:/institutions/domains', []) fetchMock.get('express:/institutions/domains', [])
} }
async function confirmCodeForEmail(email: string) {
screen.getByText(`Enter the 6-digit confirmation code sent to ${email}.`)
const inputCode = screen.getByLabelText(/6-digit confirmation code/i)
fireEvent.change(inputCode, { target: { value: '123456' } })
const submitCodeBtn = screen.getByRole<HTMLButtonElement>('button', {
name: 'Confirm',
})
fireEvent.click(submitCodeBtn)
await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: /confirming/i })
)
}
describe('<EmailsSection />', function () { describe('<EmailsSection />', function () {
beforeEach(function () { beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), { Object.assign(getMeta('ol-ExposedSettings'), {
@@ -181,7 +194,8 @@ describe('<EmailsSection />', function () {
resetFetchMock() resetFetchMock()
fetchMock fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData]) .get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails', 200) .post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
fireEvent.click(addAnotherEmailBtn) fireEvent.click(addAnotherEmailBtn)
const input = screen.getByLabelText(/email/i) const input = screen.getByLabelText(/email/i)
@@ -206,6 +220,7 @@ describe('<EmailsSection />', function () {
}) })
) )
await confirmCodeForEmail(userEmailData.email)
screen.getByText(userEmailData.email) screen.getByText(userEmailData.email)
}) })
@@ -222,7 +237,7 @@ describe('<EmailsSection />', function () {
resetFetchMock() resetFetchMock()
fetchMock fetchMock
.get('/user/emails?ensureAffiliation=true', []) .get('/user/emails?ensureAffiliation=true', [])
.post('/user/emails', 400) .post('/user/emails/secondary', 400)
fireEvent.click(addAnotherEmailBtn) fireEvent.click(addAnotherEmailBtn)
const input = screen.getByLabelText(/email/i) const input = screen.getByLabelText(/email/i)
@@ -340,7 +355,8 @@ describe('<EmailsSection />', function () {
fetchMock fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy]) .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post(/\/user\/emails/, 200) .post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.click( await userEvent.click(
screen.getByRole('button', { screen.getByRole('button', {
@@ -359,8 +375,12 @@ describe('<EmailsSection />', function () {
department: customDepartment, department: customDepartment,
}) })
screen.getByText(userEmailData.email) screen.getByText(
screen.getByText(userEmailData.affiliation.institution.name) `Enter the 6-digit confirmation code sent to ${userEmailData.email}.`
)
await confirmCodeForEmail(userEmailData.email)
screen.getByText(userEmailData.affiliation.role!, { exact: false }) screen.getByText(userEmailData.affiliation.role!, { exact: false })
screen.getByText(customDepartment, { exact: false }) screen.getByText(customDepartment, { exact: false })
}) })
@@ -485,7 +505,8 @@ describe('<EmailsSection />', function () {
fetchMock fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy]) .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post(/\/user\/emails/, 200) .post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.click( await userEvent.click(
screen.getByRole('button', { screen.getByRole('button', {
@@ -493,6 +514,8 @@ describe('<EmailsSection />', function () {
}) })
) )
await confirmCodeForEmail(userEmailData.email)
const [[, request]] = fetchMock.calls(/\/user\/emails/) const [[, request]] = fetchMock.calls(/\/user\/emails/)
expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({ expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
@@ -635,7 +658,8 @@ describe('<EmailsSection />', function () {
fetchMock fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy]) .get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post('/user/emails', 200) .post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.type( await userEvent.type(
screen.getByRole('textbox', { name: /role/i }), screen.getByRole('textbox', { name: /role/i }),
@@ -651,6 +675,8 @@ describe('<EmailsSection />', function () {
}) })
) )
await confirmCodeForEmail('user@autocomplete.edu')
await fetchMock.flush(true) await fetchMock.flush(true)
fetchMock.reset() fetchMock.reset()