Merge pull request #18320 from overleaf/dp-add-secondary-prompt-ui

Add secondary email form V2 (with Captcha this time)

GitOrigin-RevId: b06216a2c9cb5b3b09305a17992eca506a0047f5
This commit is contained in:
David 2024-05-22 09:38:22 +01:00 committed by Copybot
parent 5aea030184
commit 635aae7b1f
22 changed files with 837 additions and 59 deletions

View file

@ -17,7 +17,9 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler') const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
const UserAuditLogHandler = require('./UserAuditLogHandler') const UserAuditLogHandler = require('./UserAuditLogHandler')
const { RateLimiter } = require('../../infrastructure/RateLimiter') const { RateLimiter } = require('../../infrastructure/RateLimiter')
const Features = require('../../infrastructure/Features')
const tsscmp = require('tsscmp') const tsscmp = require('tsscmp')
const Modules = require('../../infrastructure/Modules')
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10 const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
@ -208,9 +210,7 @@ async function addWithConfirmationCode(req, res) {
confirmCodeExpiresTimestamp, confirmCodeExpiresTimestamp,
} }
return res.json({ return res.sendStatus(200)
redir: '/user/emails/confirm-secondary',
})
} catch (err) { } catch (err) {
if (err.name === 'EmailExistsError') { if (err.name === 'EmailExistsError') {
return res.status(409).json({ return res.status(409).json({
@ -418,6 +418,45 @@ async function resendSecondaryEmailConfirmationCode(req, res) {
} }
} }
async function confirmSecondaryEmailPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!req.session.pendingSecondaryEmail) {
const redirectURL =
AuthenticationController.getRedirectFromSession(req) || '/project'
return res.redirect(redirectURL)
}
AnalyticsManager.recordEventForUser(
userId,
'confirm-secondary-email-page-displayed'
)
res.render('user/confirmSecondaryEmail', {
email: req.session.pendingSecondaryEmail.email,
})
}
async function addSecondaryEmailPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const confirmedEmails =
await UserGetter.promises.getUserConfirmedEmails(userId)
if (confirmedEmails.length >= 2) {
const redirectURL =
AuthenticationController.getRedirectFromSession(req) || '/project'
return res.redirect(redirectURL)
}
AnalyticsManager.recordEventForUser(
userId,
'add-secondary-email-page-displayed'
)
res.render('user/addSecondaryEmail')
}
async function primaryEmailCheckPage(req, res) { async function primaryEmailCheckPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const user = await UserGetter.promises.getUser(userId, { const user = await UserGetter.promises.getUser(userId, {
@ -443,10 +482,35 @@ async function primaryEmailCheck(req, res) {
await UserUpdater.promises.updateUser(userId, { await UserUpdater.promises.updateUser(userId, {
$set: { lastPrimaryEmailCheck: new Date() }, $set: { lastPrimaryEmailCheck: new Date() },
}) })
AnalyticsManager.recordEventForUserInBackground( AnalyticsManager.recordEventForUserInBackground(
userId, userId,
'primary-email-check-done' 'primary-email-check-done'
) )
// We want to redirect to prompt a user to add a secondary email if their primary
// is an institutional email and they dont' already have a secondary.
if (Features.hasFeature('saas') && req.capabilitySet.has('add-affiliation')) {
const confirmedEmails =
await UserGetter.promises.getUserConfirmedEmails(userId)
if (confirmedEmails.length < 2) {
const { email: primaryEmail } = SessionManager.getSessionUser(req.session)
const primaryEmailDomain = EmailHelper.getDomain(primaryEmail)
const institution = (
await Modules.promises.hooks.fire(
'getInstitutionViaDomain',
primaryEmailDomain
)
)?.[0]
if (institution) {
return AsyncFormHelper.redirect(req, res, '/user/emails/add-secondary')
}
}
}
AsyncFormHelper.redirect(req, res, '/project') AsyncFormHelper.redirect(req, res, '/project')
} }
@ -558,6 +622,10 @@ const UserEmailsController = {
sendReconfirmation, sendReconfirmation,
addSecondaryEmailPage: expressify(addSecondaryEmailPage),
confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage),
primaryEmailCheckPage: expressify(primaryEmailCheckPage), primaryEmailCheckPage: expressify(primaryEmailCheckPage),
primaryEmailCheck: expressify(primaryEmailCheck), primaryEmailCheck: expressify(primaryEmailCheck),

View file

@ -79,6 +79,18 @@ async function getUserFullEmails(userId) {
) )
} }
async function getUserConfirmedEmails(userId) {
const user = await UserGetter.promises.getUser(userId, {
emails: 1,
})
if (!user) {
throw new Error('User not Found')
}
return user.emails.filter(email => !!email.confirmedAt)
}
async function getSsoUsersAtInstitution(institutionId, projection) { async function getSsoUsersAtInstitution(institutionId, projection) {
if (!projection) { if (!projection) {
throw new Error('missing projection') throw new Error('missing projection')
@ -124,6 +136,8 @@ const UserGetter = {
getUserFullEmails: callbackify(getUserFullEmails), getUserFullEmails: callbackify(getUserFullEmails),
getUserConfirmedEmails: callbackify(getUserConfirmedEmails),
getUserByMainEmail(email, projection, callback) { getUserByMainEmail(email, projection, callback) {
email = email.trim() email = email.trim()
if (arguments.length === 2) { if (arguments.length === 2) {

View file

@ -336,6 +336,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
webRouter.post( webRouter.post(
'/user/emails/primary-email-check', '/user/emails/primary-email-check',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),
PermissionsController.useCapabilities(),
UserEmailsController.primaryEmailCheck UserEmailsController.primaryEmailCheck
) )
@ -348,6 +349,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
CaptchaMiddleware.validateCaptcha('addEmail'), CaptchaMiddleware.validateCaptcha('addEmail'),
UserEmailsController.add UserEmailsController.add
) )
webRouter.post( webRouter.post(
'/user/emails/delete', '/user/emails/delete',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),
@ -366,29 +368,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail), RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail),
UserEmailsController.endorse UserEmailsController.endorse
) )
}
webRouter.post( if (Features.hasFeature('saas')) {
'/user/emails/secondary', webRouter.get(
'/user/emails/add-secondary',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),
PermissionsController.requirePermission('add-secondary-email'), PermissionsController.requirePermission('add-secondary-email'),
RateLimiterMiddleware.rateLimit(rateLimiters.addEmail), UserEmailsController.addSecondaryEmailPage
UserEmailsController.addWithConfirmationCode
) )
webRouter.post( webRouter.get(
'/user/emails/confirm-secondary', '/user/emails/confirm-secondary',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),
PermissionsController.requirePermission('add-secondary-email'), PermissionsController.requirePermission('add-secondary-email'),
RateLimiterMiddleware.rateLimit(rateLimiters.checkEmailConfirmationCode), UserEmailsController.confirmSecondaryEmailPage
UserEmailsController.checkSecondaryEmailConfirmationCode
)
webRouter.post(
'/user/emails/resend-secondary-confirmation',
AuthenticationController.requireLogin(),
PermissionsController.requirePermission('add-secondary-email'),
RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmationCode),
UserEmailsController.resendSecondaryEmailConfirmationCode
) )
} }

