diff --git a/app/assets/javascripts/discourse/app/components/modal/login.hbs b/app/assets/javascripts/discourse/app/components/modal/login.hbs index bd02b8f0a64..f60a4272506 100644 --- a/app/assets/javascripts/discourse/app/components/modal/login.hbs +++ b/app/assets/javascripts/discourse/app/components/modal/login.hbs @@ -5,6 +5,7 @@ @flash={{this.flash}} @flashType={{this.flashType}} {{did-insert this.preloadLogin}} + {{on "click" this.interceptResetLink}} > <:body> diff --git a/app/assets/javascripts/discourse/app/components/modal/login.js b/app/assets/javascripts/discourse/app/components/modal/login.js index 75cc5d5f0d8..76ad67f1fc7 100644 --- a/app/assets/javascripts/discourse/app/components/modal/login.js +++ b/app/assets/javascripts/discourse/app/components/modal/login.js @@ -3,10 +3,12 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import { schedule } from "@ember/runloop"; import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; import { isEmpty } from "@ember/utils"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import cookie, { removeCookie } from "discourse/lib/cookie"; +import { wantsNewWindow } from "discourse/lib/intercept-click"; import { areCookiesEnabled } from "discourse/lib/utilities"; import { wavingHandURL } from "discourse/lib/waving-hand-url"; import { @@ -18,6 +20,7 @@ import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import escape from "discourse-common/lib/escape"; import getURL from "discourse-common/lib/get-url"; import I18n from "discourse-i18n"; +import ForgotPassword from "./forgot-password"; export default class Login extends Component { @service capabilities; @@ -25,6 +28,7 @@ export default class Login extends Component { @service siteSettings; @service site; @service login; + @service modal; @tracked loggingIn = false; @tracked loggedIn = false; @@ -248,6 +252,13 @@ export default class Login extends Component { } else if (result.reason === "suspended") { this.args.closeModal(); this.dialog.alert(result.error); + } else if (result.reason === "expired") { + this.flash = htmlSafe( + I18n.t("login.password_expired", { + reset_url: getURL("/password-reset"), + }) + ); + this.flashType = "error"; } else { this.flash = result.error; this.flashType = "error"; @@ -344,4 +355,21 @@ export default class Login extends Component { } this.args.model.showCreateAccount(createAccountProps); } + + @action + interceptResetLink(event) { + if ( + !wantsNewWindow(event) && + event.target.href && + new URL(event.target.href).pathname === getURL("/password-reset") + ) { + event.preventDefault(); + event.stopPropagation(); + this.modal.show(ForgotPassword, { + model: { + emailOrUsername: this.loginName, + }, + }); + } + } } diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 00e72a294b1..b1fc3cbe20a 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -353,13 +353,7 @@ class SessionController < ApplicationController # User's password has expired so they need to reset it if user.password_expired?(password) - begin - enqueue_password_reset_for_user(user) - rescue RateLimiter::LimitExceeded - # Just noop here as user would have already been sent the forgot password email more than once - end - - render json: { error: I18n.t("login.password_expired") } + render json: { error: "expired", reason: "expired" } return end else diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d6000966086..69a0ca726eb 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2291,6 +2291,7 @@ en: error: "Unknown error" cookies_error: "Your browser seems to have cookies disabled. You might not be able to log in without enabling them first." rate_limit: "Please wait before trying to log in again." + password_expired: "Password expired. Please reset your password." blank_username: "Please enter your email or username." blank_username_or_password: "Please enter your email or username, and password." reset_password: "Reset Password" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 924d1ff7123..5d961dbea6e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2899,7 +2899,6 @@ en: not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in." incorrect_username_email_or_password: "Incorrect username, email or password" incorrect_password: "Incorrect password" - password_expired: "Password expired. Reset instructions sent to your email." incorrect_password_or_passkey: "Incorrect password or passkey" wait_approval: "Thanks for signing up. We will notify you when your account has been approved." active: "Your account is activated and ready to use." diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index f441c51c3eb..87f2a768934 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -2045,53 +2045,14 @@ RSpec.describe SessionController do use_redis_snapshotting - it "should return an error response code with the right error message and enqueues the password reset email" do - expect_enqueued_with( - job: :critical_user_email, - args: { - type: "forgot_password", - user_id: user.id, - }, - ) do - post "/session.json", params: { login: user.username, password: "myawesomepassword" } - end + it "should return an error response code with the right error message" do + post "/session.json", params: { login: user.username, password: "myawesomepassword" } expect(response.status).to eq(200) - expect(response.parsed_body["error"]).to eq(I18n.t("login.password_expired")) + expect(response.parsed_body["error"]).to eq("expired") + expect(response.parsed_body["reason"]).to eq("expired") expect(session[:current_user_id]).to eq(nil) end - - it "should limit the number of forgot password emails sent a day to the user when logging in with an expired password" do - SiteSetting.max_logins_per_ip_per_minute = - described_class::FORGOT_PASSWORD_EMAIL_LIMIT_PER_DAY + 1 - - SiteSetting.max_logins_per_ip_per_hour = - described_class::FORGOT_PASSWORD_EMAIL_LIMIT_PER_DAY + 1 - - described_class::FORGOT_PASSWORD_EMAIL_LIMIT_PER_DAY.times do - expect_enqueued_with( - job: :critical_user_email, - args: { - type: "forgot_password", - user_id: user.id, - }, - ) do - post "/session.json", params: { login: user.username, password: "myawesomepassword" } - expect(response.status).to eq(200) - end - end - - expect_not_enqueued_with( - job: :critical_user_email, - args: { - type: "forgot_password", - user_id: user.id, - }, - ) do - post "/session.json", params: { login: user.username, password: "myawesomepassword" } - expect(response.status).to eq(200) - end - end end context "when a user has security key-only 2FA login" do diff --git a/spec/system/login_spec.rb b/spec/system/login_spec.rb index c0337f184f8..063f9633ace 100644 --- a/spec/system/login_spec.rb +++ b/spec/system/login_spec.rb @@ -41,13 +41,23 @@ shared_examples "login scenarios" do it "displays the right message when user's email has been marked as expired" do password = "myawesomepassword" user.update!(password:) - expired_user_password = Fabricate(:expired_user_password, user:, password:) + Fabricate(:expired_user_password, user:, password:) login_modal.open login_modal.fill(username: user.username, password:) login_modal.click_login - expect(login_modal).to have_content(I18n.t("login.password_expired")) + expect(login_modal.find("#modal-alert")).to have_content( + I18n.t("js.login.password_expired", reset_url: "/password-reset").gsub(/<.*?>/, ""), + ) + 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+}) end it "can reset password" do