mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #18139 from overleaf/dp-add-secondary-prompt-ui
Add UI for secondary email prompt GitOrigin-RevId: 887b2c7f0047f19b605f03745f7dda83926ec70b
This commit is contained in:
parent
41cb0859db
commit
0630e96d49
21 changed files with 811 additions and 42 deletions
|
@ -17,7 +17,9 @@ const AnalyticsManager = require('../Analytics/AnalyticsManager')
|
|||
const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
|
||||
const UserAuditLogHandler = require('./UserAuditLogHandler')
|
||||
const { RateLimiter } = require('../../infrastructure/RateLimiter')
|
||||
const Features = require('../../infrastructure/Features')
|
||||
const tsscmp = require('tsscmp')
|
||||
const Modules = require('../../infrastructure/Modules')
|
||||
|
||||
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
|
||||
|
||||
|
@ -208,9 +210,7 @@ async function addWithConfirmationCode(req, res) {
|
|||
confirmCodeExpiresTimestamp,
|
||||
}
|
||||
|
||||
return res.json({
|
||||
redir: '/user/emails/confirm-secondary',
|
||||
})
|
||||
return res.sendStatus(200)
|
||||
} catch (err) {
|
||||
if (err.name === 'EmailExistsError') {
|
||||
return res.status(409).json({
|
||||
|
@ -414,6 +414,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) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
|
@ -440,6 +479,30 @@ async function primaryEmailCheck(req, res) {
|
|||
$set: { lastPrimaryEmailCheck: new Date() },
|
||||
})
|
||||
AnalyticsManager.recordEventForUser(userId, '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('affiliations')) {
|
||||
const confirmedEmails =
|
||||
await UserGetter.promises.getUserConfirmedEmails(userId)
|
||||
|
||||
if (confirmedEmails.length < 2) {
|
||||
const primaryEmail = await UserGetter.promises.getUserEmail(userId)
|
||||
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')
|
||||
}
|
||||
|
||||
|
@ -551,6 +614,10 @@ const UserEmailsController = {
|
|||
|
||||
sendReconfirmation,
|
||||
|
||||
addSecondaryEmailPage: expressify(addSecondaryEmailPage),
|
||||
|
||||
confirmSecondaryEmailPage: expressify(confirmSecondaryEmailPage),
|
||||
|
||||
primaryEmailCheckPage: expressify(primaryEmailCheckPage),
|
||||
|
||||
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) {
|
||||
if (!projection) {
|
||||
throw new Error('missing projection')
|
||||
|
@ -124,6 +136,8 @@ const UserGetter = {
|
|||
|
||||
getUserFullEmails: callbackify(getUserFullEmails),
|
||||
|
||||
getUserConfirmedEmails: callbackify(getUserConfirmedEmails),
|
||||
|
||||
getUserByMainEmail(email, projection, callback) {
|
||||
email = email.trim()
|
||||
if (arguments.length === 2) {
|
||||
|
|
|
@ -348,6 +348,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
CaptchaMiddleware.validateCaptcha('addEmail'),
|
||||
UserEmailsController.add
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/user/emails/delete',
|
||||
AuthenticationController.requireLogin(),
|
||||
|
@ -390,6 +391,20 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmationCode),
|
||||
UserEmailsController.resendSecondaryEmailConfirmationCode
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/user/emails/add-secondary',
|
||||
AuthenticationController.requireLogin(),
|
||||
PermissionsController.requirePermission('add-secondary-email'),
|
||||
UserEmailsController.addSecondaryEmailPage
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/user/emails/confirm-secondary',
|
||||
AuthenticationController.requireLogin(),
|
||||
PermissionsController.requirePermission('add-secondary-email'),
|
||||
UserEmailsController.confirmSecondaryEmailPage
|
||||
)
|
||||
}
|
||||
|
||||
webRouter.get(
|
||||
|
|
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": "",
|
||||
"active": "",
|
||||
"add": "",
|
||||
"add_a_recovery_email_address": "",
|
||||
"add_additional_certificate": "",
|
||||
"add_affiliation": "",
|
||||
"add_another_address_line": "",
|
||||
|
@ -41,6 +42,7 @@
|
|||
"add_comma_separated_emails_help": "",
|
||||
"add_comment": "",
|
||||
"add_company_details": "",
|
||||
"add_email_address": "",
|
||||
"add_email_to_claim_features": "",
|
||||
"add_files": "",
|
||||
"add_more_collaborators": "",
|
||||
|
@ -205,7 +207,6 @@
|
|||
"confirm_primary_email_change": "",
|
||||
"confirm_remove_sso_config_enter_email": "",
|
||||
"confirm_your_email": "",
|
||||
"confirmation_code_message": "",
|
||||
"confirming": "",
|
||||
"conflicting_paths_found": "",
|
||||
"congratulations_youve_successfully_join_group": "",
|
||||
|
@ -356,6 +357,7 @@
|
|||
"email_confirmed_onboarding_message": "",
|
||||
"email_limit_reached": "",
|
||||
"email_link_expired": "",
|
||||
"email_must_be_linked_to_institution": "",
|
||||
"email_or_password_wrong_try_again": "",
|
||||
"emails_and_affiliations_explanation": "",
|
||||
"emails_and_affiliations_title": "",
|
||||
|
@ -369,6 +371,7 @@
|
|||
"enter_6_digit_code": "",
|
||||
"enter_any_size_including_units_or_valid_latex_command": "",
|
||||
"enter_image_url": "",
|
||||
"enter_the_confirmation_code": "",
|
||||
"error": "",
|
||||
"error_opening_document": "",
|
||||
"error_opening_document_detail": "",
|
||||
|
@ -636,6 +639,7 @@
|
|||
"justify": "",
|
||||
"keep_current_plan": "",
|
||||
"keep_personal_projects_separate": "",
|
||||
"keep_your_account_safe_add_another_email": "",
|
||||
"keybindings": "",
|
||||
"labels_help_you_to_easily_reference_your_figures": "",
|
||||
"labels_help_you_to_reference_your_tables": "",
|
||||
|
@ -660,6 +664,7 @@
|
|||
"layout": "",
|
||||
"layout_processing": "",
|
||||
"learn_more": "",
|
||||
"learn_more_about_account": "",
|
||||
"learn_more_about_link_sharing": "",
|
||||
"learn_more_about_managed_users": "",
|
||||
"learn_more_about_other_causes_of_compile_timeouts": "",
|
||||
|
@ -1306,6 +1311,7 @@
|
|||
"test_configuration_successful": "",
|
||||
"tex_live_version": "",
|
||||
"thank_you_exclamation": "",
|
||||
"thanks_for_confirming_your_email_address": "",
|
||||
"thanks_for_subscribing": "",
|
||||
"thanks_for_subscribing_you_help_sl": "",
|
||||
"thanks_settings_updated": "",
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
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 { 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)
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
await postJSON('/user/emails/secondary', {
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
.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}>
|
||||
<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
|
||||
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,326 @@
|
|||
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 = {
|
||||
isRegistrationForm: boolean
|
||||
}
|
||||
|
||||
export function ConfirmEmailForm({
|
||||
isRegistrationForm,
|
||||
}: 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: isRegistrationForm ? 'registration' : 'secondary',
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const requestPath = isRegistrationForm
|
||||
? '/registration/confirm-email'
|
||||
: '/user/emails/confirm-secondary'
|
||||
|
||||
const requestBody = isRegistrationForm
|
||||
? {
|
||||
_csrf: getMeta('ol-csrfToken'),
|
||||
code: confirmationCode,
|
||||
email,
|
||||
}
|
||||
: {
|
||||
code: confirmationCode,
|
||||
}
|
||||
|
||||
postJSON(requestPath, {
|
||||
body: requestBody,
|
||||
})
|
||||
.then(data => {
|
||||
setSuccessRedirectPath(data?.redir || '/')
|
||||
})
|
||||
.catch(err => {
|
||||
errorHandler(err, 'confirm')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsConfirming(false)
|
||||
})
|
||||
|
||||
sendMB('email-verification-click', {
|
||||
button: 'verify',
|
||||
flow: isRegistrationForm ? 'registration' : 'secondary',
|
||||
})
|
||||
}
|
||||
|
||||
const resendHandler = (e: FormEvent<Button>) => {
|
||||
setIsResending(true)
|
||||
setFeedback(null)
|
||||
|
||||
const requestPath = isRegistrationForm
|
||||
? '/registration/confirm-email/resend'
|
||||
: '/user/emails/resend-secondary-confirmation'
|
||||
|
||||
const requestBody = isRegistrationForm
|
||||
? {
|
||||
_csrf: getMeta('ol-csrfToken'),
|
||||
email,
|
||||
}
|
||||
: {}
|
||||
|
||||
postJSON(requestPath, {
|
||||
body: requestBody,
|
||||
})
|
||||
.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: isRegistrationForm ? 'registration' : 'secondary',
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
isRegistrationForm={isRegistrationForm}
|
||||
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({
|
||||
isRegistrationForm,
|
||||
redirectTo,
|
||||
}: {
|
||||
isRegistrationForm: boolean
|
||||
redirectTo: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
location.assign(redirectTo)
|
||||
}
|
||||
|
||||
return (
|
||||
<Interstitial className="confirm-email" showLogo>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div aria-live="polite">
|
||||
<h1 className="h3 interstitial-header">
|
||||
{isRegistrationForm
|
||||
? t('email_confirmed_onboarding')
|
||||
: t('thanks_for_confirming_your_email_address')}
|
||||
</h1>
|
||||
<p className="small">
|
||||
{isRegistrationForm && (
|
||||
<Trans
|
||||
i18nKey="email_confirmed_onboarding_message"
|
||||
/* eslint-disable-next-line react/jsx-key */
|
||||
components={[<strong />]}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<Button type="submit" bsStyle={null} className="btn-primary">
|
||||
{isRegistrationForm ? t('continue') : t('go_to_overleaf')}
|
||||
</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>
|
||||
}
|
||||
}
|
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,13 @@
|
|||
import '../../marketing'
|
||||
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ConfirmEmailForm } from '../../features/settings/components/emails/confirm-email'
|
||||
|
||||
const confirmEmailContainer = document.getElementById('confirm-secondary-email')
|
||||
|
||||
if (confirmEmailContainer) {
|
||||
ReactDOM.render(
|
||||
<ConfirmEmailForm isRegistrationForm={false} />,
|
||||
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 {
|
||||
width: 130px;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
|
|
@ -136,6 +136,9 @@
|
|||
@import 'app/admin-hub.less';
|
||||
@import 'app/import.less';
|
||||
@import 'app/website-redesign.less';
|
||||
@import 'app/add-secondary-email-prompt.less';
|
||||
@import 'app/confirm-email.less';
|
||||
|
||||
// Pages
|
||||
@import 'app/about.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 {
|
||||
max-width: 720px;
|
||||
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.",
|
||||
"active": "Active",
|
||||
"add": "Add",
|
||||
"add_a_recovery_email_address": "Add a recovery email address",
|
||||
"add_additional_certificate": "Add another certificate",
|
||||
"add_affiliation": "Add Affiliation",
|
||||
"add_another_address_line": "Add another address line",
|
||||
|
@ -61,6 +62,7 @@
|
|||
"add_comment": "Add comment",
|
||||
"add_company_details": "Add Company Details",
|
||||
"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_files": "Add Files",
|
||||
"add_more_collaborators": "Add more collaborators",
|
||||
|
@ -297,7 +299,6 @@
|
|||
"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_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_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
|
||||
"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_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_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_or_reset": "Your email or password is incorrect. Please try again, or <0>set or reset your password</0>.",
|
||||
"email_required": "Email required",
|
||||
|
@ -518,6 +520,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": "Enter the 6-digit confirmation code sent to __email__.",
|
||||
"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_new_password": "Enter your new password",
|
||||
|
@ -928,6 +931,7 @@
|
|||
"keep_current_plan": "Keep my current plan",
|
||||
"keep_personal_projects_separate": "Keep personal projects separate",
|
||||
"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.",
|
||||
"keybindings": "Keybindings",
|
||||
"knowledge_base": "knowledge base",
|
||||
|
@ -969,6 +973,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.",
|
||||
"learn": "Learn",
|
||||
"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_link_sharing": "Learn more about Link Sharing",
|
||||
"learn_more_about_managed_users": "Learn more about Managed Users.",
|
||||
|
@ -1862,6 +1867,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_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_for_confirming_your_email_address": "Thanks for confirming your email address",
|
||||
"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_settings_updated": "Thanks, your settings have been updated.",
|
||||
|
|
|
@ -297,7 +297,6 @@
|
|||
"confirm_primary_email_change": "确认主电子邮件更改",
|
||||
"confirm_remove_sso_config_enter_email": "要确认您要删除 SSO 配置,请输入您的电子邮件地址:",
|
||||
"confirm_your_email": "确认您的电子邮件地址",
|
||||
"confirmation_code_message": "我们已向__email__发送了一个6位数的验证码。请在下面输入以确认您的电子邮件地址。",
|
||||
"confirmation_link_broken": "抱歉,您的确认链接有问题。请尝试复制并粘贴邮件底部的链接。",
|
||||
"confirmation_token_invalid": "抱歉,您的确认令牌无效或已过期。请请求新的电子邮件确认链接。",
|
||||
"confirming": "确认",
|
||||
|
|
|
@ -46,9 +46,8 @@ describe('Add secondary email address confirmation code email', function () {
|
|||
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.body.redir).to.equal('/user/emails/confirm-secondary')
|
||||
expect(confirmCode.length).to.equal(6)
|
||||
})
|
||||
|
||||
|
|
|
@ -2,9 +2,15 @@ const UserHelper = require('./helpers/UserHelper')
|
|||
const Settings = require('@overleaf/settings')
|
||||
const { expect } = require('chai')
|
||||
const Features = require('../../../app/src/infrastructure/Features')
|
||||
const MockV1ApiClass = require('./mocks/MockV1Api')
|
||||
|
||||
describe('PrimaryEmailCheck', function () {
|
||||
let userHelper
|
||||
let MockV1Api
|
||||
|
||||
before(function () {
|
||||
MockV1Api = MockV1ApiClass.instance()
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
userHelper = await UserHelper.createUser()
|
||||
|
@ -200,18 +206,84 @@ describe('PrimaryEmailCheck', function () {
|
|||
await UserHelper.updateUser(userHelper.user._id, {
|
||||
$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 () {
|
||||
expect(checkResponse.status).to.equal(302)
|
||||
expect(checkResponse.headers.get('location')).to.equal(
|
||||
UserHelper.url('/project').toString()
|
||||
)
|
||||
describe('when the user has a secondary email address', function () {
|
||||
before(async function () {
|
||||
if (!Features.hasFeature('saas')) {
|
||||
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)
|
||||
|
||||
checkResponse = await userHelper.fetch(
|
||||
'/user/emails/primary-email-check',
|
||||
{ method: 'POST' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should be redirected to the add secondary email page', function () {
|
||||
expect(checkResponse.status).to.equal(302)
|
||||
expect(checkResponse.headers.get('location')).to.equal(
|
||||
UserHelper.url('/user/emails/add-secondary').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 () {
|
||||
|
|
|
@ -301,8 +301,8 @@ describe('UserEmailsController', function () {
|
|||
|
||||
it('sends an email confirmation', function (done) {
|
||||
this.UserEmailsController.addWithConfirmationCode(this.req, {
|
||||
json: ({ redir }) => {
|
||||
redir.should.equal('/user/emails/confirm-secondary')
|
||||
sendStatus: code => {
|
||||
code.should.equal(200)
|
||||
assertCalledWith(
|
||||
this.UserEmailsConfirmationHandler.promises.sendConfirmationCode,
|
||||
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 () {
|
||||
it('query user by main email', function (done) {
|
||||
const email = 'hello@world.com'
|
||||
|
|
Loading…
Reference in a new issue