mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
5aea030184
commit
635aae7b1f
22 changed files with 837 additions and 59 deletions
|
@ -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),
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
14
services/web/app/views/user/addSecondaryEmail.pug
Normal file
14
services/web/app/views/user/addSecondaryEmail.pug
Normal 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
|
15
services/web/app/views/user/confirmSecondaryEmail.pug
Normal file
15
services/web/app/views/user/confirmSecondaryEmail.pug
Normal 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
|
|
@ -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": "",
|
||||||
|
|
|
@ -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')}…</> : 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('confirm')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isConfirming || isResending}
|
||||||
|
onClick={resendHandler}
|
||||||
|
bsStyle={null}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
{isResending ? (
|
||||||
|
<>
|
||||||
|
{t('resending_confirmation_code')}
|
||||||
|
<span>…</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>
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
12
services/web/frontend/js/pages/user/add-secondary-email.tsx
Normal file
12
services/web/frontend/js/pages/user/add-secondary-email.tsx
Normal 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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
23
services/web/frontend/stylesheets/app/confirm-email.less
Normal file
23
services/web/frontend/stylesheets/app/confirm-email.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@
|
||||||
.logo {
|
.logo {
|
||||||
width: 130px;
|
width: 130px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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": "We’ve 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 don’t recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
|
"email_does_not_belong_to_university": "We don’t 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 don’t lose access to it by adding another email address.",
|
||||||
"keep_your_email_updated": "Keep your email updated so that you don’t lose access to your account and data.",
|
"keep_your_email_updated": "Keep your email updated so that you don’t 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. It’s 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. It’s 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.",
|
||||||
|
|
|
@ -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": "确认",
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Loading…
Reference in a new issue