Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
class UsersController < ApplicationController
before_action :set_user, only: [ :show, :edit, :update, :destroy,
:toggle_lock_status, :confirm_email,
:send_welcome_instructions, :send_reset_password_instructions ]
:send_welcome_instructions, :send_reset_password_instructions,
:confirm_email_change, :process_email_change,
:confirm_email_manual, :process_email_manual ]

def index
authorize!
Expand Down Expand Up @@ -128,11 +130,18 @@ def update
@user.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user }
@user.comments.select(&:changed?).each { |c| c.updated_by = current_user }

# Suppress Devise's automatic reconfirmation email so the interstitial can control it
@user.skip_confirmation_notification!

if @user.save
bypass_sign_in(@user) if @user == current_user
notice = "User was successfully updated."
notice += " A confirmation email has been sent to #{@user.unconfirmed_email}." if @user.unconfirmed_email.present? && @user.saved_change_to_unconfirmed_email?
redirect_to @user, notice: notice

if current_user.super_user? && @user.saved_change_to_unconfirmed_email? && @user.unconfirmed_email.present?
redirect_to confirm_email_change_user_path(@user)
return
end

redirect_to @user, notice: "User was successfully updated."
else
flash[:alert] = "Unable to update user."
set_form_variables
Expand Down Expand Up @@ -233,6 +242,46 @@ def confirm_email
end
end

# ---------------------------------------------------------
# EMAIL CHANGE INTERSTITIAL
# ---------------------------------------------------------

def confirm_email_change
authorize! @user, to: :confirm_email_change?
end

def process_email_change
authorize! @user, to: :process_email_change?

result = UserServices::ProcessEmailChange.call(
user: @user,
send_confirmation: params[:send_confirmation] == "1",
current_user: current_user
)

redirect_to @user, notice: result.summary
end

# ---------------------------------------------------------
# MANUAL EMAIL CONFIRMATION INTERSTITIAL
# ---------------------------------------------------------

def confirm_email_manual
authorize! @user, to: :confirm_email_manual?
end

def process_email_manual
authorize! @user, to: :process_email_manual?

result = UserServices::ProcessEmailManualConfirm.call(
user: @user,
action: params[:confirm_action],
current_user: current_user
)

redirect_to @user, notice: result.summary
end

# ---------------------------------------------------------
# SEND INVITATION
# ---------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ def update? = admin?
def destroy? = record.persisted? && admin? && record.person_id.blank? && !has_ahoy_records?
def toggle_lock_status? = admin?
def confirm_email? = admin?
def confirm_email_change? = admin?
def process_email_change? = admin?
def confirm_email_manual? = admin?
def process_email_manual? = admin?
def send_welcome_instructions? = admin?
def search? = admin?
def change_password? = authenticated?
Expand Down
36 changes: 36 additions & 0 deletions app/services/user_services/process_email_change.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module UserServices
class ProcessEmailChange
Result = Struct.new(:actions_taken, keyword_init: true) do
def summary
return "User was successfully updated." if actions_taken.empty?
"User was successfully updated. #{actions_taken.to_sentence}."
end
end

def self.call(user:, send_confirmation:, current_user:)
new(user:, send_confirmation:, current_user:).call
end

def initialize(user:, send_confirmation:, current_user:)
@user = user
@send_confirmation = send_confirmation
@current_user = current_user
@actions_taken = []
end

def call
send_confirmation_email if @send_confirmation

Result.new(actions_taken: @actions_taken)
end

private

def send_confirmation_email
return unless @user.unconfirmed_email.present?

@user.send_confirmation_instructions
@actions_taken << "A confirmation email has been sent to #{@user.unconfirmed_email}"
end
end
end
49 changes: 49 additions & 0 deletions app/services/user_services/process_email_manual_confirm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
module UserServices
class ProcessEmailManualConfirm
Result = Struct.new(:actions_taken, keyword_init: true) do
def summary
actions_taken.to_sentence.presence || "No action taken."
end
end

def self.call(user:, action:, current_user:)
new(user:, action:, current_user:).call
end

def initialize(user:, action:, current_user:)
@user = user
@action = action
@current_user = current_user
@actions_taken = []
end

def call
case @action
when "resend"
resend_confirmation
when "confirm"
manually_confirm
end

Result.new(actions_taken: @actions_taken)
end

private

def resend_confirmation
target_email = @user.unconfirmed_email.presence || @user.email
@user.send_confirmation_instructions
@actions_taken << "Confirmation email has been resent to #{target_email}"
end

