diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index a4182e50d5..57a1e3874e 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -9,6 +9,7 @@ import OError from '@overleaf/o-error' import EmailsHelper from '../Helpers/EmailHelper.mjs' import { expressify } from '@overleaf/promise-utils' import { z, validateReq } from '../../infrastructure/Validation.mjs' +import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' const setNewUserPasswordSchema = z.object({ body: z.object({ @@ -186,16 +187,20 @@ async function renderSetPasswordForm(req, res, next) { return res.redirect('/user/password/reset?error=token_expired') } req.session.resetToken = query.passwordResetToken - let emailQuery = '' + const params = new URLSearchParams() if (typeof query.email === 'string') { const email = EmailsHelper.parseEmail(query.email) if (email) { - emailQuery = `?email=${encodeURIComponent(email)}` + params.append('email', email) } } - - return res.redirect('/user/password/set' + emailQuery) + if (req.query.uniaccessphase1) { + // Preserve uniaccessphase1 flag in the redirect so it can be tested + params.append('uniaccessphase1', req.query.uniaccessphase1) + } + const queryString = params.toString() ? `?${params.toString()}` : '' + return res.redirect('/user/password/set' + queryString) } catch (err) { if (err.name === 'ForbiddenError') { return next(err) @@ -214,11 +219,22 @@ async function renderSetPasswordForm(req, res, next) { const passwordResetToken = req.session.resetToken delete req.session.resetToken - res.render('user/setPassword', { - title: 'set_password', - email, - passwordResetToken, - }) + const ciamAssignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'uniaccessphase1' + ) + + res.render( + ciamAssignment.variant === 'enabled' + ? 'user/setPasswordCiam' + : 'user/setPassword', + { + title: 'set_password', + email, + passwordResetToken, + } + ) } const renderRequestResetFormSchema = z.object({ @@ -235,10 +251,21 @@ async function renderRequestResetForm(req, res) { error = 'password_reset_token_expired' } - res.render('user/passwordReset', { - title: 'reset_password', - error, - }) + const ciamAssignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'uniaccessphase1' + ) + + res.render( + ciamAssignment.variant === 'enabled' + ? 'user/passwordResetCiam' + : 'user/passwordReset', + { + title: 'reset_password', + error, + } + ) } export default { diff --git a/services/web/app/views/_mixins/ciam_mixins.pug b/services/web/app/views/_mixins/ciam_mixins.pug index 6cf7a9b986..84a226a8a9 100644 --- a/services/web/app/views/_mixins/ciam_mixins.pug +++ b/services/web/app/views/_mixins/ciam_mixins.pug @@ -1,6 +1,5 @@ include terms_of_service include recaptcha -include ../../../modules/saas-authentication/app/views/_mixins mixin ciamLogo header.ciam-logo @@ -10,7 +9,7 @@ mixin ciamLogo mixin ciamCardSeparator hr.ciam-card-separator -mixin ciamCardFooter +mixin ciamCardFooter(short) section.ciam-card-footer +ciamCardSeparator .ciam-footer-ds-logo @@ -24,7 +23,10 @@ mixin ciamCardFooter alt='Digital Science — home' ) p - | !{translate('advancing_research_with', null, [{ name: 'a', attrs: { href: 'https://www.overleaf.com/', target: '_blank', rel: 'noopener noreferrer' }}, { name: 'a', attrs: { href: 'https://www.papersapp.com/', target: '_blank', rel: 'noopener noreferrer' }}])} + if short + | !{translate('overleaf_is_a_ds_product')} + else + | !{translate('advancing_research_with', null, [{ name: 'a', attrs: { href: 'https://www.overleaf.com/', target: '_blank', rel: 'noopener noreferrer' }}, { name: 'a', attrs: { href: 'https://www.papersapp.com/', target: '_blank', rel: 'noopener noreferrer' }}])} mixin ciamTermsOfServiceAgreement p @@ -36,7 +38,7 @@ mixin ciamRecaptchaConditions mixin ciamCustomFormDangerMessage(key) div( - class='notification ciam-notification notification-type-error' + class='notification notification-ds notification-type-error' hidden data-ol-custom-form-message=key role='alert' @@ -47,10 +49,6 @@ mixin ciamCustomFormDangerMessage(key) .notification-content.text-left block -mixin ciamSamlErrorNotLoggedIn(error) - +samlErrorNotLoggedIn(error) - ph-warning-circle(aria-hidden='true') - mixin ciamFooter footer .footer-links diff --git a/services/web/app/views/user/passwordResetCiam.pug b/services/web/app/views/user/passwordResetCiam.pug new file mode 100644 index 0000000000..5647e187b1 --- /dev/null +++ b/services/web/app/views/user/passwordResetCiam.pug @@ -0,0 +1,98 @@ +extends ../layout-website-redesign +include ../_mixins/recaptcha +include ../_mixins/notification +include ../_mixins/ciam_mixins + +block vars + - var suppressNavbar = true + - var suppressFooter = true + - var suppressSkipToContent = true + - isWebsiteRedesign = true + +block content + - var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset) + + if showCaptcha + script( + type='text/javascript' + nonce=scriptNonce + src='https://www.recaptcha.net/recaptcha/api.js?render=explicit' + ) + div( + id='recaptcha' + class='g-recaptcha' + data-sitekey=settings.recaptcha.siteKey + data-size='invisible' + data-badge='inline' + ) + + .ciam-enabled.ciam-layout.ciam-password-reset( + data-ol-captcha-retry-trigger-area='' + ) + +ciamLogo + .ciam-container + main#main-content.ciam-card + form( + name='passwordResetForm' + captcha-action-name=showCaptcha ? 'passwordReset' : false + data-ciam-form + data-ol-async-form + action='/user/password/reset' + method='POST' + captcha=showCaptcha ? '' : false + ) + if error === 'password_reset_token_expired' + h1 #{translate("sorry_your_token_expired")} + p.intro-p #{translate('please_request_a_new_password_reset_email_and_follow_the_link')}. + else + h1(data-ol-not-sent) #{translate("reset_your_password")} + h1(hidden data-ol-sent) #{translate("check_your_inbox")} + p.intro-p(data-ol-not-sent) #{translate("enter_your_email_and_we_will_send_reset_instructions")} + + div(data-ol-not-sent) + +formMessagesNewStyle + if error && error !== 'password_reset_token_expired' + +ciamErrorNotification + p #{translate(error)} + + div(data-ol-custom-form-message='no-password-allowed-due-to-sso' hidden) + +ciamErrorNotification + p !{translate('you_cant_reset_password_due_to_sso', {}, [{name: 'a', attrs: {href: '/sso-login'}}])} + input(name='_csrf' type='hidden' value=csrfToken) + .form-group.form-group-ds + label.form-label(for='email') #{translate("email")} + input#email.form-control.form-control-ds.form-control-lg( + name='email' + type='email' + required + autocomplete='username' + autofocus + ) + .actions + button.btn.btn-ds.btn-lg.btn-primary.w-100( + type='submit' + data-ol-disabled-inflight + data-ol-spinner-inflight + aria-label=translate('send_reset_link') + ) + +ciamButtonContentLoading(`${translate('requesting_password_reset')}…`)= translate('send_reset_link') + p.ciam-login-text + a(href='/login') #{translate("back_to_log_in")} + div(hidden data-ol-sent) + p.intro-p !{translate('if_theres_an_account_youll_get_reset_email', {email: 'your email'})} + p.ciam-login-text.text-start + a(href='/login') #{translate('back_to_log_in')} + + if showCaptcha + .ciam-disclaimers + +ciamRecaptchaConditions + + +ciamCardFooter(true) + + // retrieve and display the email used for password reset after form is sent + script(nonce=scriptNonce). + document.querySelector('form[name="passwordResetForm"]').addEventListener('sent', function () { + const email = this.querySelector('input[name="email"]').value + const display = document.getElementById('sent-email-display') + if (display) display.textContent = email + }) diff --git a/services/web/app/views/user/setPassword.pug b/services/web/app/views/user/setPassword.pug index 5081d22409..dc3b1db0bc 100644 --- a/services/web/app/views/user/setPassword.pug +++ b/services/web/app/views/user/setPassword.pug @@ -28,11 +28,11 @@ block content +formMessagesNewStyle +customFormMessageNewStyle('password-contains-email', 'danger') - | #{translate('invalid_password_contains_email')}. + | #{translate('invalid_password_contains_email')} | #{translate('use_a_different_password')}. +customFormMessageNewStyle('password-too-similar', 'danger') - | #{translate('invalid_password_too_similar')}. + | #{translate('invalid_password_too_similar')} | #{translate('use_a_different_password')}. +customFormMessageNewStyle('token-expired', 'danger') @@ -65,7 +65,7 @@ block content | #{translate('password_cant_be_the_same_as_current_one')}. +customValidationMessageNewStyle('password-must-be-strong') - | !{translate('password_was_detected_on_a_public_list_of_known_compromised_passwords', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}])}. + | !{translate('password_was_detected_on_a_public_list_of_known_compromised_passwords', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}])} | #{translate('use_a_different_password')}. input(name='passwordResetToken' type='hidden' value=passwordResetToken) diff --git a/services/web/app/views/user/setPasswordCiam.pug b/services/web/app/views/user/setPasswordCiam.pug new file mode 100644 index 0000000000..3e3540c9f3 --- /dev/null +++ b/services/web/app/views/user/setPasswordCiam.pug @@ -0,0 +1,110 @@ +extends ../layout-website-redesign +include ../_mixins/ciam_mixins + +block vars + - var suppressNavbar = true + - var suppressFooter = true + - var suppressSkipToContent = true + - isWebsiteRedesign = true + +block content + .ciam-enabled.ciam-layout.ciam-password-set + +ciamLogo + .ciam-container + main#main-content.ciam-card + form( + name='passwordResetForm' + data-ciam-form + data-ol-async-form + action='/user/password/set' + method='POST' + data-ol-hide-on-error='token-expired' + ) + div(hidden data-ol-sent) + h1 #{translate("your_password_has_been_reset")} + p.intro-p #{translate("you_can_now_sign_in_with_new_password")}. + a.btn.btn-ds.btn-lg.btn-primary.w-100(href='/login') #{translate("go_to_sign_in")} + + div(data-ol-not-sent) + h1 #{translate("choose_a_new_password")} + +formMessagesNewStyle + + +ciamCustomFormDangerMessage('password-contains-email') + | #{translate('invalid_password_contains_email')} + | #{translate('use_a_different_password')}. + + +ciamCustomFormDangerMessage('password-too-similar') + | #{translate('invalid_password_too_similar')} + | #{translate('use_a_different_password')}. + + +ciamCustomFormDangerMessage('token-expired') + | #{translate('password_reset_token_expired')} + br + a(href='/user/password/reset') + | #{translate('request_new_password_reset_email')} + + +ciamCustomFormDangerMessage('invalid-password') + | #{translate('invalid_password')}. + + +ciamCustomFormDangerMessage('password-must-be-different') + | #{translate('password_cant_be_the_same_as_current_one')}. + + +ciamCustomFormDangerMessage('password-must-be-strong') + | !{translate('password_was_detected_on_a_public_list_of_known_compromised_passwords', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}])} + | #{translate('use_a_different_password')}. + + input(name='_csrf' type='hidden' value=csrfToken) + input( + name='email' + type='text' + hidden + autocomplete='username' + value=email + ) + + .form-group.form-group-ds + label.form-label( + for='passwordField' + data-ol-hide-on-error-message='token-expired' + ) #{translate("password")} + .form-group-password + .form-group-password-input + .form-complex-input-container + input#passwordField.form-control.form-control-ds.form-control-lg( + name='password' + type='password' + autocomplete='new-password' + autofocus + required + minlength=settings.passwordStrengthOptions.length.min + data-ol-password-visibility-target + ) + button.visibility-toggle( + type='button' + data-ol-password-visibility-toggle='visibilityOn' + aria-controls='password' + aria-label=translate('turn_on_password_visibility') + ) + ph-eye.form-input-icon-ds + button.visibility-toggle( + type='button' + data-ol-password-visibility-toggle='visibilityOff' + aria-controls='password' + aria-label=translate('turn_off_password_visibility') + hidden + ) + ph-eye-slash.form-input-icon-ds + + input(name='passwordResetToken' type='hidden' value=passwordResetToken) + div(data-ol-hide-on-error-message='token-expired') + p.password-policy #{translate('must_be_at_least_n_characters', {n: settings.passwordStrengthOptions.length.min})} + .actions + button.btn.btn-ds.btn-lg.btn-primary.w-100( + type='submit' + data-ol-disabled-inflight + data-ol-spinner-inflight + aria-label=translate('save_new_password') + ) + +ciamButtonContentLoading(`${translate('processing')}…`)= translate('save_new_password') + + +ciamCardFooter(true) diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.ts b/services/web/frontend/js/features/form-helpers/hydrate-form.ts index eb2abdb0ca..33b0e9b576 100644 --- a/services/web/frontend/js/features/form-helpers/hydrate-form.ts +++ b/services/web/frontend/js/features/form-helpers/hydrate-form.ts @@ -304,6 +304,8 @@ function showMessagesNewStyle( el.hidden = true }) + const isDsBranded = formEl.dataset.ciamForm !== undefined + // Render messages messageBag.forEach(message => { const customErrorElements = message.key @@ -322,6 +324,7 @@ function showMessagesNewStyle( messageElContainer.className = classNames('notification', { 'notification-type-error': message.type === 'error', 'notification-type-success': message.type !== 'error', + 'notification-ds': isDsBranded, }) const messageEl = document.createElement('div') @@ -344,7 +347,6 @@ function showMessagesNewStyle( } // create the left icon - const isDsBranded = formEl.dataset.ciamForm !== undefined const messageIcon = document.createElement('div') messageIcon.className = 'notification-icon' if ( diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index ebfa9183bc..9128119dc3 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/services/web/frontend/js/features/settings/components/password-section.tsx @@ -177,16 +177,16 @@ function PasswordForm() { />, ]} /> - . {t('use_a_different_password')}. + {t('use_a_different_password')}. ) : getErrorMessageKey(error) === 'password-contains-email' ? ( <> - {t('invalid_password_contains_email')}.{' '} + {t('invalid_password_contains_email')}{' '} {t('use_a_different_password')}. ) : getErrorMessageKey(error) === 'password-too-similar' ? ( <> - {t('invalid_password_too_similar')}.{' '} + {t('invalid_password_too_similar')}{' '} {t('use_a_different_password')}. ) : ( diff --git a/services/web/frontend/stylesheets/ciam/all.scss b/services/web/frontend/stylesheets/ciam/all.scss index c5eb0fd43e..6e73cd2fbe 100644 --- a/services/web/frontend/stylesheets/ciam/all.scss +++ b/services/web/frontend/stylesheets/ciam/all.scss @@ -2,6 +2,7 @@ @import 'ciam-layout'; @import 'ciam-links'; @import 'ciam-login'; +@import 'ciam-password-reset'; @import 'ciam-register'; @import 'ciam-six-digits'; @import 'ciam-try-premium'; diff --git a/services/web/frontend/stylesheets/ciam/ciam-layout.scss b/services/web/frontend/stylesheets/ciam/ciam-layout.scss index 2281c55812..8d9ed3aeb2 100644 --- a/services/web/frontend/stylesheets/ciam/ciam-layout.scss +++ b/services/web/frontend/stylesheets/ciam/ciam-layout.scss @@ -108,7 +108,6 @@ border-radius: var(--ds-border-radius-400); max-width: 464px; margin: 0 auto; - min-height: 500px; @include media-breakpoint-up(sm) { padding: var(--ds-spacing-1300); diff --git a/services/web/frontend/stylesheets/ciam/ciam-password-reset.scss b/services/web/frontend/stylesheets/ciam/ciam-password-reset.scss new file mode 100644 index 0000000000..d51835d370 --- /dev/null +++ b/services/web/frontend/stylesheets/ciam/ciam-password-reset.scss @@ -0,0 +1,28 @@ +.ciam-password-set, +.ciam-password-reset { + h1 { + margin-bottom: var(--ds-spacing-600); + } + + p.intro-p { + margin-bottom: var(--ds-spacing-800); + } + + .notification { + margin-bottom: var(--ds-spacing-800); + } + + .password-policy { + color: var(--ds-color-text-secondary); + } + + .form-group-ds { + margin-bottom: var(--ds-spacing-400); + } + + .actions { + display: flex; + flex-direction: column; + gap: var(--ds-spacing-400); + } +} diff --git a/services/web/frontend/stylesheets/pages/login-register.scss b/services/web/frontend/stylesheets/pages/login-register.scss index 72131cdcb7..e1ca000eef 100644 --- a/services/web/frontend/stylesheets/pages/login-register.scss +++ b/services/web/frontend/stylesheets/pages/login-register.scss @@ -36,7 +36,9 @@ flex-direction: column; .form-group-password-input { - input.form-control { + input.form-control, + // needlessly specific selectors to override competing styles + input.form-control.form-control-ds.form-control-lg { padding-right: var(--password-visibility-toggle-width); } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 5502ac64b8..cc4fa87425 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -330,10 +330,12 @@ "chat_error": "Could not load chat messages, please try again.", "chat_with_sales_team_50_or_more": "Chat with our sales team about larger discounts for groups of 50 or more.", "check_your_email": "Check your email", + "check_your_inbox": "Check your inbox", "checking": "Checking", "checking_dropbox_status": "Checking Dropbox status", "checking_project_github_status": "Checking project status in GitHub", "choose_a_custom_color": "Choose a custom color", + "choose_a_new_password": "Choose a new password", "choose_from_group_members": "Choose from group members", "choose_how_you_search_your_references": "Choose how you search your references", "choose_which_experiments": "Choose which experiments you’d like to try.", @@ -720,6 +722,7 @@ "enter_the_number_of_licenses_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of licenses you’d like to add to see the cost breakdown.", "enter_your_email": "Enter your email", "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_and_we_will_send_reset_instructions": "Enter your email and we’ll send reset instructions.", "enter_your_password": "Enter your password", "equation_generator": "Equation Generator", "equation_preview": "Equation preview", @@ -953,6 +956,7 @@ "go_to_pdf_location_in_code": "Go to PDF location in code (Tip: double click on the PDF for best results)", "go_to_previous_page": "Go to previous page", "go_to_settings": "Go to settings", + "go_to_sign_in": "Go to sign in", "go_to_subscriptions": "Go to Subscriptions", "go_to_writefull": "Go to Writefull", "good_news_you_already_purchased_this_add_on": "Good news! You already have this add-on, so no need to pay again.", @@ -1058,6 +1062,7 @@ "id": "ID", "if_have_existing_can_link": "If you have an existing __appName__ account on another email, you can link it to your __institutionName__ account by clicking __clickText__.", "if_owner_can_link": "If you own the __appName__ account with __email__, you will be allowed to link it to your __institutionName__ institutional account.", + "if_theres_an_account_youll_get_reset_email": "If there’s an account for __email__, you’ll get an email with reset instructions.", "if_you_find_any_issues_with_texlive": "If you find any issues with TeX Live, packages, or compilation, please <0>provide feedback. If you hit problems, you can <1>switch your compiler.", "if_you_need_to_customize_your_table_further_you_can": "If you need to customize your table further, you can. Using LaTeX code, you can change anything from table styles and border styles to colors and column widths. <0>Read our guide to using tables in LaTeX to help you get started.", "if_you_need_to_delete_your_writefull_account": "If you need to delete your Writefull account, go to your Writefull account settings.", @@ -1597,6 +1602,7 @@ "overleaf_group_plans": "Overleaf group plans", "overleaf_history_system": "Overleaf History System", "overleaf_individual_plans": "Overleaf individual plans", + "overleaf_is_a_ds_product": "Overleaf is a Digital Science product.", "overleaf_is_easy_to_use": "Overleaf is easy to use.", "overleaf_labs": "Overleaf Labs", "overleaf_logo": "Overleaf logo", @@ -2003,6 +2009,7 @@ "saml_response": "SAML Response", "save": "Save", "save_20_percent": "save 20%", + "save_new_password": "Save new password", "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", @@ -2083,6 +2090,7 @@ "send_first_message": "Send your first message to your collaborators", "send_message": "Send message", "send_request": "Send request", + "send_reset_link": "Send reset link", "send_test_email": "Send a test email", "sending": "Sending", "sent": "Sent", @@ -2801,6 +2809,7 @@ "you_can_now_enable_sso": "You can now enable SSO on your group settings page.", "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features.", "you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "You can now search and add references from your __referenceManager__ library without needing to import files—just type \\cite{} in your .tex file. Learn more", + "you_can_now_sign_in_with_new_password": "You can now sign in with your new password.", "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out of the program at any time on this page", "you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.", "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", @@ -2853,6 +2862,7 @@ "your_message_to_collaborators": "Send a message to your collaborators", "your_name_and_email_address_will_be_visible_to_the_project_owner_and_other_editors": "Your name and email address will be visible to the project owner and other editors.", "your_new_plan": "Your new plan", + "your_password_has_been_reset": "Your password has been reset", "your_password_has_been_successfully_changed": "Your password has been successfully changed", "your_password_was_detected": "Your password is on a <0>public list of known compromised passwords. Keep your account safe by changing your password now.", "your_plan": "Your plan", diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs index 19c9b87956..e8db5c0af4 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs @@ -57,6 +57,11 @@ describe('PasswordResetController', function () { removeReconfirmFlag: sinon.stub().resolves(), }, } + ctx.SplitTestHandler = { + promises: { + getAssignment: sinon.stub().resolves({ variant: 'default' }), + }, + } vi.doMock('@overleaf/settings', () => ({ default: ctx.settings, @@ -105,6 +110,13 @@ describe('PasswordResetController', function () { default: ctx.UserUpdater, })) + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + ctx.PasswordResetController = (await import(MODULE_PATH)).default })