mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* feat: add safe admin user removal * fix: address user removal review findings * fix: close remaining user removal review gaps * fix: handle deleted users during session creation * fix: fail closed when session creation fails * fix: reject token issuance for inactive users
136 lines
4.4 KiB
Ruby
136 lines
4.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Admin
|
|
class UsersController < Admin::BaseController
|
|
before_action :set_user, only: %i[update deletion destroy]
|
|
|
|
def index
|
|
authorize User
|
|
scope = policy_scope(User)
|
|
.left_joins(family: :subscription)
|
|
.includes(family: :subscription)
|
|
|
|
scope = scope.where(role: params[:role]) if params[:role].present?
|
|
scope = apply_trial_filter(scope) if params[:trial_status].present?
|
|
|
|
users = scope.order(
|
|
Arel.sql(
|
|
"CASE " \
|
|
"WHEN subscriptions.status = 'trialing' THEN 0 " \
|
|
"WHEN subscriptions.id IS NULL THEN 1 " \
|
|
"ELSE 2 END, " \
|
|
"subscriptions.trial_ends_at ASC NULLS LAST, users.email ASC"
|
|
)
|
|
)
|
|
|
|
family_ids = users.map(&:family_id).uniq
|
|
@accounts_count_by_family = Account.where(family_id: family_ids).group(:family_id).count
|
|
@entries_count_by_family = Entry.joins(:account).where(accounts: { family_id: family_ids }).group("accounts.family_id").count
|
|
|
|
user_ids = users.map(&:id).uniq
|
|
@last_login_by_user = Session.where(user_id: user_ids).group(:user_id).maximum(:created_at)
|
|
@sessions_count_by_user = Session.where(user_id: user_ids).group(:user_id).count
|
|
|
|
@families_with_users = users.group_by(&:family).sort_by do |family, _users|
|
|
-(@entries_count_by_family[family.id] || 0)
|
|
end
|
|
|
|
@invitations_by_family = Invitation.pending
|
|
.where(family_id: family_ids)
|
|
.group_by(&:family_id)
|
|
|
|
@trials_expiring_in_7_days = Subscription
|
|
.where(status: :trialing)
|
|
.where(trial_ends_at: Time.current..7.days.from_now)
|
|
.count
|
|
@sso_identity_blocks = SsoIdentityBlock.order(created_at: :desc)
|
|
|
|
# Used by the view to hide the "remove" action for the sole remaining
|
|
# active super admin, computed once here instead of per-row.
|
|
@active_super_admin_count = User.where(role: :super_admin, active: true).count
|
|
end
|
|
|
|
def update
|
|
authorize @user
|
|
|
|
if @user.update(user_params)
|
|
Rails.logger.info(
|
|
"[Admin::Users] Role changed - " \
|
|
"by_user_id=#{Current.user.id} " \
|
|
"target_user_id=#{@user.id} " \
|
|
"new_role=#{@user.role}"
|
|
)
|
|
redirect_to admin_users_path, notice: t(".success")
|
|
else
|
|
redirect_to admin_users_path, alert: t(".failure")
|
|
end
|
|
end
|
|
|
|
def deletion
|
|
# Same self-removal short-circuit as #destroy. UserPolicy#destroy? already
|
|
# denies it, but Pundit::NotAuthorizedError is not rescued anywhere in this
|
|
# app, so opening the confirmation modal for yourself (the index view hides
|
|
# the button, but the URL is guessable) would 500 instead of redirecting.
|
|
if @user.id == Current.user.id
|
|
redirect_to admin_users_path, alert: t("admin.users.destroy.cannot_remove_self")
|
|
return
|
|
end
|
|
|
|
authorize @user, :destroy?
|
|
render layout: false
|
|
end
|
|
|
|
def destroy
|
|
# Self-removal is also denied by UserPolicy#destroy?, but checking it here
|
|
# first turns it into a friendly redirect instead of an unhandled
|
|
# Pundit::NotAuthorizedError (there is no rescue_from for it in this app).
|
|
if @user.id == Current.user.id
|
|
redirect_to admin_users_path, alert: t(".cannot_remove_self")
|
|
return
|
|
end
|
|
|
|
authorize @user
|
|
|
|
unless ActiveSupport::SecurityUtils.secure_compare(params[:confirmation_email].to_s, @user.email)
|
|
redirect_to admin_users_path, alert: t(".confirmation_mismatch")
|
|
return
|
|
end
|
|
|
|
removed = @user.transaction do
|
|
next false unless @user.permanently_remove!
|
|
|
|
SsoAuditLog.log_user_removed!(user: @user, actor: Current.user, request: request)
|
|
true
|
|
end
|
|
|
|
if removed
|
|
redirect_to admin_users_path, notice: t(".success")
|
|
else
|
|
redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence.presence || t(".failure")
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def set_user
|
|
@user = User.find(params[:id])
|
|
end
|
|
|
|
def user_params
|
|
params.require(:user).permit(:role)
|
|
end
|
|
|
|
def apply_trial_filter(scope)
|
|
case params[:trial_status]
|
|
when "expiring_soon"
|
|
scope.where(subscriptions: { status: :trialing })
|
|
.where(subscriptions: { trial_ends_at: Time.current..7.days.from_now })
|
|
when "trialing"
|
|
scope.where(subscriptions: { status: :trialing })
|
|
else
|
|
scope
|
|
end
|
|
end
|
|
end
|
|
end
|