View file

@ -0,0 +1,14 @@
extends ../layout-marketing
block vars
- var suppressNavbar = true
- var suppressSkipToContent = true
block entrypointVar
- entrypoint = 'pages/user/add-secondary-email'
block append meta
block content
main.content.content-alt
#add-secondary-email

View file

@ -0,0 +1,15 @@
extends ../layout-marketing
block vars
- var suppressNavbar = true
- var suppressSkipToContent = true
block entrypointVar
- entrypoint = 'pages/user/confirm-secondary-email'
block append meta
meta(name="ol-email" content=email)
block content
main.content.content-alt
#confirm-secondary-email

View file

@ -33,6 +33,7 @@
"actions": "", "actions": "",
"active": "", "active": "",
"add": "", "add": "",
"add_a_recovery_email_address": "",
"add_additional_certificate": "", "add_additional_certificate": "",
"add_affiliation": "", "add_affiliation": "",
"add_another_address_line": "", "add_another_address_line": "",
@ -41,6 +42,7 @@
"add_comma_separated_emails_help": "", "add_comma_separated_emails_help": "",
"add_comment": "", "add_comment": "",
"add_company_details": "", "add_company_details": "",
"add_email_address": "",
"add_email_to_claim_features": "", "add_email_to_claim_features": "",
"add_files": "", "add_files": "",
"add_more_collaborators": "", "add_more_collaborators": "",
@ -205,7 +207,6 @@
"confirm_primary_email_change": "", "confirm_primary_email_change": "",
"confirm_remove_sso_config_enter_email": "", "confirm_remove_sso_config_enter_email": "",
"confirm_your_email": "", "confirm_your_email": "",
"confirmation_code_message": "",
"confirming": "", "confirming": "",
"conflicting_paths_found": "", "conflicting_paths_found": "",
"congratulations_youve_successfully_join_group": "", "congratulations_youve_successfully_join_group": "",
@ -355,6 +356,7 @@
"email_confirmed_onboarding_message": "", "email_confirmed_onboarding_message": "",
"email_limit_reached": "", "email_limit_reached": "",
"email_link_expired": "", "email_link_expired": "",
"email_must_be_linked_to_institution": "",
"email_or_password_wrong_try_again": "", "email_or_password_wrong_try_again": "",
"emails_and_affiliations_explanation": "", "emails_and_affiliations_explanation": "",
"emails_and_affiliations_title": "", "emails_and_affiliations_title": "",
@ -368,6 +370,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_confirmation_code": "",
"error": "", "error": "",
"error_opening_document": "", "error_opening_document": "",
"error_opening_document_detail": "", "error_opening_document_detail": "",
@ -637,6 +640,7 @@
"justify": "", "justify": "",
"keep_current_plan": "", "keep_current_plan": "",
"keep_personal_projects_separate": "", "keep_personal_projects_separate": "",
"keep_your_account_safe_add_another_email": "",
"keybindings": "", "keybindings": "",
"labels_help_you_to_easily_reference_your_figures": "", "labels_help_you_to_easily_reference_your_figures": "",
"labels_help_you_to_reference_your_tables": "", "labels_help_you_to_reference_your_tables": "",
@ -661,6 +665,7 @@
"layout": "", "layout": "",
"layout_processing": "", "layout_processing": "",
"learn_more": "", "learn_more": "",
"learn_more_about_account": "",
"learn_more_about_link_sharing": "", "learn_more_about_link_sharing": "",
"learn_more_about_managed_users": "", "learn_more_about_managed_users": "",
"learn_more_about_other_causes_of_compile_timeouts": "", "learn_more_about_other_causes_of_compile_timeouts": "",
@ -1314,6 +1319,7 @@
"test_configuration_successful": "", "test_configuration_successful": "",
"tex_live_version": "", "tex_live_version": "",
"thank_you_exclamation": "", "thank_you_exclamation": "",
"thanks_for_confirming_your_email_address": "",
"thanks_for_subscribing": "", "thanks_for_subscribing": "",
"thanks_for_subscribing_you_help_sl": "", "thanks_for_subscribing_you_help_sl": "",
"thanks_settings_updated": "", "thanks_settings_updated": "",

