diff --git a/app/controllers/email_confirmations_controller.rb b/app/controllers/email_confirmations_controller.rb new file mode 100644 index 00000000..eb5c3755 --- /dev/null +++ b/app/controllers/email_confirmations_controller.rb @@ -0,0 +1,18 @@ +class EmailConfirmationsController < ApplicationController + skip_before_action :set_request_details, only: :new + skip_authentication only: :new + + def new + # Returns nil if the token is invalid OR expired + @user = User.find_by_token_for(:email_confirmation, params[:token]) + + if @user&.unconfirmed_email && @user&.update( + email: @user.unconfirmed_email, + unconfirmed_email: nil + ) + redirect_to new_session_path, notice: t(".success_login") + else + redirect_to root_path, alert: t(".invalid_token") + end + end +end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 222ae018..aae6513c 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -22,6 +22,10 @@ class Settings::HostingsController < SettingsController Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup] end + if hosting_params.key?(:require_email_confirmation) + Setting.require_email_confirmation = hosting_params[:require_email_confirmation] + end + if hosting_params.key?(:synth_api_key) Setting.synth_api_key = hosting_params[:synth_api_key] end @@ -34,7 +38,7 @@ class Settings::HostingsController < SettingsController private def hosting_params - params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key) + params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key) end def raise_if_not_self_hosted diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 55b75581..5bb4f45f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -4,10 +4,23 @@ class UsersController < ApplicationController def update @user = Current.user - @user.update!(user_params.except(:redirect_to, :delete_profile_image)) - @user.profile_image.purge if should_purge_profile_image? + if email_changed? + if @user.initiate_email_change(user_params[:email]) + if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation + handle_redirect(t(".success")) + else + redirect_to settings_profile_path, notice: t(".email_change_initiated") + end + else + error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(".email_change_failed") + redirect_to settings_profile_path, alert: error_message + end + else + @user.update!(user_params.except(:redirect_to, :delete_profile_image)) + @user.profile_image.purge if should_purge_profile_image? - handle_redirect(t(".success")) + handle_redirect(t(".success")) + end end def destroy @@ -38,9 +51,13 @@ class UsersController < ApplicationController user_params[:profile_image].blank? end + def email_changed? + user_params[:email].present? && user_params[:email] != @user.email + end + def user_params params.require(:user).permit( - :first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, + :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] ) end diff --git a/app/helpers/email_confirmations_helper.rb b/app/helpers/email_confirmations_helper.rb new file mode 100644 index 00000000..c5f58449 --- /dev/null +++ b/app/helpers/email_confirmations_helper.rb @@ -0,0 +1,2 @@ +module EmailConfirmationsHelper +end diff --git a/app/mailers/email_confirmation_mailer.rb b/app/mailers/email_confirmation_mailer.rb new file mode 100644 index 00000000..3ad99b96 --- /dev/null +++ b/app/mailers/email_confirmation_mailer.rb @@ -0,0 +1,15 @@ +class EmailConfirmationMailer < ApplicationMailer + # Subject can be set in your I18n file at config/locales/en.yml + # with the following lookup: + # + # en.email_confirmation_mailer.confirmation_email.subject + # + def confirmation_email + @user = params[:user] + @subject = t(".subject") + @cta = t(".cta") + @confirmation_url = new_email_confirmation_url(token: @user.generate_token_for(:email_confirmation)) + + mail to: @user.unconfirmed_email, subject: @subject + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index d576fbea..41355fee 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -20,4 +20,6 @@ class Setting < RailsSettings::Base field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"] field :require_invite_for_signup, type: :boolean, default: false + + field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" end diff --git a/app/models/user.rb b/app/models/user.rb index 0d50ba87..92171040 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,9 +7,10 @@ class User < ApplicationRecord has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy accepts_nested_attributes_for :family, update_only: true - validates :email, presence: true, uniqueness: true + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validate :ensure_valid_profile_image normalizes :email, with: ->(email) { email.strip.downcase } + normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase } normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } @@ -25,6 +26,30 @@ class User < ApplicationRecord password_salt&.last(10) end + generates_token_for :email_confirmation, expires_in: 1.day do + unconfirmed_email + end + + def pending_email_change? + unconfirmed_email.present? + end + + def initiate_email_change(new_email) + return false if new_email == email + return false if new_email == unconfirmed_email + + if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation + update(email: new_email) + else + if update(unconfirmed_email: new_email) + EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later + true + else + false + end + end + end + def request_impersonation_for(user_id) impersonated = User.find(user_id) impersonator_support_sessions.create!(impersonated: impersonated) diff --git a/app/views/email_confirmation_mailer/confirmation_email.html.erb b/app/views/email_confirmation_mailer/confirmation_email.html.erb new file mode 100644 index 00000000..be87ccd0 --- /dev/null +++ b/app/views/email_confirmation_mailer/confirmation_email.html.erb @@ -0,0 +1,7 @@ +
<%= t(".body") %>
+ +<%= link_to @cta, @confirmation_url, class: "button" %> + + \ No newline at end of file diff --git a/app/views/email_confirmation_mailer/confirmation_email.text.erb b/app/views/email_confirmation_mailer/confirmation_email.text.erb new file mode 100644 index 00000000..8f5fa14b --- /dev/null +++ b/app/views/email_confirmation_mailer/confirmation_email.text.erb @@ -0,0 +1,9 @@ +EmailConfirmation#confirmation_email + +<%= t(".greeting") %> + +<%= t(".body") %> + +<%= t(".cta") %>: <%= @confirmation_url %> + +<%= t(".expiry_notice", hours: 24) %> \ No newline at end of file diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index e9889d75..de3b1e22 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -13,6 +13,20 @@ <% end %> +<%= t(".email_confirmation_title") %>
+<%= t(".email_confirmation_description") %>
++ You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect. +
+ <% end %>