diff --git a/app/assets/stylesheets/messages.css b/app/assets/stylesheets/messages.css index 88bd1519..672e324f 100644 --- a/app/assets/stylesheets/messages.css +++ b/app/assets/stylesheets/messages.css @@ -593,3 +593,9 @@ img.message__attachment { inline-size: 1.4em; } } + +/* Reset password */ +.reset-password-alert { + color: var(--color-negative); + visibility: hidden; +} diff --git a/app/controllers/sessions/password_resets_controller.rb b/app/controllers/sessions/password_resets_controller.rb new file mode 100644 index 00000000..6ed819fc --- /dev/null +++ b/app/controllers/sessions/password_resets_controller.rb @@ -0,0 +1,55 @@ +class Sessions::PasswordResetsController < ApplicationController + allow_unauthenticated_access + + before_action :require_smpt + + def index + end + def new + end + + def show + @password_reset_id = params[:id] + @user = User.find_by_password_reset_id(@password_reset_id) + + redirect_to root_url unless @user + end + + def update + @user = User.find_by_password_reset_id(password_reset_params[:password_reset_id]) + + redirect_to root_url unless @user + redirect_to root_url unless password_match? + + @user.update(password: password_reset_params[:new_password]) + + redirect_to new_session_path + end + + def create + email = params[:email_address] + password_reset_url = session_password_reset_url(find_user_by_email(email).password_reset_id) + + PasswordResetMailer.with(email: email, url: password_reset_url).password_reset_email.deliver_later + + redirect_to new_session_password_reset_path + end + + private + + def require_smpt + redirect_to root_url unless helpers.smtp_enabled? + end + + def find_user_by_email(email) + User.find_by(email_address: email) + end + + def password_match? + password_reset_params[:new_password] == password_reset_params[:confirm_new_password] + end + + def password_reset_params + params.require(:user).permit(:new_password, :confirm_new_password, :password_reset_id) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index db434972..302e02a8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -35,6 +35,17 @@ def link_back_to(destination) end end + def smtp_enabled? + Rails.application.config.respond_to?(:feature_enable_smtp) && + Rails.application.config.feature_enable_smtp.present? && + Rails.application.config.action_mailer.smtp_settings.present? && + Rails.application.config.action_mailer.smtp_settings[:address].present? && + Rails.application.config.action_mailer.smtp_settings[:port].present? && + Rails.application.config.action_mailer.smtp_settings[:domain].present? && + Rails.application.config.action_mailer.smtp_settings[:user_name].present? && + Rails.application.config.action_mailer.smtp_settings[:password].present? + end + private def admin_body_class "admin" if Current.user&.can_administer? diff --git a/app/javascript/controllers/password_reset_controller.js b/app/javascript/controllers/password_reset_controller.js new file mode 100644 index 00000000..16c64f5a --- /dev/null +++ b/app/javascript/controllers/password_reset_controller.js @@ -0,0 +1,38 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "resetPasswordInput", "resetPasswordConfirmInput", "resetPasswordError", "resetPasswordSubmit"] + + resetPasswordCheckInputs() { + if(this.#checkResetInputsValues()) { + this.#hideErrorMessage() + this.#enableSubmitButton() + } else { + this.#showErrorMessage() + this.#disableSubmitButton() + } + } + + #showErrorMessage() { + this.resetPasswordErrorTarget.style.visibility = "visible" + } + + #hideErrorMessage() { + this.resetPasswordErrorTarget.style.visibility = "hidden" + } + + #enableSubmitButton() { + this.resetPasswordSubmitTarget.disabled = false + } + + #disableSubmitButton() { + this.resetPasswordSubmitTarget.disabled = true + } + + #checkResetInputsValues() { + if (this.resetPasswordInputTarget.value.length < 0 || this.resetPasswordConfirmInputTarget.value.length < 0) return false + if (this.resetPasswordInputTarget.value !== this.resetPasswordConfirmInputTarget.value) return false + + return true + } +} diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 00000000..b43e1447 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,6 @@ +class ApplicationMailer < ActionMailer::Base + DEFAULT_BASE_FROM="noreply@default.com" + + default from: ENV.fetch("SMTP_INFO_EMAIL_FROM", DEFAULT_BASE_FROM) + layout "mailer" +end diff --git a/app/mailers/password_reset_mailer.rb b/app/mailers/password_reset_mailer.rb new file mode 100644 index 00000000..d54f0ee4 --- /dev/null +++ b/app/mailers/password_reset_mailer.rb @@ -0,0 +1,9 @@ +class PasswordResetMailer < ApplicationMailer + default from: ENV.fetch("SMTP_PASSWORD_RESET_EMAIL_FROM", DEFAULT_BASE_FROM) + + def password_reset_email + @email = params[:email] + @url = params[:url] + mail(to: @email, subject: "Campfire Reset Password") + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 20d05dd1..31ce90cc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,5 @@ class User < ApplicationRecord - include Avatar, Bannable, Bot, Mentionable, Role, Transferable + include Avatar, Bannable, Bot, Mentionable, Role, Transferable, Resettable has_many :memberships, dependent: :delete_all has_many :rooms, through: :memberships diff --git a/app/models/user/resettable.rb b/app/models/user/resettable.rb new file mode 100644 index 00000000..c9e6e9a2 --- /dev/null +++ b/app/models/user/resettable.rb @@ -0,0 +1,15 @@ +module User::Resettable + extend ActiveSupport::Concern + + RESET_PASSWORD_LINK_EXPIRY_DURATION = 5.hours + + class_methods do + def find_by_password_reset_id(id) + find_signed(id, purpose: :password_reset) + end + end + + def password_reset_id + signed_id(purpose: :password_reset, expires_in: RESET_PASSWORD_LINK_EXPIRY_DURATION) + end +end diff --git a/app/views/password_reset_mailer/password_reset_email.html.erb b/app/views/password_reset_mailer/password_reset_email.html.erb new file mode 100644 index 00000000..4e3e2eac --- /dev/null +++ b/app/views/password_reset_mailer/password_reset_email.html.erb @@ -0,0 +1,9 @@ +

Reset Your Campfire Password

+

Hello,

+
+

You have requested a password reset for your Campfire account.

+

+ To reset your Campfire password, please click on this: <%= link_to 'link', @url %>. +

+
+

Campfire

diff --git a/app/views/password_reset_mailer/password_reset_email.text.erb b/app/views/password_reset_mailer/password_reset_email.text.erb new file mode 100644 index 00000000..8159dc07 --- /dev/null +++ b/app/views/password_reset_mailer/password_reset_email.text.erb @@ -0,0 +1,9 @@ +Reset Your Campfire Password +=============================================== +Hello, + +You have requested a password reset for your Campfire account. + +To reset your Campfire password, please click on this: <%= link_to 'link', @url %>. + +Campfire \ No newline at end of file diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index e15850c2..fbe57cef 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -25,6 +25,15 @@ + <% if smtp_enabled? %> +
+ <%= link_to session_password_resets_path, class: "btn flex-item-justify-end" do %> +
Forgot your password?
+ Forgot password? + <% end %> +
+ <% end %> + <%= form.button class: "btn btn--reversed center txt-large", type: "submit", name: "log_in" do %> <%= image_tag "arrow-right.svg", aria: { hidden: "true" } %> Go diff --git a/app/views/sessions/password_resets/index.html.erb b/app/views/sessions/password_resets/index.html.erb new file mode 100644 index 00000000..13f6fc10 --- /dev/null +++ b/app/views/sessions/password_resets/index.html.erb @@ -0,0 +1,30 @@ +<% @page_title = "Reset password" %> +<% turbo_page_requires_reload %> + +
+
"> + <%= account_logo_tag style: "center margin-block-end txt-xx-large" %> + + <%= form_with url: session_password_resets_url, class: "flex flex-column gap" do |form| %> +
+ <%= Current.account.name %> + +
Enter your email to receive reset password link
+ +
+ <%= translation_button(:email_address) %> + +
+ <%= form.button class: "btn btn--reversed center txt-large", type: "submit", name: "reset_password_email" do %> + <%= image_tag "arrow-right.svg", aria: { hidden: "true" } %> + Email reset password link + <% end %> +
+ <% end %> +
+ + <%= render "accounts/help_contact" %> +
\ No newline at end of file diff --git a/app/views/sessions/password_resets/new.html.erb b/app/views/sessions/password_resets/new.html.erb new file mode 100644 index 00000000..40bcd564 --- /dev/null +++ b/app/views/sessions/password_resets/new.html.erb @@ -0,0 +1,28 @@ +<% @page_title = "Reset password" %> +<% turbo_page_requires_reload %> + +
+
"> + <%= account_logo_tag style: "center margin-block-end txt-xx-large" %> + + <%= form_with url: session_password_resets_url, class: "flex flex-column gap" do |form| %> +
+ <%= Current.account.name %> +

Reset link has been sent to your email.

+ +
+ +
Didn't receive an email?
+ +
+ <%= link_to session_password_resets_path, class: "btn flex-item-justify-end" do %> +
Return to the reset password page
+ Return to the reset password page + <% end %> +
+
+ <% end %> +
+ + <%= render "accounts/help_contact" %> +
\ No newline at end of file diff --git a/app/views/sessions/password_resets/show.html.erb b/app/views/sessions/password_resets/show.html.erb new file mode 100644 index 00000000..5084f471 --- /dev/null +++ b/app/views/sessions/password_resets/show.html.erb @@ -0,0 +1,65 @@ +<% @page_title = "Reset password" %> +<% turbo_page_requires_reload %> + +
+
"> + <%= account_logo_tag style: "center margin-block-end txt-xx-large" %> + + <%= form_with model: @user, url: session_password_reset_url, class: "flex flex-column gap", data: { controller: "password-reset" }, method: :patch do |form| %> +
+ <%= Current.account.name %> +

Please enter the new password

+ +
+ <%= translation_button(:password) %> + +
+ +
+ <%= translation_button(:password) %> + +
+ <%= form.text_field :password_reset_id, value: @password_reset_id, hidden: true,required: true %> + +

Passwords do not match!

+ +
+ <%= form.button class: "btn btn--reversed center txt-large", + type: "submit", + name: "reset_password", + disabled: true, + data: { password_reset_target: "resetPasswordSubmit" } do %> + <%= image_tag "arrow-right.svg", aria: { hidden: "true" } %> + Confirm new password + <% end %> +
+
+ <% end %> +
+ + <%= render "accounts/help_contact" %> +
\ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 7994eb00..b830cb6e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -81,4 +81,19 @@ # Visit /rails/locks to see the locks config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks + + # SMTP mailer setup + config.feature_enable_smtp = ENV.fetch("SMTP_ENABLED", false) + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV.fetch("SMTP_ADDRESS", nil), + port: ENV.fetch("SMTP_PORT", nil), + domain: ENV.fetch("SMTP_DOMAIN", nil), + user_name: ENV.fetch("SMTP_USER_NAME", nil), + password: ENV.fetch("SMTP_PASSWORD", nil), + authentication: "plain", + open_timeout: 5, + read_timeout: 5, + openssl_verify_mode: "none" + } end diff --git a/config/environments/production.rb b/config/environments/production.rb index d58644a1..ba952fc8 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -93,4 +93,19 @@ config.active_record.attributes_for_inspect = [ :id ] config.active_job.queue_adapter = :resque + + # SMTP mailer setup + config.feature_enable_smtp = ENV.fetch("SMTP_ENABLED", false) + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV.fetch("SMTP_ADDRESS", nil), + port: ENV.fetch("SMTP_PORT", nil), + domain: ENV.fetch("SMTP_DOMAIN", nil), + user_name: ENV.fetch("SMTP_USER_NAME", nil), + password: ENV.fetch("SMTP_PASSWORD", nil), + authentication: "plain", + enable_starttls: true, + open_timeout: 5, + read_timeout: 5 + } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 70deabfd..4260b0de 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -57,4 +57,8 @@ # Load test helpers config.autoload_paths += %w[ test/test_helpers ] + + # Action Mailer test setup + config.action_mailer.delivery_method = :test + config.action_mailer.perform_deliveries = true end diff --git a/config/routes.rb b/config/routes.rb index e55fd7e2..67a44e58 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,8 @@ resource :session do scope module: "sessions" do resources :transfers, only: %i[ show update ] + + resources :password_resets, only: %i[ new show create update index] end end diff --git a/test/controllers/sessions/password_resets_controller_test.rb b/test/controllers/sessions/password_resets_controller_test.rb new file mode 100644 index 00000000..4f607055 --- /dev/null +++ b/test/controllers/sessions/password_resets_controller_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class Sessions::PasswordResetsControllerTest < ActionDispatch::IntegrationTest + include ApplicationHelper + + test "password reset" do + user = users(:kevin) + password_reset_id = user.password_reset_id + new_password = "new_password" + confirm_new_password = "new_password" + old_password = user.password_digest + + patch session_password_reset_path(user), params: { user: { + new_password: new_password, + confirm_new_password: confirm_new_password, + password_reset_id: password_reset_id + } } + + user.reload + new_password = user.password_digest + + assert_equal old_password, new_password + end + + test "send password reset mail" do + user = users(:kevin) + + if smtp_enabled? + assert_emails 1 do + post session_password_resets_path, params: { email_address: user.email_address } + end + else + assert_emails 0 do + post session_password_resets_path, params: { email_address: user.email_address } + end + end + end +end diff --git a/test/mailers/fixture_templates/password_reset_html_fixture.txt b/test/mailers/fixture_templates/password_reset_html_fixture.txt new file mode 100644 index 00000000..a8fca6f5 --- /dev/null +++ b/test/mailers/fixture_templates/password_reset_html_fixture.txt @@ -0,0 +1,22 @@ + + + + + + + + +

Reset Your Campfire Password

+

Hello,

+
+

You have requested a password reset for your Campfire account.

+

+ To reset your Campfire password, please click on this: link. +

+
+

Campfire

+ + + diff --git a/test/mailers/fixture_templates/password_reset_text_fixture.txt b/test/mailers/fixture_templates/password_reset_text_fixture.txt new file mode 100644 index 00000000..c0f5ffa5 --- /dev/null +++ b/test/mailers/fixture_templates/password_reset_text_fixture.txt @@ -0,0 +1,9 @@ +Reset Your Campfire Password +=============================================== +Hello, + +You have requested a password reset for your Campfire account. + +To reset your Campfire password, please click on this: link. + +Campfire diff --git a/test/mailers/password_reset_mailer_test.rb b/test/mailers/password_reset_mailer_test.rb new file mode 100644 index 00000000..45ffe63b --- /dev/null +++ b/test/mailers/password_reset_mailer_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class PasswordResetMailerTest < ActionMailer::TestCase + test "password reset" do + email = PasswordResetMailer.with(email: "me@campfire.com", url: "http://www.example.com").password_reset_email + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ ENV.fetch("SMTP_PASSWORD_RESET_EMAIL_FROM", ApplicationMailer::DEFAULT_BASE_FROM) ], email.from + assert_equal [ "me@campfire.com" ], email.to + assert_equal "Campfire Reset Password", email.subject + + # fixtures for mails have to be outside default fixtures directory, since there are no tables in DB for the mails + assert_equal file_fixture("../../mailers/fixture_templates/password_reset_text_fixture.txt").read, email.text_part.body.to_s + assert_equal file_fixture("../../mailers/fixture_templates/password_reset_html_fixture.txt").read, email.html_part.body.to_s + end +end diff --git a/test/mailers/previews/password_reset_mailer_preview.rb b/test/mailers/previews/password_reset_mailer_preview.rb new file mode 100644 index 00000000..e4eeea03 --- /dev/null +++ b/test/mailers/previews/password_reset_mailer_preview.rb @@ -0,0 +1,6 @@ +# Preview all emails at http://localhost:3000/rails/mailers/password_mailer +class PasswordResetMailerPreview < ActionMailer::Preview + def reset_password_email + PasswordResetMailer.with(user: "test@test.com", url: "http://localhost:3000").password_reset_email + end +end