View file

@ -0,0 +1,163 @@
import { Interstitial } from '@/shared/components/interstitial'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import EmailInput from './add-email/input'
import { useState } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
import { sendMB } from '@/infrastructure/event-tracking'
import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
import { postJSON } from '../../../../infrastructure/fetch-json'
type AddSecondaryEmailError = {
name: string
data: any
}
export function AddSecondaryEmailPrompt() {
const isReady = useWaitForI18n()
const { t } = useTranslation()
const [email, setEmail] = useState<string>()
const [error, setError] = useState<AddSecondaryEmailError | undefined>()
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
if (!isReady) {
return null
}
const onEmailChange = (newEmail: string) => {
if (newEmail !== email) {
setEmail(newEmail)
setError(undefined)
}
}
const errorHandler = (err: any) => {
let errorName = 'generic_something_went_wrong'
if (err?.response?.status === 409) {
errorName = 'email_already_registered'
} else if (err?.response?.status === 429) {
errorName = 'too_many_attempts'
} else if (err?.response?.status === 422) {
errorName = 'email_must_be_linked_to_institution'
} else if (err?.data.errorReason === 'cannot_verify_user_not_robot') {
errorName = 'cannot_verify_user_not_robot'
}
setError({ name: errorName, data: err?.data })
sendMB('add-secondary-email-error', { errorName })
}
const handleSubmit = async (e?: React.FormEvent<HTMLFormElement>) => {
if (e) {
e.preventDefault()
}
setIsSubmitting(true)
const token = await getReCaptchaToken()
await postJSON('/user/emails/secondary', {
body: {
email,
'g-recaptcha-response': token,
},
})
.then(() => {
location.assign('/user/emails/confirm-secondary')
})
.catch(errorHandler)
.finally(() => {
setIsSubmitting(false)
})
}
return (
<>
<Interstitial showLogo title={t('add_a_recovery_email_address')}>
<form className="add-secondary-email" onSubmit={handleSubmit}>
<ReCaptcha2 page="addEmail" ref={recaptchaRef} />
<p>{t('keep_your_account_safe_add_another_email')}</p>
<EmailInput
onChange={onEmailChange}
handleAddNewEmail={handleSubmit}
/>
<div aria-live="polite">
{error && <ErrorMessage error={error} />}
</div>
<Button
bsStyle={null}
disabled={isSubmitting}
className="btn-primary"
type="submit"
>
{isSubmitting ? <>{t('adding')}&hellip;</> : t('add_email_address')}
</Button>
<Button
bsStyle={null}
disabled={isSubmitting}
className="btn-secondary"
href="/project"
>
{t('not_now')}
</Button>
<p className="add-secondary-email-learn-more">
<Trans
i18nKey="learn_more_about_account"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Keeping_your_account_secure" />,
]}
/>
</p>
</form>
</Interstitial>
</>
)
}
function ErrorMessage({ error }: { error: AddSecondaryEmailError }) {
const { t } = useTranslation()
let errorText
switch (error.name) {
case 'email_already_registered':
errorText = t('email_already_registered')
break
case 'too_many_attempts':
errorText = t('too_many_attempts')
break
case 'email_must_be_linked_to_institution':
errorText = (
<Trans
i18nKey="email_must_be_linked_to_institution"
values={{ institutionName: error?.data?.institutionName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<a href="/account/settings" />]}
/>
)
break
case 'cannot_verify_user_not_robot':
errorText = t('cannot_verify_user_not_robot')
break
default:
errorText = t('generic_something_went_wrong')
}
return (
<div className="add-secondary-email-error small text-danger">
<MaterialIcon className="icon" type="error" />
<div>{errorText}</div>
</div>
)
}

