diff --git a/app/assets/javascripts/discourse/app/components/header/auth-buttons.gjs b/app/assets/javascripts/discourse/app/components/header/auth-buttons.gjs index 713e8b44bc3..007c684314e 100644 --- a/app/assets/javascripts/discourse/app/components/header/auth-buttons.gjs +++ b/app/assets/javascripts/discourse/app/components/header/auth-buttons.gjs @@ -1,14 +1,25 @@ import Component from "@glimmer/component"; import { service } from "@ember/service"; -import { and, not } from "truth-helpers"; import DButton from "discourse/components/d-button"; export default class AuthButtons extends Component { @service header; + get showSignupButton() { + return ( + this.args.canSignUp && + !this.header.headerButtonsHidden.includes("signup") && + !this.header.topic + ); + } + + get showLoginButton() { + return !this.header.headerButtonsHidden.includes("login"); + } + <template> <span class="auth-buttons"> - {{#if (and @canSignUp (not this.header.topic))}} + {{#if this.showSignupButton}} <DButton class="btn-primary btn-small sign-up-button" @action={{@showCreateAccount}} @@ -16,12 +27,14 @@ export default class AuthButtons extends Component { /> {{/if}} - <DButton - class="btn-primary btn-small login-button" - @action={{@showLogin}} - @label="log_in" - @icon="user" - /> + {{#if this.showLoginButton}} + <DButton + class="btn-primary btn-small login-button" + @action={{@showLogin}} + @label="log_in" + @icon="user" + /> + {{/if}} </span> </template> } diff --git a/app/assets/javascripts/discourse/app/components/header/icons.gjs b/app/assets/javascripts/discourse/app/components/header/icons.gjs index e48d3f1c742..a49357078c3 100644 --- a/app/assets/javascripts/discourse/app/components/header/icons.gjs +++ b/app/assets/javascripts/discourse/app/components/header/icons.gjs @@ -47,6 +47,10 @@ export default class Icons extends Component { return !this.args.sidebarEnabled || this.site.mobileView; } + get hideSearchButton() { + return this.header.headerButtonsHidden.includes("search"); + } + @action toggleHamburger() { if (this.sidebarState.adminSidebarAllowedWithLegacyNavigationMenu) { @@ -60,16 +64,18 @@ export default class Icons extends Component { <ul class="icons d-header-icons"> {{#each (headerIcons.resolve) as |entry|}} {{#if (eq entry.key "search")}} - <Dropdown - @title="search.title" - @icon="search" - @iconId={{@searchButtonId}} - @onClick={{@toggleSearchMenu}} - @active={{this.search.visible}} - @href={{getURL "/search"}} - @className="search-dropdown" - @targetSelector=".search-menu-panel" - /> + {{#unless this.hideSearchButton}} + <Dropdown + @title="search.title" + @icon="search" + @iconId={{@searchButtonId}} + @onClick={{@toggleSearchMenu}} + @active={{this.search.visible}} + @href={{getURL "/search"}} + @className="search-dropdown" + @targetSelector=".search-menu-panel" + /> + {{/unless}} {{else if (eq entry.key "hamburger")}} {{#if this.showHamburger}} <Dropdown diff --git a/app/assets/javascripts/discourse/app/controllers/password-reset.js b/app/assets/javascripts/discourse/app/controllers/password-reset.js index 6c5c838737d..e00b3e31a71 100644 --- a/app/assets/javascripts/discourse/app/controllers/password-reset.js +++ b/app/assets/javascripts/discourse/app/controllers/password-reset.js @@ -35,6 +35,11 @@ export default Controller.extend(PasswordValidation, { redirected: false, maskPassword: true, + init() { + this._super(...arguments); + this.set("selectedSecondFactorMethod", this.secondFactorMethod); + }, + @discourseComputed() continueButtonText() { return I18n.t("password_reset.continue", { @@ -73,7 +78,7 @@ export default Controller.extend(PasswordValidation, { password: this.accountPassword, second_factor_token: this.securityKeyCredential || this.secondFactorToken, - second_factor_method: this.secondFactorMethod, + second_factor_method: this.selectedSecondFactorMethod, timezone: moment.tz.guess(), }, }) @@ -109,7 +114,7 @@ export default Controller.extend(PasswordValidation, { this.rejectedPasswords.pushObject(this.accountPassword); this.rejectedPasswordsMessages.set( this.accountPassword, - result.errors.password[0] + (result.friendly_messages || []).join("\n") ); } diff --git a/app/assets/javascripts/discourse/app/helpers/hide-application-header-buttons.js b/app/assets/javascripts/discourse/app/helpers/hide-application-header-buttons.js new file mode 100644 index 00000000000..ca55060e8c2 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/hide-application-header-buttons.js @@ -0,0 +1,15 @@ +import Helper from "@ember/component/helper"; +import { scheduleOnce } from "@ember/runloop"; +import { service } from "@ember/service"; + +export default class HideApplicationHeaderButtons extends Helper { + @service header; + + registerHider(buttons) { + this.header.registerHider(this, buttons); + } + + compute([...buttons]) { + scheduleOnce("afterRender", this, this.registerHider, buttons); + } +} diff --git a/app/assets/javascripts/discourse/app/mixins/password-validation.js b/app/assets/javascripts/discourse/app/mixins/password-validation.js index c949e2fc2e9..e3bc3997909 100644 --- a/app/assets/javascripts/discourse/app/mixins/password-validation.js +++ b/app/assets/javascripts/discourse/app/mixins/password-validation.js @@ -81,7 +81,9 @@ export default Mixin.create({ if (password.length < passwordMinLength) { return EmberObject.create( Object.assign(failedAttrs, { - reason: I18n.t("user.password.too_short"), + reason: I18n.t("user.password.too_short", { + password_min_length: passwordMinLength, + }), }) ); } diff --git a/app/assets/javascripts/discourse/app/services/header.js b/app/assets/javascripts/discourse/app/services/header.js index e88007b1635..35689aa0887 100644 --- a/app/assets/javascripts/discourse/app/services/header.js +++ b/app/assets/javascripts/discourse/app/services/header.js @@ -1,5 +1,7 @@ import { tracked } from "@glimmer/tracking"; +import { registerDestructor } from "@ember/destroyable"; import Service, { service } from "@ember/service"; +import { TrackedMap } from "@ember-compat/tracked-built-ins"; import { disableImplicitInjections } from "discourse/lib/implicit-injections"; @disableImplicitInjections @@ -11,6 +13,8 @@ export default class Header extends Service { @tracked userVisible = false; @tracked anyWidgetHeaderOverrides = false; + #hiders = new TrackedMap(); + get useGlimmerHeader() { if (this.siteSettings.glimmer_header_mode === "disabled") { return false; @@ -29,4 +33,22 @@ export default class Header extends Service { } } } + + registerHider(ref, buttons) { + this.#hiders.set(ref, buttons); + + registerDestructor(ref, () => { + this.#hiders.delete(ref); + }); + } + + get headerButtonsHidden() { + const buttonsToHide = new Set(); + this.#hiders.forEach((buttons) => { + buttons.forEach((button) => { + buttonsToHide.add(button); + }); + }); + return Array.from(buttonsToHide); + } } diff --git a/app/assets/javascripts/discourse/app/templates/password-reset.hbs b/app/assets/javascripts/discourse/app/templates/password-reset.hbs index 7caca1c3183..764e9eef0f3 100644 --- a/app/assets/javascripts/discourse/app/templates/password-reset.hbs +++ b/app/assets/javascripts/discourse/app/templates/password-reset.hbs @@ -1,3 +1,6 @@ +{{body-class "password-reset-page"}} +{{hide-application-sidebar}} +{{hide-application-header-buttons "search" "login" "signup"}} <div class="container password-reset clearfix"> <div class="pull-left col-image"> <img src={{this.lockImageUrl}} class="password-reset-img" alt="" /> @@ -19,8 +22,12 @@ {{/unless}} {{/if}} {{else}} - <form> + <form class="change-password-form"> {{#if this.securityKeyOrSecondFactorRequired}} + <h2>{{i18n "user.change_password.title"}}</h2> + <p> + {{i18n "user.change_password.verify_identity"}} + </p> {{#if this.errorMessage}} <div class="alert alert-error">{{this.errorMessage}}</div> <br /> @@ -31,13 +38,13 @@ @challenge={{this.model.security_key_challenge}} @showSecurityKey={{this.model.security_key_required}} @showSecondFactor={{false}} - @secondFactorMethod={{this.secondFactorMethod}} + @secondFactorMethod={{this.selectedSecondFactorMethod}} @otherMethodAllowed={{this.otherMethodAllowed}} @action={{action "authenticateSecurityKey"}} /> {{else}} <SecondFactorForm - @secondFactorMethod={{this.secondFactorMethod}} + @secondFactorMethod={{this.selectedSecondFactorMethod}} @secondFactorToken={{this.secondFactorToken}} @backupEnabled={{this.backupEnabled}} @isLogin={{false}} @@ -47,7 +54,7 @@ "input" (with-event-value (fn (mut this.secondFactorToken))) }} - @secondFactorMethod={{this.secondFactorMethod}} + @secondFactorMethod={{this.selectedSecondFactorMethod}} value={{this.secondFactorToken}} id="second-factor" /> @@ -77,16 +84,15 @@ @maskPassword={{this.maskPassword}} @togglePasswordMask={{this.togglePasswordMask}} /> - <InputTip @validation={{this.passwordValidation}} /> - </div> - <div class="instructions"> <div class="caps-lock-warning {{unless this.capsLockOn 'hidden'}}"> {{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}} </div> </div> + <InputTip @validation={{this.passwordValidation}} /> + <DButton @action={{action "submit"}} @label="user.change_password.set_password" diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index 9689c665d75..7f6b42d5695 100644 --- a/app/assets/javascripts/discourse/app/widgets/header.js +++ b/app/assets/javascripts/discourse/app/widgets/header.js @@ -232,7 +232,7 @@ createWidget( ); createWidget("header-icons", { - services: ["search"], + services: ["search", "header"], tagName: "ul.icons.d-header-icons", init() { @@ -250,17 +250,19 @@ createWidget("header-icons", { resolvedIcons.forEach((icon) => { if (icon.key === "search") { - icons.push( - this.attach("header-dropdown", { - title: "search.title", - icon: "search", - iconId: SEARCH_BUTTON_ID, - action: "toggleSearchMenu", - active: this.search.visible, - href: getURL("/search"), - classNames: ["search-dropdown"], - }) - ); + if (!this.header.headerButtonsHidden.includes("search")) { + icons.push( + this.attach("header-dropdown", { + title: "search.title", + icon: "search", + iconId: SEARCH_BUTTON_ID, + action: "toggleSearchMenu", + active: this.search.visible, + href: getURL("/search"), + classNames: ["search-dropdown"], + }) + ); + } } else if (icon.key === "user-menu" && attrs.user) { icons.push( this.attach("user-dropdown", { @@ -294,6 +296,7 @@ createWidget("header-icons", { }); createWidget("header-buttons", { + services: ["header"], tagName: "span.auth-buttons", html(attrs) { @@ -303,7 +306,11 @@ createWidget("header-buttons", { const buttons = []; - if (attrs.canSignUp && !attrs.topic) { + if ( + attrs.canSignUp && + !attrs.topic && + !this.header.headerButtonsHidden.includes("signup") + ) { buttons.push( this.attach("button", { label: "sign_up", @@ -313,14 +320,17 @@ createWidget("header-buttons", { ); } - buttons.push( - this.attach("button", { - label: "log_in", - className: "btn-primary btn-small login-button", - action: "showLogin", - icon: "user", - }) - ); + if (!this.header.headerButtonsHidden.includes("login")) { + buttons.push( + this.attach("button", { + label: "log_in", + className: "btn-primary btn-small login-button", + action: "showLogin", + icon: "user", + }) + ); + } + return buttons; }, }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/password-reset-test.js b/app/assets/javascripts/discourse/tests/acceptance/password-reset-test.js index 1ea28e0d848..258ebd045f4 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/password-reset-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/password-reset-test.js @@ -27,6 +27,7 @@ acceptance("Password Reset", function (needs) { return helper.response({ success: false, errors: { password: ["is the name of your cat"] }, + friendly_messages: ["Password is the name of your cat"], }); } else { return helper.response({ @@ -52,6 +53,7 @@ acceptance("Password Reset", function (needs) { return helper.response({ success: false, errors: { password: ["invalid"] }, + friendly_messages: ["Password is invalid"], }); } else { return helper.response({ @@ -76,7 +78,9 @@ acceptance("Password Reset", function (needs) { assert.ok(exists(".password-reset .tip.bad"), "input is not valid"); assert.ok( query(".password-reset .tip.bad").innerHTML.includes( - I18n.t("user.password.too_short") + I18n.t("user.password.too_short", { + password_min_length: this.siteSettings.min_password_length, + }) ), "password too short" ); @@ -86,7 +90,7 @@ acceptance("Password Reset", function (needs) { assert.ok(exists(".password-reset .tip.bad"), "input is not valid"); assert.ok( query(".password-reset .tip.bad").innerHTML.includes( - "is the name of your cat" + "Password is the name of your cat" ), "server validation error message shows" ); diff --git a/app/assets/javascripts/discourse/tests/unit/components/create-account-test.js b/app/assets/javascripts/discourse/tests/unit/components/create-account-test.js index ea4b823a383..cdc22f98953 100644 --- a/app/assets/javascripts/discourse/tests/unit/components/create-account-test.js +++ b/app/assets/javascripts/discourse/tests/unit/components/create-account-test.js @@ -1,3 +1,4 @@ +import { getOwner } from "@ember/application"; import { settled } from "@ember/test-helpers"; import { setupTest } from "ember-qunit"; import { module, test } from "qunit"; @@ -75,8 +76,14 @@ module("Unit | Component | create-account", function (hooks) { ); }; + const siteSettings = getOwner(this).lookup("service:site-settings"); testInvalidPassword("", null); - testInvalidPassword("x", I18n.t("user.password.too_short")); + testInvalidPassword( + "x", + I18n.t("user.password.too_short", { + password_min_length: siteSettings.min_password_length, + }) + ); testInvalidPassword( "porkchops123", I18n.t("user.password.same_as_username") diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index 527bb4ca4e3..42fd87a0991 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -50,6 +50,17 @@ body.invite-page { } } +.password-reset-page { + .caps-lock-warning { + display: inline; + } + .change-password-form { + .tip { + display: block; + } + } +} + .toggle-password-mask { align-self: start; line-height: 1.4; // aligns with input description text diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bc4cdaa22ec..55160590e0d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -938,6 +938,7 @@ class UsersController < ApplicationController success: false, message: @error, errors: @user&.errors&.to_hash, + friendly_messages: @user&.errors&.full_messages, is_developer: UsernameCheckerService.is_developer?(@user&.email), admin: @user&.admin?, } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 483aead157c..9bf1450bc07 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1497,6 +1497,8 @@ en: set_password: "Set Password" choose_new: "Choose a new password" choose: "Choose a password" + verify_identity: "To continue, please verify your identity." + title: "Password Reset" second_factor_backup: title: "Two-Factor Backup Codes" @@ -1933,7 +1935,9 @@ en: fine_print: "We are asking you to confirm your identity because this is a potentially sensitive action. Once authenticated, you will only be asked to re-authenticate again after a few hours of inactivity." password: title: "Password" - too_short: "Your password is too short." + too_short: + one: "Your password is too short (minimum is %{password_min_length} character)." + other: "Your password is too short (minimum is %{password_min_length} characters)." common: "That password is too common." same_as_username: "Your password is the same as your username." same_as_email: "Your password is the same as your email." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 52e26eef59f..72b9a2d003d 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -723,6 +723,7 @@ en: bio_raw: "About Me" user: ip_address: "" + password: "Password" errors: <<: *errors models: diff --git a/spec/system/header_spec.rb b/spec/system/header_spec.rb index c0d00a53d4b..ae12d8140dd 100644 --- a/spec/system/header_spec.rb +++ b/spec/system/header_spec.rb @@ -192,6 +192,23 @@ RSpec.describe "Glimmer Header", type: :system do ) end + context "when resetting password" do + fab!(:current_user) { Fabricate(:user) } + + it "does not show search, login, or signup buttons" do + email_token = + current_user.email_tokens.create!( + email: current_user.email, + scope: EmailToken.scopes[:password_reset], + ) + + visit "/u/password-reset/#{email_token.token}" + expect(page).not_to have_css("button.login-button") + expect(page).not_to have_css("button.sign-up-button") + expect(page).not_to have_css(".search-dropdown #search-button") + end + end + context "when logged in and login required" do fab!(:current_user) { Fabricate(:user) } diff --git a/spec/system/legacy_widget_header_spec.rb b/spec/system/legacy_widget_header_spec.rb new file mode 100644 index 00000000000..6b0ce779d2e --- /dev/null +++ b/spec/system/legacy_widget_header_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe "Legacy Widget Header", type: :system do + before { SiteSetting.glimmer_header_mode = "disabled" } + + context "when resetting password" do + fab!(:current_user) { Fabricate(:user) } + + it "does not show search, login, or signup buttons" do + email_token = + current_user.email_tokens.create!( + email: current_user.email, + scope: EmailToken.scopes[:password_reset], + ) + + visit "/u/password-reset/#{email_token.token}" + expect(page).not_to have_css("button.login-button") + expect(page).not_to have_css("button.sign-up-button") + expect(page).not_to have_css(".search-dropdown #search-button") + end + end +end diff --git a/spec/system/login_spec.rb b/spec/system/login_spec.rb index 063f9633ace..2b2377b989e 100644 --- a/spec/system/login_spec.rb +++ b/spec/system/login_spec.rb @@ -8,6 +8,19 @@ shared_examples "login scenarios" do before { Jobs.run_immediately! } + def wait_for_email_link(user, type) + wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } + mail = ActionMailer::Base.deliveries.last + expect(mail.to).to contain_exactly(user.email) + if type == :reset_password + mail.body.to_s[%r{/u/password-reset/\S+}] + elsif type == :activation + mail.body.to_s[%r{/u/activate-account/\S+}] + elsif type == :email_login + mail.body.to_s[%r{/session/email-login/\S+}] + end + end + context "with username and password" do it "can login" do EmailToken.confirm(Fabricate(:email_token, user: user).token) @@ -25,11 +38,7 @@ shared_examples "login scenarios" do expect(page).to have_css(".not-activated-modal") login_modal.click(".activation-controls button.resend") - wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } - - mail = ActionMailer::Base.deliveries.last - expect(mail.to).to contain_exactly(user.email) - activation_link = mail.body.to_s[%r{/u/activate-account/\S+}] + activation_link = wait_for_email_link(user, :activation) visit activation_link find("#activate-account-button").click @@ -53,11 +62,8 @@ shared_examples "login scenarios" do login_modal.find("#modal-alert a").click find("button.forgot-password-reset").click - wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } - - mail = ActionMailer::Base.deliveries.last - expect(mail.to).to contain_exactly(user.email) - expect(mail.body).to match(%r{/u/password-reset/\S+}) + reset_password_link = wait_for_email_link(user, :reset_password) + expect(reset_password_link).to be_present end it "can reset password" do @@ -66,15 +72,11 @@ shared_examples "login scenarios" do login_modal.forgot_password find("button.forgot-password-reset").click - wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } - - mail = ActionMailer::Base.deliveries.last - expect(mail.to).to contain_exactly(user.email) - reset_password_link = mail.body.to_s[%r{/u/password-reset/\S+}] + reset_password_link = wait_for_email_link(user, :reset_password) visit reset_password_link find("#new-account-password").fill_in(with: "newsuperpassword") - find("form .btn-primary").click + find(".change-password-form .btn-primary").click expect(page).to have_css(".header-dropdown-toggle.current-user") end end @@ -85,11 +87,7 @@ shared_examples "login scenarios" do login_modal.fill_username("john") login_modal.email_login_link - wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } - - mail = ActionMailer::Base.deliveries.last - expect(mail.to).to contain_exactly(user.email) - login_link = mail.body.to_s[%r{/session/email-login/\S+}] + login_link = wait_for_email_link(user, :email_login) visit login_link find(".email-login-form .btn-primary").click @@ -148,11 +146,7 @@ shared_examples "login scenarios" do login_modal.fill_username("john") login_modal.email_login_link - wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } - - mail = ActionMailer::Base.deliveries.last - expect(mail.to).to contain_exactly(user.email) - login_link = mail.body.to_s[%r{/session/email-login/\S+}] + login_link = wait_for_email_link(user, :email_login) visit login_link totp = ROTP::TOTP.new(user_second_factor.data).now @@ -166,11 +160,7 @@ shared_examples "login scenarios" do login_modal.fill_username("john") login_modal.email_login_link - wait_for(timeout: 5) { ActionMailer::Base.deliveries.count != 0 } - - mail = ActionMailer::Base.deliveries.last - expect(mail.to).to contain_exactly(user.email) - login_link = mail.body.to_s[%r{/session/email-login/\S+}] + login_link = wait_for_email_link(user, :email_login) visit login_link find(".toggle-second-factor-method").click @@ -178,6 +168,42 @@ shared_examples "login scenarios" do find(".email-login-form .btn-primary").click expect(page).to have_css(".header-dropdown-toggle.current-user") end + + it "can reset password with TOTP" do + login_modal.open + login_modal.fill_username("john") + login_modal.forgot_password + find("button.forgot-password-reset").click + + reset_password_link = wait_for_email_link(user, :reset_password) + visit reset_password_link + + totp = ROTP::TOTP.new(user_second_factor.data).now + find(".second-factor-token-input").fill_in(with: totp) + find(".password-reset .btn-primary").click + + find("#new-account-password").fill_in(with: "newsuperpassword") + find(".change-password-form .btn-primary").click + expect(page).to have_css(".header-dropdown-toggle.current-user") + end + + it "can reset password with a backup code" do + login_modal.open + login_modal.fill_username("john") + login_modal.forgot_password + find("button.forgot-password-reset").click + + reset_password_link = wait_for_email_link(user, :reset_password) + visit reset_password_link + + find(".toggle-second-factor-method").click + find(".second-factor-token-input").fill_in(with: "iAmValidBackupCode") + find(".password-reset .btn-primary").click + + find("#new-account-password").fill_in(with: "newsuperpassword") + find(".change-password-form .btn-primary").click + expect(page).to have_css(".header-dropdown-toggle.current-user") + end end end