diff --git a/services/web/app/src/Features/Email/EmailBuilder.js b/services/web/app/src/Features/Email/EmailBuilder.js index 3e547c3974..0e46b1b8ea 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.js +++ b/services/web/app/src/Features/Email/EmailBuilder.js @@ -257,12 +257,12 @@ templates.confirmCode = NoCTAEmailTemplate({ return 'Confirm your email address' }, message(opts, isPlainText) { - const msg = opts.isSecondary - ? ['Use this 6-digit code to confirm your email address.'] - : [ + const msg = opts.welcomeUser + ? [ `Welcome to Overleaf! We're so glad you joined us.`, 'Use this 6-digit confirmation code to finish your setup.', ] + : ['Use this 6-digit code to confirm your email address.'] if (isPlainText && opts.confirmCode) { msg.push(opts.confirmCode) diff --git a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js index 792b003b7a..b349c97848 100644 --- a/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js +++ b/services/web/app/src/Features/User/UserEmailsConfirmationHandler.js @@ -43,7 +43,7 @@ function sendConfirmationEmail(userId, email, emailTemplate, callback) { ) } -async function sendConfirmationCode(email, isSecondary) { +async function sendConfirmationCode(email, welcomeUser) { if (!EmailHelper.parseEmail(email)) { throw new Error('invalid email') } @@ -55,7 +55,7 @@ async function sendConfirmationCode(email, isSecondary) { await EmailHandler.promises.sendEmail('confirmCode', { to: email, confirmCode, - isSecondary, + welcomeUser, category: ['ConfirmEmail'], }) diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.js index ca6da4dc53..8d4745e891 100644 --- a/services/web/app/src/Features/User/UserEmailsController.js +++ b/services/web/app/src/Features/User/UserEmailsController.js @@ -24,23 +24,19 @@ const SplitTestHandler = require('../SplitTests/SplitTestHandler') const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10 -const sendSecondaryConfirmCodeRateLimiter = new RateLimiter( - 'send-secondary-confirmation-code', - { - points: 1, - duration: 60, - } -) -const checkSecondaryConfirmCodeRateLimiter = new RateLimiter( - 'check-secondary-confirmation-code-per-email', +const sendConfirmCodeRateLimiter = new RateLimiter('send-confirmation-code', { + points: 1, + duration: 60, +}) +const checkConfirmCodeRateLimiter = new RateLimiter( + 'check-confirmation-code-per-email', { points: 10, duration: 60, } ) - -const resendSecondaryConfirmCodeRateLimiter = new RateLimiter( - 'resend-secondary-confirmation-code', +const resendConfirmCodeRateLimiter = new RateLimiter( + 'resend-confirmation-code', { points: 1, duration: 60, @@ -146,6 +142,23 @@ async function sendReconfirmation(req, res) { res.sendStatus(204) } +async function sendExistingSecondaryEmailConfirmationCode(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const email = EmailHelper.parseEmail(req.body.email) + if (!email) { + return res.sendStatus(400) + } + const user = await UserGetter.promises.getUserByAnyEmail(email, { + _id: 1, + email, + }) + if (!user || user._id.toString() !== userId) { + return res.sendStatus(422) + } + await sendCodeAndStoreInSession(req, 'pendingExistingEmail', email) + res.sendStatus(204) +} + /** * This method is for adding a secondary email to be confirmed via a code. * For email link confirmation see the `add` method in this file. @@ -177,7 +190,7 @@ async function addWithConfirmationCode(req, res) { try { await UserGetter.promises.ensureUniqueEmailAddress(email) - await sendSecondaryConfirmCodeRateLimiter.consume(email, 1, { + await sendConfirmCodeRateLimiter.consume(email, 1, { method: 'email', }) @@ -191,18 +204,12 @@ async function addWithConfirmationCode(req, res) { } ) - const { confirmCode, confirmCodeExpiresTimestamp } = - await UserEmailsConfirmationHandler.promises.sendConfirmationCode( - email, - true - ) - - req.session.pendingSecondaryEmail = { + await sendCodeAndStoreInSession( + req, + 'pendingSecondaryEmail', email, - confirmCode, - confirmCodeExpiresTimestamp, - affiliationOptions, - } + affiliationOptions + ) return res.sendStatus(200) } catch (err) { @@ -231,37 +238,132 @@ async function addWithConfirmationCode(req, res) { } } -async function checkSecondaryEmailConfirmationCode(req, res) { - const userId = SessionManager.getLoggedInUserId(req.session) - const code = req.body.code - const user = await UserGetter.promises.getUser(userId, { - email: 1, - 'emails.email': 1, - }) - - if (!req.session.pendingSecondaryEmail) { - logger.err( - {}, - 'error checking confirmation code. missing pendingSecondaryEmail' +/** + * @param {import('express').Request} req + * @param {string} sessionKey + * @param {string} email + * @param affiliationOptions + * @returns {Promise} + */ +async function sendCodeAndStoreInSession( + req, + sessionKey, + email, + affiliationOptions +) { + const { confirmCode, confirmCodeExpiresTimestamp } = + await UserEmailsConfirmationHandler.promises.sendConfirmationCode( + email, + false ) - - return res.status(500).json({ - message: { - key: 'error_performing_request', - }, - }) + req.session[sessionKey] = { + email, + confirmCode, + confirmCodeExpiresTimestamp, + affiliationOptions, } +} - const newSecondaryEmail = req.session.pendingSecondaryEmail.email - - try { - await checkSecondaryConfirmCodeRateLimiter.consume(newSecondaryEmail, 1, { - method: 'email', +/** + * @param {string} sessionKey + * @param {(req: import('express').Request, user: any, email: string, affiliationOptions: any) => Promise} beforeConfirmEmail + * @returns {Promise<*>} + */ +const _checkConfirmationCode = + (sessionKey, beforeConfirmEmail) => async (req, res) => { + const userId = SessionManager.getLoggedInUserId(req.session) + const code = req.body.code + const user = await UserGetter.promises.getUser(userId, { + email: 1, + 'emails.email': 1, }) - } catch (err) { - if (err?.remainingPoints === 0) { - return res.sendStatus(429) - } else { + + const sessionData = req.session[sessionKey] + + if (!sessionData) { + logger.err({}, `error checking confirmation code. missing ${sessionKey}`) + + return res.status(422).json({ + message: { + key: 'error_performing_request', + }, + }) + } + + const emailToCheck = sessionData.email + + try { + await checkConfirmCodeRateLimiter.consume(emailToCheck, 1, { + method: 'email', + }) + } catch (err) { + if (err?.remainingPoints === 0) { + return res.sendStatus(429) + } else { + return res.status(500).json({ + message: { + key: 'error_performing_request', + }, + }) + } + } + + if (sessionData.confirmCodeExpiresTimestamp < Date.now()) { + return res.status(403).json({ + message: { key: 'expired_confirmation_code' }, + }) + } + + if (!tsscmp(sessionData.confirmCode, code)) { + return res.status(403).json({ + message: { key: 'invalid_confirmation_code' }, + }) + } + + try { + await beforeConfirmEmail( + req, + user, + emailToCheck, + sessionData.affiliationOptions + ) + + await UserUpdater.promises.confirmEmail( + userId, + emailToCheck, + sessionData.affiliationOptions + ) + + delete req.session[sessionKey] + + AnalyticsManager.recordEventForUserInBackground( + user._id, + 'email-verified', + { + provider: 'email', + verification_type: 'token', + isPrimary: user.email === emailToCheck, + } + ) + + const redirectUrl = + AuthenticationController.getRedirectFromSession(req) || '/project' + + return res.json({ + redir: redirectUrl, + }) + } catch (error) { + if (error.name === 'EmailExistsError') { + return res.status(409).json({ + message: { + type: 'error', + text: req.i18n.translate('email_already_registered'), + }, + }) + } + + logger.err({ error }, 'failed to check confirmation code') + return res.status(500).json({ message: { key: 'error_performing_request', @@ -270,147 +372,104 @@ async function checkSecondaryEmailConfirmationCode(req, res) { } } - if ( - req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp < Date.now() - ) { - return res.status(403).json({ - message: { key: 'expired_confirmation_code' }, - }) - } - - if (!tsscmp(req.session.pendingSecondaryEmail.confirmCode, code)) { - return res.status(403).json({ - message: { key: 'invalid_confirmation_code' }, - }) - } - - try { +const checkNewSecondaryEmailConfirmationCode = _checkConfirmationCode( + 'pendingSecondaryEmail', + async (req, user, email, affiliationOptions) => { await UserAuditLogHandler.promises.addEntry( - userId, + user._id, 'add-email-via-code', - userId, + user._id, req.ip, - { newSecondaryEmail } + { newSecondaryEmail: email } ) - - await _sendSecurityAlertEmail(user, newSecondaryEmail) - + await _sendSecurityAlertEmail(user, email) await UserUpdater.promises.addEmailAddress( - userId, - newSecondaryEmail, - req.session.pendingSecondaryEmail.affiliationOptions, + user._id, + email, + affiliationOptions, { initiatorId: user._id, ipAddress: req.ip, } ) + } +) - await UserUpdater.promises.confirmEmail( - userId, - newSecondaryEmail, - req.session.pendingSecondaryEmail.affiliationOptions - ) - - delete req.session.pendingSecondaryEmail - - AnalyticsManager.recordEventForUserInBackground( +const checkExistingEmailConfirmationCode = _checkConfirmationCode( + 'pendingExistingEmail', + async (req, user, email) => { + await UserAuditLogHandler.promises.addEntry( user._id, - 'email-verified', - { - provider: 'email', - verification_type: 'token', - isPrimary: false, - } + 'confirm-email-via-code', + user._id, + req.ip, + { email } ) + } +) - const redirectUrl = - AuthenticationController.getRedirectFromSession(req) || '/project' - - return res.json({ - redir: redirectUrl, - }) - } catch (error) { - if (error.name === 'EmailExistsError') { - return res.status(409).json({ +const _resendConfirmationCode = + (sessionKey, operation, auditLogEmailKey) => async (req, res) => { + const sessionData = req.session[sessionKey] + if (!sessionData) { + logger.err({}, `error resending confirmation code. missing ${sessionKey}`) + return res.status(422).json({ message: { - type: 'error', - text: req.i18n.translate('email_already_registered'), + key: 'error_performing_request', }, }) } - logger.err({ error }, 'failed to check confirmation code') + const email = sessionData.email - return res.status(500).json({ - message: { - key: 'error_performing_request', - }, - }) - } -} + try { + await resendConfirmCodeRateLimiter.consume(email, 1, { method: 'email' }) + } catch (err) { + if (err?.remainingPoints === 0) { + return res.status(429).json({}) + } else { + throw err + } + } -async function resendSecondaryEmailConfirmationCode(req, res) { - if (!req.session.pendingSecondaryEmail) { - logger.err( - {}, - 'error resending confirmation code. missing pendingSecondaryEmail' - ) + const userId = SessionManager.getLoggedInUserId(req.session) - return res.status(500).json({ - message: { - key: 'error_performing_request', - }, - }) - } + try { + await UserAuditLogHandler.promises.addEntry( + userId, + operation, + userId, + req.ip, + { [auditLogEmailKey]: email } + ) - const email = req.session.pendingSecondaryEmail.email + const { confirmCode, confirmCodeExpiresTimestamp } = + await UserEmailsConfirmationHandler.promises.sendConfirmationCode( + email, + false + ) - try { - await resendSecondaryConfirmCodeRateLimiter.consume(email, 1, { - method: 'email', - }) - } catch (err) { - if (err?.remainingPoints === 0) { - return res.status(429).json({}) - } else { - throw err + sessionData.confirmCode = confirmCode + sessionData.confirmCodeExpiresTimestamp = confirmCodeExpiresTimestamp + + return res.status(200).json({ message: { key: 'we_sent_new_code' } }) + } catch (err) { + logger.err({ err, userId, email }, 'failed to send confirmation code') + return res.status(500).json({ key: 'error_performing_request' }) } } - try { - const userId = SessionManager.getLoggedInUserId(req.session) +const resendNewSecondaryEmailConfirmationCode = _resendConfirmationCode( + 'pendingSecondaryEmail', + 'resend-add-email-code', + 'newSecondaryEmail' +) - await UserAuditLogHandler.promises.addEntry( - userId, - 'resend-add-email-code', - userId, - req.ip, - { - newSecondaryEmail: email, - } - ) - - const { confirmCode, confirmCodeExpiresTimestamp } = - await UserEmailsConfirmationHandler.promises.sendConfirmationCode( - email, - true - ) - - req.session.pendingSecondaryEmail.confirmCode = confirmCode - req.session.pendingSecondaryEmail.confirmCodeExpiresTimestamp = - confirmCodeExpiresTimestamp - - return res.status(200).json({ - message: { key: 'we_sent_new_code' }, - }) - } catch (err) { - logger.err({ err, email }, 'failed to send confirmation code') - - return res.status(500).json({ - key: 'error_performing_request', - }) - } -} +const resendExistingSecondaryEmailConfirmationCode = _resendConfirmationCode( + 'pendingExistingEmail', + 'resend-confirm-email-code', + 'email' +) async function confirmSecondaryEmailPage(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) @@ -623,12 +682,23 @@ const UserEmailsController = { }, add: expressify(add), + addWithConfirmationCode: expressify(addWithConfirmationCode), - checkSecondaryEmailConfirmationCode: expressify( - checkSecondaryEmailConfirmationCode + + checkNewSecondaryEmailConfirmationCode: expressify( + checkNewSecondaryEmailConfirmationCode ), - resendSecondaryEmailConfirmationCode: expressify( - resendSecondaryEmailConfirmationCode + + checkExistingEmailConfirmationCode: expressify( + checkExistingEmailConfirmationCode + ), + + resendNewSecondaryEmailConfirmationCode: expressify( + resendNewSecondaryEmailConfirmationCode + ), + + resendExistingSecondaryEmailConfirmationCode: expressify( + resendExistingSecondaryEmailConfirmationCode ), remove: expressify(remove), @@ -660,6 +730,10 @@ const UserEmailsController = { sendReconfirmation: expressify(sendReconfirmation), + sendExistingSecondaryEmailConfirmationCode: expressify( + sendExistingSecondaryEmailConfirmationCode + ), + addSecondaryEmailPage: expressify(addSecondaryEmailPage), confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage), diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index c213689435..830620506a 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -180,6 +180,10 @@ const rateLimiters = { points: 1, duration: 60, }), + sendConfirmation: new RateLimiter('send-confirmation', { + points: 1, + duration: 60, + }), sendChatMessage: new RateLimiter('send-chat-message', { points: 100, duration: 60, @@ -335,6 +339,28 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail), UserEmailsController.confirm ) + + webRouter.post( + '/user/emails/send-confirmation-code', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.sendConfirmation), + UserEmailsController.sendExistingSecondaryEmailConfirmationCode + ) + + webRouter.post( + '/user/emails/resend-confirmation-code', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmation), + UserEmailsController.resendExistingSecondaryEmailConfirmationCode + ) + + webRouter.post( + '/user/emails/confirm-code', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail), + UserEmailsController.checkExistingEmailConfirmationCode + ) + webRouter.post( '/user/emails/resend_confirmation', AuthenticationController.requireLogin(), diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 8d8380cfc1..ad684dd166 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -508,6 +508,7 @@ "enter_6_digit_code": "", "enter_any_size_including_units_or_valid_latex_command": "", "enter_image_url": "", + "enter_the_code": "", "enter_the_confirmation_code": "", "enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "", "equation_generator": "", @@ -1184,7 +1185,6 @@ "please_ask_the_project_owner_to_upgrade_more_editors": "", "please_ask_the_project_owner_to_upgrade_to_track_changes": "", "please_change_primary_to_remove": "", - "please_check_your_inbox": "", "please_check_your_inbox_to_confirm": "", "please_compile_pdf_before_download": "", "please_compile_pdf_before_word_count": "", @@ -1975,6 +1975,7 @@ "we_do_not_share_personal_information": "", "we_got_your_request": "", "we_logged_you_in": "", + "we_sent_code": "", "we_sent_new_code": "", "we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "", "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "", diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx index a2884c91f1..83a5f0bf0a 100644 --- a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx +++ b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx @@ -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 =
{t('confirm_your_email')}
+ if (isModal) intro =
{t('we_sent_code')}
+ if (interstitial) + intro = ( +

{t('confirm_your_email')}

+ ) + return (
)} - {interstitial ? ( -

{t('confirm_your_email')}

- ) : ( -
{t('confirm_your_email')}
- )} + {intro} - {t('enter_the_confirmation_code', { email })} + {isModal + ? t('enter_the_code', { email }) + : t('enter_the_confirmation_code', { email })} - - {t('unconfirmed')}. - {!ssoAvailable && {t('please_check_your_inbox')}.} - + {t('unconfirmed')}.
{!ssoAvailable && ( - + )} )} diff --git a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx new file mode 100644 index 0000000000..0c7b1394fe --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-code-modal.tsx @@ -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 ( + <> + {t('sending')}… + + ) + } + + const rateLimited = + error && error instanceof FetchError && error.response?.status === 429 + + return ( + <> + {modalVisible && ( + setModalVisible(false)} + id="action-project-modal" + backdrop="static" + > + + {t('confirm_your_email')} + + + + { + getEmails() + setModalVisible(false) + }} + /> + + + setModalVisible(false)} + > + {t('cancel')} + + + + )} + + {t('resend_confirmation_code')} + +
+ {isError && ( +
+ {rateLimited + ? t('too_many_requests') + : t('generic_something_went_wrong')} +
+ )} + + ) +} + +export default ResendConfirmationCodeModal diff --git a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx deleted file mode 100644 index e4696cfd72..0000000000 --- a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx +++ /dev/null @@ -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 ( - <> - {t('sending')}… - - ) - } - - const rateLimited = - error && error instanceof FetchError && error.response?.status === 429 - - return ( - <> - - {t('resend_confirmation_email')} - -
- {isError && ( -
- {rateLimited - ? t('too_many_requests') - : t('generic_something_went_wrong')} -
- )} - - ) -} - -export default ResendConfirmationEmailButton 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 a27389429f..07350fd24a 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/account-settings.scss @@ -204,8 +204,9 @@ } } -#settings-page-root { - .confirm-email-form { +#settings-page-root, +#action-project-modal { + &#settings-page-root .confirm-email-form { background: var(--bg-light-secondary); } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 034d1e3c15..8b615beeb0 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -654,6 +654,7 @@ "enter_6_digit_code": "Enter 6-digit code", "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command", "enter_image_url": "Enter image URL", + "enter_the_code": "Enter the 6-digit code sent to __email__.", "enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.", "enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of users you’d like to add to see the cost breakdown.", "enter_your_email_address": "Enter your email address", @@ -1583,7 +1584,6 @@ "please_ask_the_project_owner_to_upgrade_more_editors": "Please ask the project owner to upgrade their plan to allow more editors.", "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": "Please check your inbox", "please_check_your_inbox_to_confirm": "Please check your email inbox to confirm your <0>__institutionName__ 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", @@ -2520,6 +2520,7 @@ "we_got_your_request": "We’ve got your request", "we_logged_you_in": "We have logged you in.", "we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you from time to time by email with a survey, or to see if you would like to participate in other user research initiatives", + "we_sent_code": "We’ve sent you a confirmation code", "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.", "we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "We’ll charge you now for the cost of your additional users based on the remaining months of your current subscription.", "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "We’ll charge you now for your new plan based on the remaining months of your current subscription.", diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx index 622993a23d..ec05d1e90d 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -81,7 +81,7 @@ describe('', function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) render() - await screen.findByText(/please check your inbox/i) + await screen.findByText(/unconfirmed/i) }) it('hides confirmation status for confirmed users', async function () { @@ -96,7 +96,7 @@ describe('', function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) render() - await screen.findByRole('button', { name: /resend confirmation email/i }) + await screen.findByRole('button', { name: /resend confirmation code/i }) }) it('renders professional label', async function () { @@ -115,16 +115,16 @@ describe('', function () { render() await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) - fetchMock.post('/user/emails/resend_confirmation', 200) + fetchMock.post('/user/emails/send-confirmation-code', 200) const button = screen.getByRole('button', { - name: /resend confirmation email/i, + name: /resend confirmation code/i, }) fireEvent.click(button) expect( screen.queryByRole('button', { - name: /resend confirmation email/i, + name: /resend confirmation code/i, }) ).to.be.null @@ -135,7 +135,7 @@ describe('', function () { ).to.be.null await screen.findByRole('button', { - name: /resend confirmation email/i, + name: /resend confirmation code/i, }) }) @@ -145,22 +145,19 @@ describe('', function () { render() await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) - fetchMock.post('/user/emails/resend_confirmation', 503) + fetchMock.post('/user/emails/send-confirmation-code', 503) const button = screen.getByRole('button', { - name: /resend confirmation email/i, + name: /resend confirmation code/i, }) fireEvent.click(button) - expect( - screen.queryByRole('button', { - name: /resend confirmation email/i, - }) - ).to.be.null + expect(screen.queryByRole('button', { name: /resend confirmation code/i })) + .to.be.null await waitForElementToBeRemoved(() => screen.getByText(/sending/i)) screen.getByText(/sorry, something went wrong/i) - screen.getByRole('button', { name: /resend confirmation email/i }) + screen.getByRole('button', { name: /resend confirmation code/i }) }) }) diff --git a/services/web/test/unit/src/User/UserEmailsControllerTests.js b/services/web/test/unit/src/User/UserEmailsControllerTests.js index 926cd6c816..ae94194084 100644 --- a/services/web/test/unit/src/User/UserEmailsControllerTests.js +++ b/services/web/test/unit/src/User/UserEmailsControllerTests.js @@ -311,7 +311,7 @@ describe('UserEmailsController', function () { assertCalledWith( this.UserEmailsConfirmationHandler.promises.sendConfirmationCode, this.newEmail, - true + false ) done() }, @@ -360,7 +360,7 @@ describe('UserEmailsController', function () { }) }) - describe('checkSecondaryEmailConfirmationCode', function () { + describe('checkNewSecondaryEmailConfirmationCode', function () { beforeEach(function () { this.newEmail = 'new_email@baz.com' this.req.session.pendingSecondaryEmail = { @@ -378,7 +378,7 @@ describe('UserEmailsController', function () { }) it('adds the email', function (done) { - this.UserEmailsController.checkSecondaryEmailConfirmationCode( + this.UserEmailsController.checkNewSecondaryEmailConfirmationCode( this.req, { json: () => { @@ -399,7 +399,7 @@ describe('UserEmailsController', function () { }) it('redirects to /project', function (done) { - this.UserEmailsController.checkSecondaryEmailConfirmationCode( + this.UserEmailsController.checkNewSecondaryEmailConfirmationCode( this.req, { json: ({ redir }) => { @@ -419,7 +419,7 @@ describe('UserEmailsController', function () { } this.req.body.code = '123456' - await this.UserEmailsController.checkSecondaryEmailConfirmationCode( + await this.UserEmailsController.checkNewSecondaryEmailConfirmationCode( this.req, { json: sinon.stub().resolves(), @@ -444,7 +444,7 @@ describe('UserEmailsController', function () { }) it('does not add the email', function (done) { - this.UserEmailsController.checkSecondaryEmailConfirmationCode( + this.UserEmailsController.checkNewSecondaryEmailConfirmationCode( this.req, { status: () => { @@ -458,7 +458,7 @@ describe('UserEmailsController', function () { }) it('responds with a 403', function (done) { - this.UserEmailsController.checkSecondaryEmailConfirmationCode( + this.UserEmailsController.checkNewSecondaryEmailConfirmationCode( this.req, { status: code => { @@ -472,7 +472,7 @@ describe('UserEmailsController', function () { }) }) - describe('resendSecondaryEmailConfirmationCode', function () { + describe('resendNewSecondaryEmailConfirmationCode', function () { beforeEach(function () { this.newEmail = 'new_email@baz.com' this.req.session.pendingSecondaryEmail = { @@ -489,18 +489,21 @@ describe('UserEmailsController', function () { }) it('should send the email', function (done) { - this.UserEmailsController.resendSecondaryEmailConfirmationCode(this.req, { - status: code => { - code.should.equal(200) - assertCalledWith( - this.UserEmailsConfirmationHandler.promises.sendConfirmationCode, - this.newEmail, - true - ) - done() - return { json: this.next } - }, - }) + this.UserEmailsController.resendNewSecondaryEmailConfirmationCode( + this.req, + { + status: code => { + code.should.equal(200) + assertCalledWith( + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode, + this.newEmail, + false + ) + done() + return { json: this.next } + }, + } + ) }) }) @@ -906,4 +909,301 @@ describe('UserEmailsController', function () { }) }) }) + + describe('sendExistingSecondaryEmailConfirmationCode', function () { + beforeEach(function () { + this.email = 'existing-email@example.com' + this.req.body.email = this.email + this.EmailHelper.parseEmail.returns(this.email) + this.UserGetter.promises.getUserByAnyEmail.resolves({ + _id: this.user._id, + email: this.email, + }) + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon + .stub() + .resolves({ + confirmCode: '123456', + confirmCodeExpiresTimestamp: new Date(), + }) + }) + + it('should send confirmation code for existing email', async function () { + await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + this.req, + { + sendStatus: code => { + code.should.equal(204) + assertCalledWith( + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode, + this.email, + false + ) + }, + } + ) + }) + + it('should store confirmation code in session', async function () { + const confirmCode = '123456' + const confirmCodeExpiresTimestamp = new Date() + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves( + { confirmCode, confirmCodeExpiresTimestamp } + ) + await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + this.req, + { sendStatus: sinon.stub() } + ) + expect(this.req.session.pendingExistingEmail).to.deep.equal({ + email: this.email, + confirmCode, + confirmCodeExpiresTimestamp, + affiliationOptions: undefined, + }) + }) + + it('should handle invalid email', async function () { + this.EmailHelper.parseEmail.returns(null) + await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + this.req, + { + sendStatus: code => { + code.should.equal(400) + assertNotCalled( + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode + ) + }, + } + ) + }) + + it('should handle email not belonging to user', async function () { + this.UserGetter.promises.getUserByAnyEmail.resolves({ + _id: 'another-user-id', + }) + await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + this.req, + { + sendStatus: code => { + code.should.equal(422) + assertNotCalled( + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode + ) + }, + } + ) + }) + }) + + describe('checkExistingEmailConfirmationCode', function () { + beforeEach(function () { + this.email = 'existing-email@example.com' + this.req.session.pendingExistingEmail = { + confirmCode: '123456', + email: this.email, + confirmCodeExpiresTimestamp: new Date(Math.max), + } + this.UserUpdater.promises.confirmEmail.resolves() + this.res = { + json: sinon.stub(), + status: sinon.stub().returns({ json: sinon.stub() }), + } + }) + + describe('with a valid confirmation code', function () { + beforeEach(function () { + this.req.body = { code: '123456' } + }) + + it('confirms the email', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { + json: () => { + assertCalledWith( + this.UserUpdater.promises.confirmEmail, + this.user._id, + this.email + ) + }, + } + ) + }) + + it('adds audit log entry', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { json: sinon.stub() } + ) + assertCalledWith( + this.UserAuditLogHandler.promises.addEntry, + this.user._id, + 'confirm-email-via-code', + this.user._id, + this.req.ip, + { email: this.email } + ) + }) + + it('records analytics event', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { json: sinon.stub() } + ) + assertCalledWith( + this.AnalyticsManager.recordEventForUserInBackground, + this.user._id, + 'email-verified', + { + provider: 'email', + verification_type: 'token', + isPrimary: this.user.email === this.email, + } + ) + }) + + it('removes pendingExistingEmail from session', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { json: sinon.stub() } + ) + + expect(this.req.session.pendingExistingEmail).to.be.undefined + }) + }) + + describe('with an invalid confirmation code', function () { + beforeEach(function () { + this.req.body = { code: '999999' } + }) + + it('does not confirm the email', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { + status: () => { + assertNotCalled(this.UserUpdater.promises.confirmEmail) + return { json: this.next } + }, + } + ) + }) + + it('responds with a 403', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { + status: code => { + code.should.equal(403) + return { json: this.next } + }, + } + ) + }) + }) + + describe('with an expired confirmation code', function () { + beforeEach(function () { + this.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp = + new Date(0) + this.req.body = { code: '123456' } + }) + + it('responds with a 403', async function () { + await this.UserEmailsController.checkExistingEmailConfirmationCode( + this.req, + { + status: code => { + code.should.equal(403) + return { json: this.next } + }, + } + ) + }) + }) + }) + + describe('resendExistingSecondaryEmailConfirmationCode', function () { + beforeEach(function () { + this.email = 'existing-email@example.com' + this.req.session.pendingExistingEmail = { + confirmCode: '123456', + email: this.email, + confirmCodeExpiresTimestamp: new Date(Math.max), + } + this.res.status = sinon.stub().returns({ json: sinon.stub() }) + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode = sinon + .stub() + .resolves({ + confirmCode: '654321', + confirmCodeExpiresTimestamp: new Date(), + }) + }) + + it('should resend confirmation code', async function () { + await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode( + this.req, + { + status: code => { + code.should.equal(200) + assertCalledWith( + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode, + this.email, + false + ) + return { json: sinon.stub() } + }, + } + ) + }) + + it('should update session with new code', async function () { + const newCode = '654321' + const newExpiryTime = new Date() + this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves( + { + confirmCode: newCode, + confirmCodeExpiresTimestamp: newExpiryTime, + } + ) + await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode( + this.req, + { status: () => ({ json: sinon.stub() }) } + ) + expect(this.req.session.pendingExistingEmail.confirmCode).to.equal( + newCode + ) + expect( + this.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp + ).to.equal(newExpiryTime) + }) + + it('should add audit log entry', async function () { + await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode( + this.req, + { status: () => ({ json: sinon.stub() }) } + ) + + assertCalledWith( + this.UserAuditLogHandler.promises.addEntry, + this.user._id, + 'resend-confirm-email-code', + this.user._id, + this.req.ip, + { email: this.email } + ) + }) + + it('should handle rate limiting', async function () { + this.rateLimiter.consume.rejects({ remainingPoints: 0 }) + await this.UserEmailsController.resendExistingSecondaryEmailConfirmationCode( + this.req, + { + status: code => { + code.should.equal(429) + return { json: sinon.stub() } + }, + } + ) + }) + }) })