View file

@ -0,0 +1,296 @@
import { postJSON } from '@/infrastructure/fetch-json'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import Notification from '@/shared/components/notification'
import getMeta from '@/utils/meta'
import { FormEvent, useState } from 'react'
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
import { sendMB } from '@/infrastructure/event-tracking'
import { Interstitial } from '@/shared/components/interstitial'
type Feedback = {
type: 'input' | 'alert'
style: 'error' | 'info'
message: string
}
type ConfirmEmailFormProps = {
confirmationEndpoint: string
flow: string
resendEndpoint: string
successMessage: React.ReactNode
successButtonText: string
}
export function ConfirmEmailForm({
confirmationEndpoint,
flow,
resendEndpoint,
successMessage,
successButtonText,
}: ConfirmEmailFormProps) {
const { t } = useTranslation()
const [confirmationCode, setConfirmationCode] = useState('')
const [feedback, setFeedback] = useState<Feedback | null>(null)
const [isConfirming, setIsConfirming] = useState(false)
const [isResending, setIsResending] = useState(false)
const [successRedirectPath, setSuccessRedirectPath] = useState('')
const email = getMeta('ol-email')
const { isReady } = useWaitForI18n()
const errorHandler = (err: any, actionType?: string) => {
let errorName = err?.data?.message?.key || 'generic_something_went_wrong'
if (err?.response?.status === 429) {
if (actionType === 'confirm') {
errorName = 'too_many_confirm_code_verification_attempts'
} else if (actionType === 'resend') {
errorName = 'too_many_confirm_code_resend_attempts'
}
setFeedback({
type: 'alert',
style: 'error',
message: errorName,
})
} else {
setFeedback({
type: 'input',
style: 'error',
message: errorName,
})
}
sendMB('email-verification-error', {
errorName,
flow,
})
}
const invalidFormHandler = () => {
if (!confirmationCode) {
return setFeedback({
type: 'input',
style: 'error',
message: 'please_enter_confirmation_code',
})
}
}
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsConfirming(true)
setFeedback(null)
postJSON(confirmationEndpoint, {
body: {
code: confirmationCode,
},
})
.then(data => {
setSuccessRedirectPath(data?.redir || '/')
})
.catch(err => {
errorHandler(err, 'confirm')
})
.finally(() => {
setIsConfirming(false)
})
sendMB('email-verification-click', {
button: 'verify',
flow,
})
}
const resendHandler = (e: FormEvent<Button>) => {
setIsResending(true)
setFeedback(null)
postJSON(resendEndpoint)
.then(data => {
setIsResending(false)
if (data?.message?.key) {
setFeedback({
type: 'alert',
style: 'info',
message: data.message.key,
})
}
})
.catch(err => {
errorHandler(err, 'resend')
})
.finally(() => {
setIsResending(false)
})
sendMB('email-verification-click', {
button: 'resend',
flow,
})
}
const changeHandler = (e: FormEvent<HTMLInputElement>) => {
setConfirmationCode(e.currentTarget.value)
setFeedback(null)
}
if (!isReady) {
return (
<Interstitial className="confirm-email" showLogo>
<LoadingSpinner />
</Interstitial>
)
}
if (successRedirectPath) {
return (
<ConfirmEmailSuccessfullForm
successMessage={successMessage}
successButtonText={successButtonText}
redirectTo={successRedirectPath}
/>
)
}
return (
<Interstitial className="confirm-email" showLogo>
<form onSubmit={submitHandler} onInvalid={invalidFormHandler}>
{feedback?.type === 'alert' && (
<Notification
ariaLive="polite"
className="confirm-email-alert"
type={feedback.style}
content={<ErrorMessage error={feedback.message} />}
/>
)}
<h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
<p className="small">{t('enter_the_confirmation_code', { email })}</p>
<input
className="form-control"
placeholder={t('enter_6_digit_code')}
inputMode="numeric"
required
value={confirmationCode}
onChange={changeHandler}
data-ol-dirty={feedback ? 'true' : undefined}
maxLength={6}
autoComplete="one-time-code"
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
<div aria-live="polite">
{feedback?.type === 'input' && (
<div className="small text-danger">
<MaterialIcon className="icon" type="error" />
<div>
<ErrorMessage error={feedback.message} />
</div>
</div>
)}
</div>
<div className="form-actions">
<Button
disabled={isConfirming || isResending}
type="submit"
bsStyle={null}
className="btn-primary"
>
{isConfirming ? (
<>
{t('confirming')}
<span>&hellip;</span>
</>
) : (
t('confirm')
)}
</Button>
<Button
disabled={isConfirming || isResending}
onClick={resendHandler}
bsStyle={null}
className="btn-secondary"
>
{isResending ? (
<>
{t('resending_confirmation_code')}
<span>&hellip;</span>
</>
) : (
t('resend_confirmation_code')
)}
</Button>
</div>
</form>
</Interstitial>
)
}
function ConfirmEmailSuccessfullForm({
successMessage,
successButtonText,
redirectTo,
}: {
successMessage: React.ReactNode
successButtonText: string
redirectTo: string
}) {
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
location.assign(redirectTo)
}
return (
<Interstitial className="confirm-email" showLogo>
<form onSubmit={submitHandler}>
<div aria-live="polite">{successMessage}</div>
<div className="form-actions">
<Button type="submit" bsStyle={null} className="btn-primary">
{successButtonText}
</Button>
</div>
</form>
</Interstitial>
)
}
function ErrorMessage({ error }: { error: string }) {
const { t } = useTranslation()
switch (error) {
case 'invalid_confirmation_code':
return <span>{t('invalid_confirmation_code')}</span>
case 'expired_confirmation_code':
return (
<Trans
i18nKey="expired_confirmation_code"
/* eslint-disable-next-line react/jsx-key */
components={[<strong />]}
/>
)
case 'email_already_registered':
return <span>{t('email_already_registered')}</span>
case 'too_many_confirm_code_resend_attempts':
return <span>{t('too_many_confirm_code_resend_attempts')}</span>
case 'too_many_confirm_code_verification_attempts':
return <span>{t('too_many_confirm_code_verification_attempts')}</span>
case 'we_sent_new_code':
return <span>{t('we_sent_new_code')}</span>
case 'please_enter_confirmation_code':
return <span>{t('please_enter_confirmation_code')}</span>
default:
return <span>{t('generic_something_went_wrong')}</span>
}
}

