diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.js index 2a25859baa..e3c8e7e0b1 100644 --- a/services/web/app/src/Features/User/UserEmailsController.js +++ b/services/web/app/src/Features/User/UserEmailsController.js @@ -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 diff --git a/services/web/frontend/js/features/settings/components/emails/add-email.tsx b/services/web/frontend/js/features/settings/components/emails/add-email.tsx index a5b98ba196..a547255f45 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email.tsx @@ -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(null) const [countryCode, setCountryCode] = useState(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 ( + { + setConfirmationStep(false) + setIsFormVisible(false) + }} + /> + ) + } + if (!isFormVisible) { return ( diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-email.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx similarity index 66% rename from services/web/frontend/js/features/settings/components/emails/confirm-email.tsx rename to services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx index c4ba1cdf85..a2884c91f1 100644 --- a/services/web/frontend/js/features/settings/components/emails/confirm-email.tsx +++ b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx @@ -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) => { + const submitHandler = async (e: FormEvent) => { 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 - + {t('resend_confirmation_code')} + + {onCancel && ( + + {t('cancel')} + + )} - - + + ) } @@ -245,17 +271,15 @@ function ConfirmEmailSuccessfullForm({ } return ( - -
-
{successMessage}
+ +
{successMessage}
-
- -
-
-
+
+ + {successButtonText} + +
+ ) } diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-secondary-email-form.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-secondary-email-form.tsx index eb97ead750..3eaee4b2af 100644 --- a/services/web/frontend/js/features/settings/components/emails/confirm-secondary-email-form.tsx +++ b/services/web/frontend/js/features/settings/components/emails/confirm-secondary-email-form.tsx @@ -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 ( - + + + ) } diff --git a/services/web/frontend/stylesheets/app/confirm-email.less b/services/web/frontend/stylesheets/app/confirm-email.less index 8ea0b2d9fb..741cd2331e 100644 --- a/services/web/frontend/stylesheets/app/confirm-email.less +++ b/services/web/frontend/stylesheets/app/confirm-email.less @@ -15,6 +15,10 @@ gap: 12px; padding-top: 20px; } + + label { + font-weight: normal; + } } .confirm-email-alert { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss index 3b6f0d63f2..a27389429f 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss @@ -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; + } + } + } +} diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx index 9f2aab9dd7..cf7d305aea 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx @@ -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('button', { + name: 'Confirm', + }) + fireEvent.click(submitCodeBtn) + await waitForElementToBeRemoved(() => + screen.getByRole('button', { name: /confirming/i }) + ) +} + describe('', function () { beforeEach(function () { Object.assign(getMeta('ol-ExposedSettings'), { @@ -181,7 +194,8 @@ describe('', 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('', function () { }) ) + await confirmCodeForEmail(userEmailData.email) screen.getByText(userEmailData.email) }) @@ -222,7 +237,7 @@ describe('', 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('', 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('', 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('', 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('', 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('', 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('', function () { }) ) + await confirmCodeForEmail('user@autocomplete.edu') + await fetchMock.flush(true) fetchMock.reset()