[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:
@@ -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),
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
+13
-6
@@ -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>
|
||||
)}
|
||||
|
||||
+116
@@ -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')}…
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
-70
@@ -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')}…
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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__</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": "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</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": "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.",
|
||||
|
||||
+11
-14
@@ -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() }
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user