[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-30 15:25:43 +01:00
committed by Copybot
parent e01e3960c3
commit 858d31bcd0
7 changed files with 212 additions and 91 deletions

View File

@@ -155,6 +155,12 @@ async function addWithConfirmationCode(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const affiliationOptions = {
university: req.body.university,
role: req.body.role,
department: req.body.department,
}
if (!email) {
return res.sendStatus(422)
}
@@ -195,6 +201,7 @@ async function addWithConfirmationCode(req, res) {
email,
confirmCode,
confirmCodeExpiresTimestamp,
affiliationOptions,
}
return res.sendStatus(200)
@@ -291,7 +298,7 @@ async function checkSecondaryEmailConfirmationCode(req, res) {
await UserUpdater.promises.addEmailAddress(
userId,
req.session.pendingSecondaryEmail.email,
{},
req.session.pendingSecondaryEmail.affiliationOptions,
{
initiatorId: user._id,
ipAddress: req.ip,
@@ -301,7 +308,7 @@ async function checkSecondaryEmailConfirmationCode(req, res) {
await UserUpdater.promises.confirmEmail(
userId,
req.session.pendingSecondaryEmail.email,
{}
req.session.pendingSecondaryEmail.affiliationOptions
)
delete req.session.pendingSecondaryEmail

View File

@@ -19,6 +19,7 @@ import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
import OLCol from '@/features/ui/components/ol/ol-col'
import { bsVersion } from '@/features/utils/bootstrap-5'
import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
function AddEmail() {
const { t } = useTranslation()
@@ -26,6 +27,7 @@ function AddEmail() {
() => window.location.hash === '#add-email'
)
const [newEmail, setNewEmail] = useState('')
const [confirmationStep, setConfirmationStep] = useState(false)
const [newEmailMatchedDomain, setNewEmailMatchedDomain] =
useState<DomainInfo | null>(null)
const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
@@ -90,7 +92,7 @@ function AddEmail() {
runAsync(
(async () => {
const token = await getReCaptchaToken()
await postJSON('/user/emails', {
await postJSON('/user/emails/secondary', {
body: {
email: newEmail,
...knownUniversityData,
@@ -101,11 +103,28 @@ function AddEmail() {
})()
)
.then(() => {
getEmails()
setConfirmationStep(true)
})
.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) {
return (
<Layout isError={isError} error={error}>

View File

@@ -2,13 +2,13 @@ import { postJSON } from '@/infrastructure/fetch-json'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import Notification from '@/shared/components/notification'
import getMeta from '@/utils/meta'
import { FormEvent, useState } from 'react'
import { Button } from 'react-bootstrap'
import { FormEvent, MouseEventHandler, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
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: 'input' | 'alert'
@@ -20,8 +20,12 @@ type ConfirmEmailFormProps = {
confirmationEndpoint: string
flow: string
resendEndpoint: string
successMessage: React.ReactNode
successButtonText: string
successMessage?: React.ReactNode
successButtonText?: string
email?: string
onSuccessfulConfirmation?: () => void
interstitial: boolean
onCancel?: () => void
}
export function ConfirmEmailForm({
@@ -30,6 +34,10 @@ export function ConfirmEmailForm({
resendEndpoint,
successMessage,
successButtonText,
email = getMeta('ol-email'),
onSuccessfulConfirmation,
interstitial,
onCancel,
}: ConfirmEmailFormProps) {
const { t } = useTranslation()
const [confirmationCode, setConfirmationCode] = useState('')
@@ -37,7 +45,6 @@ export function ConfirmEmailForm({
const [isConfirming, setIsConfirming] = useState(false)
const [isResending, setIsResending] = useState(false)
const [successRedirectPath, setSuccessRedirectPath] = useState('')
const email = getMeta('ol-email')
const { isReady } = useWaitForI18n()
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()
setIsConfirming(true)
setFeedback(null)
postJSON(confirmationEndpoint, {
body: {
code: confirmationCode,
},
})
.then(data => {
setSuccessRedirectPath(data?.redir || '/')
})
.catch(err => {
errorHandler(err, 'confirm')
})
.finally(() => {
setIsConfirming(false)
})
sendMB('email-verification-click', {
button: 'verify',
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)
setFeedback(null)
@@ -138,14 +143,10 @@ export function ConfirmEmailForm({
}
if (!isReady) {
return (
<Interstitial className="confirm-email" showLogo>
<LoadingSpinner />
</Interstitial>
)
return <LoadingSpinner />
}
if (successRedirectPath) {
if (successRedirectPath && successButtonText && successMessage) {
return (
<ConfirmEmailSuccessfullForm
successMessage={successMessage}
@@ -156,8 +157,12 @@ export function ConfirmEmailForm({
}
return (
<Interstitial className="confirm-email" showLogo>
<form onSubmit={submitHandler} onInvalid={invalidFormHandler}>
<form
onSubmit={submitHandler}
onInvalid={invalidFormHandler}
className="confirm-email-form"
>
<div className="confirm-email-form-inner">
{feedback?.type === 'alert' && (
<Notification
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
id="one-time-code"
className="form-control"
placeholder={t('enter_6_digit_code')}
inputMode="numeric"
@@ -194,39 +206,53 @@ export function ConfirmEmailForm({
</div>
<div className="form-actions">
<Button
disabled={isConfirming || isResending}
<OLButton
disabled={isResending}
type="submit"
bsStyle={null}
className="btn-primary"
isLoading={isConfirming}
bs3Props={{
loading: isConfirming ? (
<>
{t('confirming')}
<span>&hellip;</span>
</>
) : (
t('confirm')
),
}}
>
{isConfirming ? (
<>
{t('confirming')}
<span>&hellip;</span>
</>
) : (
t('confirm')
)}
</Button>
<Button
disabled={isConfirming || isResending}
{t('confirm')}
</OLButton>
<OLButton
variant="secondary"
disabled={isConfirming}
onClick={resendHandler}
bsStyle={null}
className="btn-secondary"
isLoading={isResending}
bs3Props={{
loading: isResending ? (
<>
{t('resending_confirmation_code')}
<span>&hellip;</span>
</>
) : (
t('resend_confirmation_code')
),
}}
>
{isResending ? (
<>
{t('resending_confirmation_code')}
<span>&hellip;</span>
</>
) : (
t('resend_confirmation_code')
)}
</Button>
{t('resend_confirmation_code')}
</OLButton>
{onCancel && (
<OLButton
variant="danger-ghost"
disabled={isConfirming || isResending}
onClick={onCancel}
>
{t('cancel')}
</OLButton>
)}
</div>
</form>
</Interstitial>
</div>
</form>
)
}
@@ -245,17 +271,15 @@ function ConfirmEmailSuccessfullForm({
}
return (
<Interstitial className="confirm-email" showLogo>
<form onSubmit={submitHandler}>
<div aria-live="polite">{successMessage}</div>
<form onSubmit={submitHandler}>
<div aria-live="polite">{successMessage}</div>
<div className="form-actions">
<Button type="submit" bsStyle={null} className="btn-primary">
{successButtonText}
</Button>
</div>
</form>
</Interstitial>
<div className="form-actions">
<OLButton type="submit" variant="primary">
{successButtonText}
</OLButton>
</div>
</form>
)
}

View File

@@ -1,5 +1,6 @@
import { ConfirmEmailForm } from './confirm-email'
import { ConfirmEmailForm } from './confirm-email-form'
import { useTranslation } from 'react-i18next'
import { Interstitial } from '@/shared/components/interstitial'
export default function ConfirmSecondaryEmailForm() {
const { t } = useTranslation()
@@ -13,12 +14,15 @@ export default function ConfirmSecondaryEmailForm() {
)
return (
<ConfirmEmailForm
successMessage={successMessage}
successButtonText={t('go_to_overleaf')}
confirmationEndpoint="/user/emails/confirm-secondary"
resendEndpoint="/user/emails/resend-secondary-confirmation"
flow="secondary"
/>
<Interstitial className="confirm-email" showLogo>
<ConfirmEmailForm
successMessage={successMessage}
successButtonText={t('go_to_overleaf')}
confirmationEndpoint="/user/emails/confirm-secondary"
resendEndpoint="/user/emails/resend-secondary-confirmation"
flow="secondary"
interstitial
/>
</Interstitial>
)
}

View File

@@ -15,6 +15,10 @@
gap: 12px;
padding-top: 20px;
}
label {
font-weight: normal;
}
}
.confirm-email-alert {

View File

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

View File

@@ -60,6 +60,19 @@ function resetFetchMock() {
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 () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
@@ -181,7 +194,8 @@ describe('<EmailsSection />', function () {
resetFetchMock()
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails', 200)
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
fireEvent.click(addAnotherEmailBtn)
const input = screen.getByLabelText(/email/i)
@@ -206,6 +220,7 @@ describe('<EmailsSection />', function () {
})
)
await confirmCodeForEmail(userEmailData.email)
screen.getByText(userEmailData.email)
})
@@ -222,7 +237,7 @@ describe('<EmailsSection />', function () {
resetFetchMock()
fetchMock
.get('/user/emails?ensureAffiliation=true', [])
.post('/user/emails', 400)
.post('/user/emails/secondary', 400)
fireEvent.click(addAnotherEmailBtn)
const input = screen.getByLabelText(/email/i)
@@ -340,7 +355,8 @@ describe('<EmailsSection />', function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post(/\/user\/emails/, 200)
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.click(
screen.getByRole('button', {
@@ -359,8 +375,12 @@ describe('<EmailsSection />', function () {
department: customDepartment,
})
screen.getByText(userEmailData.email)
screen.getByText(userEmailData.affiliation.institution.name)
screen.getByText(
`Enter the 6-digit confirmation code sent to ${userEmailData.email}.`
)
await confirmCodeForEmail(userEmailData.email)
screen.getByText(userEmailData.affiliation.role!, { exact: false })
screen.getByText(customDepartment, { exact: false })
})
@@ -485,7 +505,8 @@ describe('<EmailsSection />', function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post(/\/user\/emails/, 200)
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.click(
screen.getByRole('button', {
@@ -493,6 +514,8 @@ describe('<EmailsSection />', function () {
})
)
await confirmCodeForEmail(userEmailData.email)
const [[, request]] = fetchMock.calls(/\/user\/emails/)
expect(JSON.parse(request?.body?.toString() || '{}')).to.deep.include({
@@ -635,7 +658,8 @@ describe('<EmailsSection />', function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post('/user/emails', 200)
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.type(
screen.getByRole('textbox', { name: /role/i }),
@@ -651,6 +675,8 @@ describe('<EmailsSection />', function () {
})
)
await confirmCodeForEmail('user@autocomplete.edu')
await fetchMock.flush(true)
fetchMock.reset()