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
61 changes: 61 additions & 0 deletions app/controllers/backend/identities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,67 @@ def flip
redirect_to backend_identity_path(@identity)
end

def ban
authorize @identity

if @identity == current_user&.identity
flash[:alert] = "You can't ban your own account."
return redirect_to backend_identity_path(@identity)
end

if params[:reason].blank?
flash[:alert] = "Reason is required to ban an account."
return redirect_to backend_identity_path(@identity)
end

if params[:confirm_email] != @identity.primary_email
flash[:alert] = "Email confirmation did not match."
return redirect_to backend_identity_path(@identity)
end

@identity.lock_account!
activity_params = { reason: params[:reason] }

if params[:deactivate_slack] == "1" && @identity.slack_id.present?
result = SCIMService.deactivate_user(slack_id: @identity.slack_id)
activity_params[:slack_deactivated] = result[:success]

if result[:success]
@identity.update!(disallow_slack: true)
flash[:notice] = "Account locked and Slack deactivated."
else
activity_params[:slack_error] = result[:error]
flash[:warning] = "Account locked, but Slack deactivation failed: #{result[:error]}"
end
else
flash[:notice] = "Account locked."
end

@identity.create_activity(
:ban,
owner: current_user,
recipient: @identity,
parameters: activity_params
)

redirect_to backend_identity_path(@identity)
end

def unban
authorize @identity

@identity.unlock_account!
@identity.update!(disallow_slack: false) if @identity.disallow_slack?
@identity.create_activity(
:unban,
owner: current_user,
recipient: @identity,
)

flash[:notice] = "Account unlocked."
redirect_to backend_identity_path(@identity)
end

def reset_persona_attempts
authorize @identity

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/backend/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,6 @@ def set_user
@user = User.find(params[:id])
end

def user_params = params.require(:backend_user).permit(:username, :icon_url, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, organized_program_ids: [])
def user_params = params.require(:backend_user).permit(:username, :icon_url, :all_fields_access, :human_endorser, :program_manager, :manual_document_verifier, :super_admin, :can_ban, organized_program_ids: [])
end
end
5 changes: 5 additions & 0 deletions app/helpers/backend/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def deletion_tool(class_name: "", element: "div", **options, &block)
concat content_tag(element, class: "deletion-tool #{class_name}", **options, &block)
end

def ban_tool(class_name: "", element: "div", **options, &block)
return unless current_user&.can_ban? || current_user&.super_admin?
concat content_tag(element, class: "ban-tool #{class_name}", **options, &block)
end

def program_manager_tool(class_name: "", element: "div", **options, &block)
return unless current_user&.program_manager? || current_user&.super_admin?
concat content_tag(element, class: "program-manager-tool #{class_name}", **options, &block)
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/sessions_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module SessionsHelper
class AccountLockedError < StandardError; end

def sign_in(identity:, fingerprint_info: {}, impersonate: false)
raise(AccountLockedError, "Your HCB account has been locked.") if identity.locked?
raise(AccountLockedError, "Your account has been locked.") if identity.locked?

# Preserve fingerprint info from session if not passed
fingerprint_info = session[:fingerprint_info] if fingerprint_info.blank? && session[:fingerprint_info].present?
Expand Down
2 changes: 2 additions & 0 deletions app/models/backend/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def human_endorser? = human_endorser
def all_fields_access? = all_fields_access
def can_break_glass? = can_break_glass
def can_process_deletions? = can_process_deletions
def can_ban? = can_ban

# Returns a human-readable string of the user's roles
def pretty_roles
Expand All @@ -50,6 +51,7 @@ def pretty_roles
roles << "Manual Document Verifier" if manual_document_verifier?
roles << "Human Endorser" if human_endorser?
roles << "All Fields Access" if all_fields_access?
roles << "Ban" if can_ban?
roles.presence&.join(", ") || "None"
end

Expand Down
1 change: 1 addition & 0 deletions app/models/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ def unlock_account! = update!(locked_at: nil)
def lock_account!
update!(locked_at: Time.current)
sessions.update_all(expires_at: Time.current)
all_access_tokens.update_all(revoked_at: Time.current)
end

def self.calculate_age(birthday)
Expand Down
5 changes: 4 additions & 1 deletion app/policies/identity_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ def update? = user.present? && (user.can_break_glass? || user.super_admin?)
alias_method :promote_to_full_user?, :update?
alias_method :clear_slack_photo?, :update?

def ban? = user.present? && (user.can_ban? || user.super_admin?)
alias_method :unban?, :ban?

def simulate_onboarding? = user&.super_admin?
def flip? = user&.super_admin?
alias_method :reset_persona_attempts?, :simulate_onboarding?

class Scope < ApplicationPolicy::Scope
def resolve
if user.super_admin? || user.manual_document_verifier? || user.all_fields_access?
if user.super_admin? || user.manual_document_verifier? || user.all_fields_access? || user.can_ban?
scope.all
elsif user.organized_programs.any?
program_ids = user.organized_programs.pluck(:id)
Expand Down
31 changes: 31 additions & 0 deletions app/services/scim_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,37 @@ def clear_profile_photo(slack_id:)
{ success: false, error: e.message }
end

