From f7be74eb73453b26a7c570c33f19442c26d215d2 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Fri, 5 Sep 2025 21:48:22 +0300 Subject: [PATCH] improve user password reset --- app/controllers/passwords_controller.rb | 4 ++ app/controllers/profile_controller.rb | 4 +- app/controllers/users_controller.rb | 9 ++++- app/javascript/application.js | 2 + app/javascript/elements/visible_on_input.js | 14 +++++++ app/views/profile/index.html.erb | 24 ++++++++---- config/locales/i18n.yml | 18 +++++++++ config/routes.rb | 4 +- spec/system/profile_settings_spec.rb | 43 ++++++++++++++++++++- 9 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 app/javascript/elements/visible_on_input.js diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 7ca14716..5ae3ff32 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class PasswordsController < Devise::PasswordsController + # rubocop:disable Rails/LexicallyScopedActionFilter + skip_before_action :require_no_authentication, only: %i[edit update] + # rubocop:enable Rails/LexicallyScopedActionFilter + class Current < ActiveSupport::CurrentAttributes attribute :user end diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb index 668cd140..a1ee71bf 100644 --- a/app/controllers/profile_controller.rb +++ b/app/controllers/profile_controller.rb @@ -16,7 +16,7 @@ class ProfileController < ApplicationController end def update_password - if current_user.update(password_params) + if current_user.update_with_password(password_params) bypass_sign_in(current_user) redirect_to settings_profile_index_path, notice: I18n.t('password_has_been_changed') else @@ -31,6 +31,6 @@ class ProfileController < ApplicationController end def password_params - params.require(:user).permit(:password, :password_confirmation) + params.require(:user).permit(:password, :password_confirmation, :current_password) end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2e361097..46daba2d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UsersController < ApplicationController - load_and_authorize_resource :user, only: %i[index edit update destroy] + load_and_authorize_resource :user, only: %i[index edit update destroy resend_reset_password] before_action :build_user, only: %i[new create] authorize_resource :user, only: %i[new create] @@ -71,6 +71,13 @@ class UsersController < ApplicationController redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_removed') end + def resend_reset_password + current_user.send_reset_password_instructions + + redirect_back fallback_location: settings_users_path, + notice: I18n.t('you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes') + end + private def role_valid?(role) diff --git a/app/javascript/application.js b/app/javascript/application.js index 7b51186d..0352f3f9 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -39,6 +39,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group' import PageContainer from './elements/page_container' import EmailEditor from './elements/email_editor' import MountOnClick from './elements/mount_on_click' +import VisibleOnInput from './elements/visible_on_input' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -113,6 +114,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) safeRegisterElement('page-container', PageContainer) safeRegisterElement('email-editor', EmailEditor) safeRegisterElement('mount-on-click', MountOnClick) +safeRegisterElement('visible-on-input', VisibleOnInput) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/elements/visible_on_input.js b/app/javascript/elements/visible_on_input.js new file mode 100644 index 00000000..37292173 --- /dev/null +++ b/app/javascript/elements/visible_on_input.js @@ -0,0 +1,14 @@ +export default class extends HTMLElement { + connectedCallback () { + this.input = document.getElementById(this.dataset.inputId) + + this.input.addEventListener('input', () => { + if (this.input.value.trim().length > 0) { + this.classList.remove('hidden') + } else { + this.classList.add('hidden') + this.querySelectorAll('input').forEach(input => { input.value = '' }) + } + }) + } +} diff --git a/app/views/profile/index.html.erb b/app/views/profile/index.html.erb index 6732d907..90a2475a 100644 --- a/app/views/profile/index.html.erb +++ b/app/views/profile/index.html.erb @@ -59,14 +59,24 @@ <%= f.label :password, t('new_password'), class: 'label' %> <%= f.password_field :password, autocomplete: 'off', class: 'base-input' %> -
- <%= f.label :password_confirmation, t('confirm_password'), class: 'label' %> - <%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %> -
-
- <%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %> -
+ +
+ <%= f.label :password_confirmation, t('confirm_password'), class: 'label' %> + <%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %> +
+
+ <%= f.label :current_password, t('current_password'), class: 'label' %> + <%= f.password_field :current_password, autocomplete: 'current-password', class: 'base-input' %> + + <%= t('dont_remember_your_current_password_click_here_to_reset_it_html', link: new_user_password_url) %> + +
+
+ <%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %> +
+
<% end %> + <%= button_to nil, resend_reset_password_users_path, id: 'resend_password_button', class: 'hidden', data: { turbo_confirm: t('are_you_sure_') } %>

<%= t('two_factor_authentication') %>

diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 6af9db8d..07604244 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -801,6 +801,9 @@ en: &en reveal_api_key: Reveal API Key enter_your_password_to_reveal_the_api_key: Enter your password to reveal the API key wrong_password: Wrong password. + current_password: Current password + dont_remember_your_current_password_click_here_to_reset_it_html: 'Don''t remember your current password? to reset it.' + you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes: You will receive an email with password reset instructions in a few minutes. submission_sources: api: API bulk: Bulk Send @@ -1688,6 +1691,9 @@ es: &es reveal_api_key: Revelar clave API enter_your_password_to_reveal_the_api_key: Introduce tu contraseña para revelar la clave API wrong_password: Contraseña incorrecta. + current_password: Contraseña actual + dont_remember_your_current_password_click_here_to_reset_it_html: '¿No recuerdas tu contraseña actual? para restablecerla.' + you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes: Recibirás un correo electrónico con las instrucciones para restablecer tu contraseña en unos minutos. submission_sources: api: API bulk: Envío masivo @@ -2575,6 +2581,9 @@ it: &it reveal_api_key: Mostra chiave API enter_your_password_to_reveal_the_api_key: Inserisci la tua password per mostrare la chiave API wrong_password: Password errata. + current_password: Password attuale + dont_remember_your_current_password_click_here_to_reset_it_html: 'Non ricordi la tua password attuale? per reimpostarla.' + you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes: Riceverai un'email con le istruzioni per reimpostare la password entro pochi minuti. submission_sources: api: API bulk: Invio massivo @@ -3465,6 +3474,9 @@ fr: &fr reveal_api_key: Révéler la clé API enter_your_password_to_reveal_the_api_key: Entrez votre mot de passe pour révéler la clé API wrong_password: Mot de passe incorrect. + current_password: Mot de passe actuel + dont_remember_your_current_password_click_here_to_reset_it_html: 'Vous ne vous souvenez plus de votre mot de passe actuel ? pour le réinitialiser.' + you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes: Vous recevrez un e-mail avec les instructions de réinitialisation de votre mot de passe dans quelques minutes. submission_sources: api: API bulk: Envoi en masse @@ -4353,6 +4365,9 @@ pt: &pt reveal_api_key: Revelar chave API enter_your_password_to_reveal_the_api_key: Insira sua senha para revelar a chave API wrong_password: Senha incorreta. + current_password: Senha atual + dont_remember_your_current_password_click_here_to_reset_it_html: 'Não se lembra da sua senha atual? para redefini-la.' + you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes: Você receberá um e-mail com as instruções para redefinir sua senha em alguns minutos. submission_sources: api: API bulk: Envio em massa @@ -5241,6 +5256,9 @@ de: &de reveal_api_key: API-Schlüssel anzeigen enter_your_password_to_reveal_the_api_key: Gib dein Passwort ein, um den API-Schlüssel anzuzeigen wrong_password: Falsches Passwort. + current_password: Aktuelles Passwort + dont_remember_your_current_password_click_here_to_reset_it_html: 'Sie erinnern sich nicht an Ihr aktuelles Passwort? , um es zurückzusetzen.' + you_will_receive_an_email_with_password_reset_instructions_in_a_few_minutes: Sie erhalten in wenigen Minuten eine E-Mail mit Anweisungen zum Zurücksetzen Ihres Passworts. submission_sources: api: API bulk: Massenversand diff --git a/config/routes.rb b/config/routes.rb index 072faa4e..ecf1c3ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,7 +65,9 @@ Rails.application.routes.draw do resources :setup, only: %i[index create] resource :newsletter, only: %i[show update] resources :enquiries, only: %i[create] - resources :users, only: %i[new create edit update destroy] + resources :users, only: %i[new create edit update destroy] do + post :resend_reset_password, on: :collection + end resource :user_signature, only: %i[edit update destroy] resource :user_initials, only: %i[edit update destroy] resources :submissions_archived, only: %i[index], path: 'submissions/archived' diff --git a/spec/system/profile_settings_spec.rb b/spec/system/profile_settings_spec.rb index 3c05a664..b7dc5691 100644 --- a/spec/system/profile_settings_spec.rb +++ b/spec/system/profile_settings_spec.rb @@ -16,7 +16,6 @@ RSpec.describe 'Profile Settings' do expect(page).to have_content('Change Password') expect(page).to have_field('user[password]') - expect(page).to have_field('user[password_confirmation]') end context 'when changes contact information' do @@ -47,6 +46,7 @@ RSpec.describe 'Profile Settings' do it 'updates password' do fill_in 'New password', with: 'newpassword' fill_in 'Confirm new password', with: 'newpassword' + fill_in 'Current password', with: 'password' all(:button, 'Update')[1].click @@ -56,10 +56,51 @@ RSpec.describe 'Profile Settings' do it 'does not update if password confirmation does not match' do fill_in 'New password', with: 'newpassword' fill_in 'Confirm new password', with: 'newpassword1' + fill_in 'Current password', with: 'password' all(:button, 'Update')[1].click expect(page).to have_content("Password confirmation doesn't match Password") end + + it 'does not update if current password is incorrect' do + fill_in 'New password', with: 'newpassword' + fill_in 'Confirm new password', with: 'newpassword' + fill_in 'Current password', with: 'wrongpassword' + + all(:button, 'Update')[1].click + + expect(page).to have_content('Current password is invalid') + end + + it 'resets password and signs in with new password', sidekiq: :inline do + fill_in 'New password', with: 'newpassword' + accept_confirm('Are you sure?') do + find('label', text: 'Click here').click + end + + expect(page).to have_content('You will receive an email with password reset instructions in a few minutes.') + + email = ActionMailer::Base.deliveries.last + reset_password_url = email.body + .encoded[/href="([^"]+)"/, 1] + .sub(%r{https?://(.*?)/}, "#{Capybara.current_session.server.base_url}/") + + visit reset_password_url + + fill_in 'New password', with: 'new_strong_password' + fill_in 'Confirm new password', with: 'new_strong_password' + click_button 'Change my password' + + expect(page).to have_content('Your password has been changed successfully. You are now signed in.') + + visit new_user_session_path + + fill_in 'Email', with: user.email + fill_in 'Password', with: 'new_strong_password' + click_button 'Sign In' + + expect(page).to have_content('Signed in successfully') + end end end