def manually_confirm
pending_email = @user.unconfirmed_email
@user.confirm
if pending_email.present?
@actions_taken << "Email change to #{pending_email} has been manually confirmed"
else
@actions_taken << "Email has been manually confirmed"
end
end
end
end
13 changes: 1 addition & 12 deletions app/views/users/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,11 @@
<% if allowed_to?(:manage?, User) %>
<%= f.input :email,
label: @user.persisted? ? email_label_with_confirmation_icon(@user) : "Email",
hint: @user.persisted? ? "Changing the email will send a confirmation email to the new address" : "Only editable by admins",
hint: @user.persisted? ? nil : "Only editable by admins",
input_html: { value: f.object.email.presence || @person&.email,
class: "w-full" },
wrapper_html: { class: "w-full" },
label_html: { id: "email_label" } %>
<% if @user.persisted? && @user.confirmed_at.nil? %>
<div class="mt-2">
<button type="button"
data-controller="confirm-email"
data-confirm-email-user-id-value="<%= @user.id %>"
data-action="click->confirm-email#confirm"
class="btn btn-warning-outline text-sm">
Manually confirm email
</button>
</div>
<% end %>
<% else %>
<label class="block text-sm font-medium text-gray-500 mb-1">
Email <%= email_confirmation_icon(@user) %>
Expand Down
51 changes: 51 additions & 0 deletions app/views/users/confirm_email_change.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
<div class="<%= DomainTheme.bg_class_for(:users) %> border border-gray-200 rounded-xl shadow p-6">
<div class="max-w-2xl mx-auto">
<div class="flex items-center mb-6">
<div class="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-yellow-100">
<svg class="h-5 w-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<h1 class="ml-3 text-xl font-semibold text-gray-900">Email change saved</h1>
</div>

<div class="bg-white rounded-lg p-4 mb-6">
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">User</dt>
<dd class="text-gray-900 font-medium"><%= @user.name %></dd>
<dt class="text-gray-500">Current email</dt>
<dd class="text-gray-900"><%= @user.email %></dd>
<dt class="text-gray-500">New email (pending)</dt>
<dd class="text-gray-900 font-medium"><%= @user.unconfirmed_email %></dd>
</dl>
</div>

<p class="text-sm text-gray-600 mb-4">
The new email has been saved but requires confirmation. The user's current email
will remain <strong><%= @user.email %></strong> until they confirm the new address.
</p>

<%= form_with url: process_email_change_user_path(@user),
method: :post,
data: { turbo: false },
class: "space-y-3" do %>

<label class="flex items-start gap-3 p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<%= check_box_tag :send_confirmation, "1", true, class: "mt-0.5" %>
<div>
<span class="text-sm font-medium text-gray-900">Send confirmation email</span>
<p class="text-xs text-gray-500">
Sends a confirmation email to <strong><%= @user.unconfirmed_email %></strong> so the user can verify their new address
</p>
</div>
</label>

<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<%= link_to "Skip", user_path(@user), class: "btn btn-secondary-outline" %>
<button type="submit" class="btn btn-primary">Continue</button>
</div>
<% end %>
</div>
</div>
93 changes: 93 additions & 0 deletions app/views/users/confirm_email_manual.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
<% pending_email_change = @user.unconfirmed_email.present? %>
<% target_email = pending_email_change ? @user.unconfirmed_email : @user.email %>
<div class="<%= DomainTheme.bg_class_for(:users) %> border border-gray-200 rounded-xl shadow p-6">
<div class="max-w-2xl mx-auto">
<div class="flex items-center mb-6">
<div class="flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full bg-blue-100">
<svg class="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1 class="ml-3 text-xl font-semibold text-gray-900">Confirm email</h1>
</div>

<div class="bg-white rounded-lg p-4 mb-6">
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">User</dt>
<dd class="text-gray-900 font-medium"><%= @user.name %></dd>
<dt class="text-gray-500">Current email</dt>
<dd class="text-gray-900"><%= @user.email %></dd>
<% if pending_email_change %>
<dt class="text-gray-500">Pending email</dt>
<dd class="text-gray-900 font-medium"><%= @user.unconfirmed_email %></dd>
<% end %>
<dt class="text-gray-500">Status</dt>
<dd>
<% if pending_email_change %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">
Email change pending
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">
Unconfirmed
</span>
<% end %>
</dd>
</dl>
</div>

<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-2">
<i class="fa-solid fa-circle-info text-blue-500 mt-0.5"></i>
<div class="text-sm text-blue-800">
<p class="font-medium">Recommendation: Resend the confirmation email</p>
<p class="mt-1">
It's best to have the user confirm their own email address so we know they
can receive messages from the Portal. Only use manual confirmation when the
user cannot complete confirmation themselves.
</p>
</div>
</div>
</div>

<div class="space-y-3">
<%= form_with url: process_email_manual_user_path(@user),
method: :post,
data: { turbo: false } do %>
<%= hidden_field_tag :confirm_action, "resend" %>
<button type="submit" class="w-full flex items-start gap-3 p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer text-left">
<i class="fa-solid fa-paper-plane text-blue-500 mt-0.5"></i>
<div>
<span class="text-sm font-medium text-gray-900">Resend confirmation email</span>
<p class="text-xs text-gray-500">Sends a new confirmation email to <%= target_email %></p>
</div>
</button>
<% end %>

<%= form_with url: process_email_manual_user_path(@user),
method: :post,
data: { turbo: false } do %>
<%= hidden_field_tag :confirm_action, "confirm" %>
<button type="submit" class="w-full flex items-start gap-3 p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer text-left">
<i class="fa-solid fa-check-circle text-yellow-500 mt-0.5"></i>
<div>
<span class="text-sm font-medium text-gray-900">Manually confirm email</span>
<p class="text-xs text-gray-500">
<% if pending_email_change %>
Immediately confirms the email change to <%= @user.unconfirmed_email %> without user action
<% else %>
Immediately marks the email as confirmed without user action
<% end %>
</p>
</div>
</button>
<% end %>

<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<%= link_to "Cancel", edit_user_path(@user), class: "btn btn-secondary-outline" %>
</div>
</div>
</div>
</div>
Loading