def deactivate_user(slack_id:)
if Rails.env.staging?
Rails.logger.info "Skipping Slack deactivation in staging for #{slack_id}"
return { success: true }
end

response = client.patch("Users/#{slack_id}", {
schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ],
Operations: [
{ op: "replace", path: "active", value: false }
]
})

if response.success?
{ success: true }
else
error_msg = if response.body.is_a?(Hash)
response.body.dig("Errors", 0, "description") ||
response.body["detail"] ||
response.body["message"] ||
response.body["error"]
end
error_msg ||= "Unknown error (Status #{response.status})"
{ success: false, error: error_msg }
end
rescue => e
Rails.logger.error "Error deactivating Slack user #{slack_id}: #{e.message}"
Sentry.capture_exception(e, tags: { component: "slack", operation: "scim_deactivate_user" })
{ success: false, error: e.message }
end

def find_existing_user_by_email(email)
return nil if email.include?('"')

Expand Down
53 changes: 53 additions & 0 deletions app/views/backend/identities/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,59 @@
</details>
<% end %>

<%# === Ban / Unlock === %>
<% ban_tool do %>
<div is-="separator"></div>
<% if @identity.locked? %>
<column gap-="1">
<row align-="center" gap-="2">
<span style="color: var(--red);">this account is locked</span>
<%= button_to "unlock account", unban_backend_identity_path(@identity), method: :post, data: { confirm: "unlock #{@identity.first_name}'s account?" } %>
</row>
<% if @identity.disallow_slack? || @identity.slack_id.present? %>
<span style="color: var(--foreground2);">if their Slack was deactivated, you can reprovision it after unlocking.</span>
<% end %>
</column>
<% else %>
<details>
<summary style="color: var(--red); cursor: pointer;">ban tools</summary>
<column pad-="1 0" gap-="1">
<%= render Components::Backend::Banner.new(kind: :warning) do %>
this will lock the account, expire all sessions, and prevent login.
<% end %>
<%= form_with url: ban_backend_identity_path(@identity), method: :post, local: true do %>
<column gap-="1">
<label>
<b>reason</b>
<span style="color: var(--foreground2);">(required — logged in audit trail)</span>
<textarea name="reason" required placeholder="why is this account being banned?" rows="2"></textarea>
</label>

<% if @identity.slack_id.present? %>
<label>
<%= check_box_tag :deactivate_slack, "1", false %> also deactivate Slack account
</label>
<% end %>

<label>
<b>type "<%= @identity.primary_email %>" to confirm</b>
<input type="text" name="confirm_email" id="ban-confirm-email" required autocomplete="off" placeholder="<%= @identity.primary_email %>">
</label>

<button type="submit" variant-="red" id="ban-submit" disabled>lock account</button>

<script>
document.getElementById('ban-confirm-email').addEventListener('input', function(e) {
document.getElementById('ban-submit').disabled = e.target.value !== '<%= j @identity.primary_email %>';
});
</script>
</column>
<% end %>
</column>
</details>
<% end %>
<% end %>

<%# === Simulate onboarding === %>
<%= render "backend/identities/simulate_onboarding", identity: @identity %>

Expand Down
1 change: 1 addition & 0 deletions app/views/backend/shared/_permissions_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<label><%= f.check_box :all_fields_access %> all fields access <span style="color: var(--foreground2);">— view all fields on all identities</span></label>
<label><%= f.check_box :manual_document_verifier %> document verifier <span style="color: var(--foreground2);">— review and verify documents</span></label>
<label><%= f.check_box :can_break_glass %> break glass <span style="color: var(--foreground2);">— view ID docs after review</span></label>
<label><%= f.check_box :can_ban %> ban <span style="color: var(--foreground2);">— lock accounts and deactivate Slack</span></label>

<div is-="separator"></div>

Expand Down
3 changes: 3 additions & 0 deletions app/views/public_activity/identity/_ban.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity) do %>
locked <%= activity.recipient&.first_name || "user" %>'s account.<% if activity.parameters[:slack_deactivated] %> Slack account deactivated.<% end %><% if activity.parameters[:reason].present? %> — "<%= activity.parameters[:reason] %>"<% end %>
<% end %>
3 changes: 3 additions & 0 deletions app/views/public_activity/identity/_unban.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity) do %>
unlocked <%= activity.recipient&.first_name || "user" %>'s account.
<% end %>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ def self.matches?(request)
post :simulate_onboarding
post :flip
post :reset_persona_attempts
post :ban
post :unban
end
resources :addresses, only: [ :new, :create, :edit, :update, :destroy ], controller: "identity_addresses"
end
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20260827164018_add_can_ban_to_backend_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddCanBanToBackendUsers < ActiveRecord::Migration[8.0]
def change
add_column :backend_users, :can_ban, :boolean, default: false, null: false
end
end