diff --git a/services/web/app/src/Features/User/UserEmailsController.js b/services/web/app/src/Features/User/UserEmailsController.js index cfb692798d..faa5301497 100644 --- a/services/web/app/src/Features/User/UserEmailsController.js +++ b/services/web/app/src/Features/User/UserEmailsController.js @@ -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), diff --git a/services/web/app/src/Features/User/UserGetter.js b/services/web/app/src/Features/User/UserGetter.js index 2d68f3c24d..4729fea3d3 100644 --- a/services/web/app/src/Features/User/UserGetter.js +++ b/services/web/app/src/Features/User/UserGetter.js @@ -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) { diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 484b44e644..aa398e81fd 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -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( diff --git a/services/web/app/views/user/addSecondaryEmail.pug b/services/web/app/views/user/addSecondaryEmail.pug new file mode 100644 index 0000000000..643a740b27 --- /dev/null +++ b/services/web/app/views/user/addSecondaryEmail.pug @@ -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 diff --git a/services/web/app/views/user/confirmSecondaryEmail.pug b/services/web/app/views/user/confirmSecondaryEmail.pug new file mode 100644 index 0000000000..4d0c59e9db --- /dev/null +++ b/services/web/app/views/user/confirmSecondaryEmail.pug @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4a0b614fa3..db32fb3dc2 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx b/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx new file mode 100644 index 0000000000..664ac1e0c0 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx @@ -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() + const [error, setError] = useState() + const [isSubmitting, setIsSubmitting] = useState(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) => { + 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 ( + <> + +
+

{t('keep_your_account_safe_add_another_email')}

+ + + +
+ {error && } +
+ + + +

+ , + ]} + /> +

+ +
+ + ) +} + +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 = ( + ]} + /> + ) + break + default: + errorText = t('generic_something_went_wrong') + } + + return ( +
+ +
{errorText}
+
+ ) +} diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-email.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-email.tsx new file mode 100644 index 0000000000..8e3055a762 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/confirm-email.tsx @@ -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(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) => { + 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 + + + + + ) +} + +function ConfirmEmailSuccessfullForm({ + isRegistrationForm, + redirectTo, +}: { + isRegistrationForm: boolean + redirectTo: string +}) { + const { t } = useTranslation() + const submitHandler = (e: FormEvent) => { + e.preventDefault() + location.assign(redirectTo) + } + + return ( + +
+
+

+ {isRegistrationForm + ? t('email_confirmed_onboarding') + : t('thanks_for_confirming_your_email_address')} +

+

+ {isRegistrationForm && ( + ]} + /> + )} +

+
+ +
+ +
+
+
+ ) +} + +function ErrorMessage({ error }: { error: string }) { + const { t } = useTranslation() + + switch (error) { + case 'invalid_confirmation_code': + return {t('invalid_confirmation_code')} + + case 'expired_confirmation_code': + return ( + ]} + /> + ) + + case 'email_already_registered': + return {t('email_already_registered')} + + case 'too_many_confirm_code_resend_attempts': + return {t('too_many_confirm_code_resend_attempts')} + + case 'too_many_confirm_code_verification_attempts': + return {t('too_many_confirm_code_verification_attempts')} + + case 'we_sent_new_code': + return {t('we_sent_new_code')} + + case 'please_enter_confirmation_code': + return {t('please_enter_confirmation_code')} + + default: + return {t('generic_something_went_wrong')} + } +} diff --git a/services/web/frontend/js/pages/user/add-secondary-email.tsx b/services/web/frontend/js/pages/user/add-secondary-email.tsx new file mode 100644 index 0000000000..8297d319af --- /dev/null +++ b/services/web/frontend/js/pages/user/add-secondary-email.tsx @@ -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(, addSecondaryEmailContainer) +} diff --git a/services/web/frontend/js/pages/user/confirm-secondary-email.tsx b/services/web/frontend/js/pages/user/confirm-secondary-email.tsx new file mode 100644 index 0000000000..bce4619314 --- /dev/null +++ b/services/web/frontend/js/pages/user/confirm-secondary-email.tsx @@ -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( + , + confirmEmailContainer + ) +} diff --git a/services/web/frontend/stylesheets/app/add-secondary-email-prompt.less b/services/web/frontend/stylesheets/app/add-secondary-email-prompt.less new file mode 100644 index 0000000000..7a4eff71a7 --- /dev/null +++ b/services/web/frontend/stylesheets/app/add-secondary-email-prompt.less @@ -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; + } +} diff --git a/services/web/frontend/stylesheets/app/confirm-email.less b/services/web/frontend/stylesheets/app/confirm-email.less new file mode 100644 index 0000000000..8ea0b2d9fb --- /dev/null +++ b/services/web/frontend/stylesheets/app/confirm-email.less @@ -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; + } +} diff --git a/services/web/frontend/stylesheets/components/interstitial.less b/services/web/frontend/stylesheets/components/interstitial.less index ac51759182..1d83751e1c 100644 --- a/services/web/frontend/stylesheets/components/interstitial.less +++ b/services/web/frontend/stylesheets/components/interstitial.less @@ -9,6 +9,7 @@ .logo { width: 130px; margin: 0 auto; + margin-bottom: 24px; } .btn { diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index 5c56f96c5c..6782633a29 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -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'; diff --git a/services/web/frontend/stylesheets/modules/overleaf-integration.less b/services/web/frontend/stylesheets/modules/overleaf-integration.less index 971d89ae86..bae3ebd68f 100644 --- a/services/web/frontend/stylesheets/modules/overleaf-integration.less +++ b/services/web/frontend/stylesheets/modules/overleaf-integration.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; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index fa9fb173b6..5e312943bc 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 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 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.", "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 about managing your __appName__ account.", "learn_more_about_emails": "<0>Learn more 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 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 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.", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 3f96c2eb5c..ecb6af70b2 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -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": "确认", diff --git a/services/web/test/acceptance/src/AddSecondaryEmailTests.js b/services/web/test/acceptance/src/AddSecondaryEmailTests.js index 526d69e724..b20bb027c7 100644 --- a/services/web/test/acceptance/src/AddSecondaryEmailTests.js +++ b/services/web/test/acceptance/src/AddSecondaryEmailTests.js @@ -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) }) diff --git a/services/web/test/acceptance/src/PrimaryEmailCheckTests.js b/services/web/test/acceptance/src/PrimaryEmailCheckTests.js index 943baaa825..47a560f950 100644 --- a/services/web/test/acceptance/src/PrimaryEmailCheckTests.js +++ b/services/web/test/acceptance/src/PrimaryEmailCheckTests.js @@ -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 () { diff --git a/services/web/test/unit/src/User/UserEmailsControllerTests.js b/services/web/test/unit/src/User/UserEmailsControllerTests.js index 14fd99b841..6d9c339d40 100644 --- a/services/web/test/unit/src/User/UserEmailsControllerTests.js +++ b/services/web/test/unit/src/User/UserEmailsControllerTests.js @@ -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, diff --git a/services/web/test/unit/src/User/UserGetterTests.js b/services/web/test/unit/src/User/UserGetterTests.js index e76ffbd781..36304b38a3 100644 --- a/services/web/test/unit/src/User/UserGetterTests.js +++ b/services/web/test/unit/src/User/UserGetterTests.js @@ -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'