[web] Create CIAM versions of the password reset screens (#30087)

* Make CIAM copies of Pug files

passwordResetCiam.pug
setPasswordCiam.pug

* Update controller with split test assignment

* Use CIAM layout in passwordResetCiam.pug

* Style passwordResetCiam according to designs

* Use CIAM layout in setPasswordCiam.pug

* Style setPasswordCiam according to designs

* Use settings value in registration screen for must_be_at_least_n_characters

* Retrieve email input with a script

* Replace mb-4 by --ds-spacing-800

* Add eye icon to toggle password visibility

* Avoid double dots after some translated strings

* Use `ciamCustomFormDangerMessage`

* Use `ciamErrorNotification`

* Use `ciamButtonContentLoading`

* Replace remaining "mb" classes

* Move new password errors to the top of the form

* Fix CIAM mixins path after rebase

* Use `ciamCustomFormDangerMessage`

* Add `data-ol-spinner-inflight` to buttons

* Replace classname ciam-notification by notification-ds

Remove borders from CIAM notifications
Fix font size

* Revert "Use settings value in registration screen for must_be_at_least_n_characters"

This reverts commit a0af95c11e171097750ad7ee871f6baf89d5c0cb.

(It's Friday afternoon so I don't want to update unrelated stuff :D)

* Update: check_your_inbox

* Remove `.ciam-card` min-height.

Unnecessary thanks to `.confirm-email-success-form`'s min-height: 400px;

* Use phosphor icons

* Style `formMessagesNewStyle` with DS notifications within CIAM pages

Alternatively, we could extend/duplicate `showMessagesNewStyle` with a CIAM variant

* Revert "Style `formMessagesNewStyle` with DS notifications within CIAM pages"

This reverts commit ed382dc1e8cdf5b916c1527f4da0a825167e9675.

* Fix styling of dynamically-created DS notifications

* Set password length info to secondary color

* Move `ciamSamlErrorNotLoggedIn` to saas-authentication module

Prevents errors in CE:

Error: ENOENT: no such file or directory, open '/overleaf/services/web/modules/saas-authentication/app/views/_mixins.pug'
    at /overleaf/services/web/app/views/_mixins/ciam_mixins.pug line 3

---------

Co-authored-by: Tim Down <158919+timdown@users.noreply.github.com>
GitOrigin-RevId: afe58f18ecee92460ab628a285b6edb48a5c678d
This commit is contained in:
Antoine Clausse
2025-12-08 11:06:30 +01:00
committed by Copybot
parent b9888957e4
commit 6eefe6dda4
13 changed files with 317 additions and 30 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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: '<span id="sent-email-display">your email</span>'})}
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
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 (

View File

@@ -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')}.
</>
) : (

View File

@@ -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';

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 youd 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 youd 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 well 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 <b>__appName__</b> account on another email, you can link it to your <b>__institutionName__</b> account by clicking <b>__clickText__</b>.",
"if_owner_can_link": "If you own the <b>__appName__</b> account with <b>__email__</b>, you will be allowed to link it to your <b>__institutionName__</b> institutional account.",
"if_theres_an_account_youll_get_reset_email": "If theres an account for __email__, youll 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</0>. If you hit problems, you can <1>switch your compiler</1>.",
"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</0> 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 <a>Writefull account settings.</a>",
@@ -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</0>.",
"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 <code>\\cite{}</code> in your .tex file. <a>Learn more</a>",
"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</0> 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</0>. Keep your account safe by changing your password now.",
"your_plan": "Your plan",

View File

@@ -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
})