[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:47:15 +01:00
committed by Copybot
parent e52f3543a7
commit c4e6dfbbbd
13 changed files with 743 additions and 293 deletions
@@ -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)
@@ -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'],
})
@@ -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<void>}
*/
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<void>} 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),
+26
View File
@@ -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(),
@@ -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": "",
@@ -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
@@ -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);
}
+2 -1
View File
@@ -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 youd 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__</0> 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": "Weve 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</0> 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": "Weve sent you a confirmation code",
"we_sent_new_code": "Weve sent a new code. If it doesnt 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": "Well 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": "Well charge you now for your new plan based on the remaining months of your current subscription.",
@@ -81,7 +81,7 @@ describe('<EmailsSection />', function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
render(<EmailsSection />)
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('<EmailsSection />', function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
render(<EmailsSection />)
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('<EmailsSection />', function () {
render(<EmailsSection />)
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('<EmailsSection />', function () {
).to.be.null
await screen.findByRole('button', {
name: /resend confirmation email/i,
name: /resend confirmation code/i,
})
})
@@ -145,22 +145,19 @@ describe('<EmailsSection />', function () {
render(<EmailsSection />)
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 })
})
})
@@ -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() }
},
}
)
})
})
})