View file

@ -0,0 +1,24 @@
import { ConfirmEmailForm } from './confirm-email'
import { useTranslation } from 'react-i18next'
export default function ConfirmSecondaryEmailForm() {
const { t } = useTranslation()
const successMessage = (
<>
<h1 className="h3 interstitial-header">
{t('thanks_for_confirming_your_email_address')}
</h1>
</>
)
return (
<ConfirmEmailForm
successMessage={successMessage}
successButtonText={t('go_to_overleaf')}
confirmationEndpoint="/user/emails/confirm-secondary"
resendEndpoint="/user/emails/resend-secondary-confirmation"
flow="secondary"
/>
)
}

View file

@ -0,0 +1,12 @@
import '../../marketing'
import ReactDOM from 'react-dom'
import { AddSecondaryEmailPrompt } from '../../features/settings/components/emails/add-secondary-email-prompt'
const addSecondaryEmailContainer = document.getElementById(
'add-secondary-email'
)
if (addSecondaryEmailContainer) {
ReactDOM.render(<AddSecondaryEmailPrompt />, addSecondaryEmailContainer)
}

View file

@ -0,0 +1,10 @@
import '../../marketing'
import ReactDOM from 'react-dom'
import ConfirmSecondaryEmailForm from '../../features/settings/components/emails/confirm-secondary-email-form'
const confirmEmailContainer = document.getElementById('confirm-secondary-email')
if (confirmEmailContainer) {
ReactDOM.render(<ConfirmSecondaryEmailForm />, confirmEmailContainer)
}

View file

@ -0,0 +1,16 @@
.add-secondary-email {
display: flex;
flex-direction: column;
gap: 12px;
.add-secondary-email-error {
display: flex;
gap: 6px;
padding: 4px;
}
.add-secondary-email-learn-more {
margin-top: 12px;
margin-bottom: 0;
}
}

View file

@ -0,0 +1,23 @@
.confirm-email {
form {
.text-danger {
display: flex;
gap: 6px;
padding: 4px;
.icon {
padding-top: 2px;
}
}
.form-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
}
}
.confirm-email-alert {
margin-bottom: 12px;
}
}

View file

@ -9,6 +9,7 @@
.logo { .logo {
width: 130px; width: 130px;
margin: 0 auto; margin: 0 auto;
margin-bottom: 24px;
} }
.btn { .btn {

View file

@ -136,6 +136,9 @@
@import 'app/admin-hub.less'; @import 'app/admin-hub.less';
@import 'app/import.less'; @import 'app/import.less';
@import 'app/website-redesign.less'; @import 'app/website-redesign.less';
@import 'app/add-secondary-email-prompt.less';
@import 'app/confirm-email.less';
// Pages // Pages
@import 'app/about.less'; @import 'app/about.less';
@import 'app/blog-posts.less'; @import 'app/blog-posts.less';

View file

@ -1,25 +1,3 @@
.onboarding-confirm-email {
gap: 24px;
form {
.text-danger {
display: flex;
gap: 6px;
padding: 4px;
.icon {
padding-top: 2px;
}
}
.form-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
}
}
}
.onboarding-data-collection-wrapper { .onboarding-data-collection-wrapper {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;

View file

@ -52,6 +52,7 @@
"activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.", "activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.",
"active": "Active", "active": "Active",
"add": "Add", "add": "Add",
"add_a_recovery_email_address": "Add a recovery email address",
"add_additional_certificate": "Add another certificate", "add_additional_certificate": "Add another certificate",
"add_affiliation": "Add Affiliation", "add_affiliation": "Add Affiliation",
"add_another_address_line": "Add another address line", "add_another_address_line": "Add another address line",
@ -61,6 +62,7 @@
"add_comment": "Add comment", "add_comment": "Add comment",
"add_company_details": "Add Company Details", "add_company_details": "Add Company Details",
"add_email": "Add Email", "add_email": "Add Email",
"add_email_address": "Add email address",
"add_email_to_claim_features": "Add an institutional email address to claim your features.", "add_email_to_claim_features": "Add an institutional email address to claim your features.",
"add_files": "Add Files", "add_files": "Add Files",
"add_more_collaborators": "Add more collaborators", "add_more_collaborators": "Add more collaborators",
@ -297,7 +299,6 @@
"confirm_primary_email_change": "Confirm primary email change", "confirm_primary_email_change": "Confirm primary email change",
"confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:", "confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:",
"confirm_your_email": "Confirm your email address", "confirm_your_email": "Confirm your email address",
"confirmation_code_message": "Weve sent a 6-digit confirmation code to __email__. Please enter it below to confirm your email address.",
"confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.", "confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.",
"confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.", "confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
"confirming": "Confirming", "confirming": "Confirming",
@ -499,6 +500,7 @@
"email_does_not_belong_to_university": "We dont recognize that domain as being affiliated with your university. Please contact us to add the affiliation.", "email_does_not_belong_to_university": "We dont recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
"email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.", "email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.",
"email_link_expired": "Email link expired, please request a new one.", "email_link_expired": "Email link expired, please request a new one.",
"email_must_be_linked_to_institution": "As a member of __institutionName__, this email address can only be added via single sign-on on your <0>account settings</0> page. Please add a different recovery email address.",
"email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.", "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.",
"email_or_password_wrong_try_again_or_reset": "Your email or password is incorrect. Please try again, or <0>set or reset your password</0>.", "email_or_password_wrong_try_again_or_reset": "Your email or password is incorrect. Please try again, or <0>set or reset your password</0>.",
"email_required": "Email required", "email_required": "Email required",
@ -518,6 +520,7 @@
"enter_6_digit_code": "Enter 6-digit code", "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_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
"enter_image_url": "Enter image URL", "enter_image_url": "Enter image URL",
"enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.",
"enter_your_email_address": "Enter your email address", "enter_your_email_address": "Enter your email address",
"enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password", "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
"enter_your_new_password": "Enter your new password", "enter_your_new_password": "Enter your new password",
@ -930,6 +933,7 @@
"keep_current_plan": "Keep my current plan", "keep_current_plan": "Keep my current plan",
"keep_personal_projects_separate": "Keep personal projects separate", "keep_personal_projects_separate": "Keep personal projects separate",
"keep_your_account_safe": "Keep your account safe", "keep_your_account_safe": "Keep your account safe",
"keep_your_account_safe_add_another_email": "Keep your account safe and make sure you dont lose access to it by adding another email address.",
"keep_your_email_updated": "Keep your email updated so that you dont lose access to your account and data.", "keep_your_email_updated": "Keep your email updated so that you dont lose access to your account and data.",
"keybindings": "Keybindings", "keybindings": "Keybindings",
"knowledge_base": "knowledge base", "knowledge_base": "knowledge base",
@ -971,6 +975,7 @@
"ldap_create_admin_instructions": "Choose an email address for the first __appName__ admin account. This should correspond to an account in the LDAP system. You will then be asked to log in with this account.", "ldap_create_admin_instructions": "Choose an email address for the first __appName__ admin account. This should correspond to an account in the LDAP system. You will then be asked to log in with this account.",
"learn": "Learn", "learn": "Learn",
"learn_more": "Learn more", "learn_more": "Learn more",
"learn_more_about_account": "<0>Learn more</0> about managing your __appName__ account.",
"learn_more_about_emails": "<0>Learn more</0> about managing your __appName__ emails.", "learn_more_about_emails": "<0>Learn more</0> about managing your __appName__ emails.",
"learn_more_about_link_sharing": "Learn more about Link Sharing", "learn_more_about_link_sharing": "Learn more about Link Sharing",
"learn_more_about_managed_users": "Learn more about Managed Users.", "learn_more_about_managed_users": "Learn more about Managed Users.",
@ -1871,6 +1876,7 @@
"thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our Beta Program, where you can have <0>early access to new features</0> and help us understand your needs better", "thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our Beta Program, where you can have <0>early access to new features</0> and help us understand your needs better",
"thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features</0> and help us explore innovative ideas that help you work more quickly and effectively", "thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features</0> and help us explore innovative ideas that help you work more quickly and effectively",
"thanks": "Thanks", "thanks": "Thanks",
"thanks_for_confirming_your_email_address": "Thanks for confirming your email address",
"thanks_for_subscribing": "Thanks for subscribing!", "thanks_for_subscribing": "Thanks for subscribing!",
"thanks_for_subscribing_you_help_sl": "Thank you for subscribing to the __planName__ plan. Its support from people like yourself that allows __appName__ to continue to grow and improve.", "thanks_for_subscribing_you_help_sl": "Thank you for subscribing to the __planName__ plan. Its support from people like yourself that allows __appName__ to continue to grow and improve.",
"thanks_settings_updated": "Thanks, your settings have been updated.", "thanks_settings_updated": "Thanks, your settings have been updated.",

View file

@ -297,7 +297,6 @@
"confirm_primary_email_change": "确认主电子邮件更改", "confirm_primary_email_change": "确认主电子邮件更改",
"confirm_remove_sso_config_enter_email": "要确认您要删除 SSO 配置,请输入您的电子邮件地址:", "confirm_remove_sso_config_enter_email": "要确认您要删除 SSO 配置,请输入您的电子邮件地址:",
"confirm_your_email": "确认您的电子邮件地址", "confirm_your_email": "确认您的电子邮件地址",
"confirmation_code_message": "我们已向__email__发送了一个6位数的验证码。请在下面输入以确认您的电子邮件地址。",
"confirmation_link_broken": "抱歉,您的确认链接有问题。请尝试复制并粘贴邮件底部的链接。", "confirmation_link_broken": "抱歉,您的确认链接有问题。请尝试复制并粘贴邮件底部的链接。",
"confirmation_token_invalid": "抱歉,您的确认令牌无效或已过期。请请求新的电子邮件确认链接。", "confirmation_token_invalid": "抱歉,您的确认令牌无效或已过期。请请求新的电子邮件确认链接。",
"confirming": "确认", "confirming": "确认",

View file

@ -18,7 +18,7 @@ describe('Add secondary email address confirmation code email', function () {
} }
beforeEach(async function () { beforeEach(async function () {
if (!Features.hasFeature('affiliations')) { if (!Features.hasFeature('saas')) {
this.skip() this.skip()
} }
@ -39,16 +39,15 @@ describe('Add secondary email address confirmation code email', function () {
}) })
afterEach(function () { afterEach(function () {
if (!Features.hasFeature('affiliations')) { if (!Features.hasFeature('saas')) {
this.skip() this.skip()
} }
spy.restore() spy.restore()
}) })
it('should redirect to confirm secondary email page', function () { it('should send email with confirmation code', function () {
expect(res.response.statusCode).to.equal(200) expect(res.response.statusCode).to.equal(200)
expect(res.body.redir).to.equal('/user/emails/confirm-secondary')
expect(confirmCode.length).to.equal(6) expect(confirmCode.length).to.equal(6)
}) })

View file

@ -2,9 +2,16 @@ const UserHelper = require('./helpers/UserHelper')
const Settings = require('@overleaf/settings') const Settings = require('@overleaf/settings')
const { expect } = require('chai') const { expect } = require('chai')
const Features = require('../../../app/src/infrastructure/Features') const Features = require('../../../app/src/infrastructure/Features')
const MockV1ApiClass = require('./mocks/MockV1Api')
const Subscription = require('./helpers/Subscription').promises
describe('PrimaryEmailCheck', function () { describe('PrimaryEmailCheck', function () {
let userHelper let userHelper
let MockV1Api
before(function () {
MockV1Api = MockV1ApiClass.instance()
})
beforeEach(async function () { beforeEach(async function () {
userHelper = await UserHelper.createUser() userHelper = await UserHelper.createUser()
@ -200,18 +207,109 @@ describe('PrimaryEmailCheck', function () {
await UserHelper.updateUser(userHelper.user._id, { await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) }, $set: { lastPrimaryEmailCheck: new Date(time) },
}) })
checkResponse = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
}) })
it('should be redirected to the project list page', function () { describe('when the user has a secondary email address', function () {
expect(checkResponse.status).to.equal(302) before(async function () {
expect(checkResponse.headers.get('location')).to.equal( if (!Features.hasFeature('saas')) {
UserHelper.url('/project').toString() this.skip()
) }
})
beforeEach(async function () {
await userHelper.confirmEmail(
userHelper.user._id,
userHelper.user.email
)
await userHelper.addEmailAndConfirm(
userHelper.user._id,
'secondary@overleaf.com'
)
checkResponse = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
})
it('should be redirected to the project list page', function () {
expect(checkResponse.status).to.equal(302)
expect(checkResponse.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
describe('when the user has an institutional email and no secondary', function () {
before(async function () {
if (!Features.hasFeature('saas')) {
this.skip()
}
if (!Features.hasFeature('saml')) {
this.skip()
}
})
beforeEach(async function () {
MockV1Api.createInstitution({
name: 'Exampe Institution',
hostname: 'example.com',
licence: 'pro_plus',
confirmed: true,
})
MockV1Api.addAffiliation(userHelper.user._id, userHelper.user.email)
})
it('should be redirected to the add secondary email page', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/user/emails/add-secondary').toString()
)
})
})
describe('when the user is a managed user', function () {
beforeEach(async function () {
const adminUser = await UserHelper.createUser()
this.subscription = new Subscription({
adminId: adminUser._id,
memberIds: [userHelper.user._id],
groupPlan: true,
planCode: 'group_professional_5_enterprise',
})
await this.subscription.ensureExists()
await this.subscription.enableManagedUsers()
})
it('should be redirected to the project list page', async function () {
const response = await userHelper.fetch(
'/user/emails/primary-email-check',
{ method: 'POST' }
)
expect(response.status).to.equal(302)
expect(response.headers.get('location')).to.equal(
UserHelper.url('/project').toString()
)
})
})
})
describe('when user has checked their primary email address', function () {
beforeEach(async function () {
const time = Date.now() - Settings.primary_email_check_expiration * 2
await UserHelper.updateUser(userHelper.user._id, {
$set: { lastPrimaryEmailCheck: new Date(time) },
})
await userHelper.fetch('/user/emails/primary-email-check', {
method: 'POST',
})
}) })
it("shouldn't be redirected from project list to the primary email check page any longer", async function () { it("shouldn't be redirected from project list to the primary email check page any longer", async function () {

View file

@ -301,8 +301,8 @@ describe('UserEmailsController', function () {
it('sends an email confirmation', function (done) { it('sends an email confirmation', function (done) {
this.UserEmailsController.addWithConfirmationCode(this.req, { this.UserEmailsController.addWithConfirmationCode(this.req, {
json: ({ redir }) => { sendStatus: code => {
redir.should.equal('/user/emails/confirm-secondary') code.should.equal(200)
assertCalledWith( assertCalledWith(
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode, this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
this.newEmail, this.newEmail,

View file

@ -1008,6 +1008,45 @@ describe('UserGetter', function () {
}) })
}) })
describe('getUserConfirmedEmails', function () {
beforeEach(function () {
this.fakeUser = {
emails: [
{
email: 'email1@foo.bar',
reversedHostname: 'rab.oof',
confirmedAt: new Date(),
},
{ email: 'email2@foo.bar', reversedHostname: 'rab.oof' },
{
email: 'email3@foo.bar',
reversedHostname: 'rab.oof',
confirmedAt: new Date(),
},
],
}
this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
})
it('should get user', async function () {
const projection = { emails: 1 }
await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id)
this.UserGetter.promises.getUser
.calledWith(this.fakeUser._id, projection)
.should.equal(true)
})
it('should return only confirmed emails', async function () {
const confirmedEmails =
await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id)
expect(confirmedEmails.length).to.equal(2)
expect(confirmedEmails[0].email).to.equal('email1@foo.bar')
expect(confirmedEmails[1].email).to.equal('email3@foo.bar')
})
})
describe('getUserbyMainEmail', function () { describe('getUserbyMainEmail', function () {
it('query user by main email', function (done) { it('query user by main email', function (done) {
const email = 'hello@world.com' const email = 'hello@world.com'