mirror of
https://github.com/we-promise/sure.git
synced 2026-09-06 15:14:19 +00:00
* Add admin family management features and tests
- Implement FamiliesController with destroy action to delete unused families.
- Add localization for success and error messages related to family deletion.
- Create FamiliesControllerTest to ensure proper functionality of family deletion.
- Update UserPolicyTest to include permissions for super admins to delete users.
- Enhance UsersControllerTest with tests for user family management, including moving users between families and creating new families.
* feat(users): enhance user management with family transfer validation and improved delete warnings
* Simplify user management actions column and combine family options
Move heavy user edit forms from table rows into a DS::Popover action
menu, add role badges to the user column, combine family migration and
creation inputs with a Stimulus controller, enable self-family
transfer for super admins, and add safety guards against demoting the
last super admin in the system.
* feat: add authentication type pills to admin user index to display SSO and local login status
* Add set password feature for local users in admin user management
- Add password field in action popover for users with local password login
- Enforce all registration password criteria (min 8 chars, mixed case, digit, special char)
- Block simultaneous family and password updates with clear error
- Show descriptive success notifications (role, password, both, family)
- Ignore password param for SSO-only users
- Add comprehensive tests for all password validation paths
* Resolve DS Drift Patrol findings and CI scan failures
- Wrap auth-type pills in DS::Tooltip instead of native title= attribute
- Add actions.manage_user key to locale and drop redundant default: fallbacks
- Fix RuboCop style offenses in Admin::UsersController
- Update Brakeman ignore entry fingerprint for Admin::UsersController#user_params
* fix: update badge query to target DS::Pill structure
* Fix DS::Tooltip misuse hiding SSO auth-type pill in admin users view
The SSO pill was passed as a block to DS::Tooltip, which caused it to
render inside the hidden div[role="tooltip"] instead of being visible.
The text: option ("SSO Provider: ...") was also silently ignored because
tooltip_content returns content (the block) over @text when a block is
given.
Fix: render the SSO/Local+SSO pill directly as visible content and pass
DS::Tooltip with no block so text: is used as the tooltip popup. An info
icon now appears next to the pill and shows the provider name on hover.
Fixes test: Admin::UsersControllerTest#test_index_renders_auth_type_pills_for_local_and_sso_users
* Remove redundant default: fallback from role pill i18n lookup
All admin.users.index.roles.{guest,member,admin,super_admin} keys are
defined in the locale file and used elsewhere in the same view without
a default:. The fallback was redundant for every valid role and would
silently mask a missing or renamed key instead of raising in
development.
Drop the default: user.role.humanize argument so that any future
missing key surfaces immediately as I18n::MissingTranslationData.
* Revert unrelated JS/schema/split churn; fix transfer_to_family! default role
- Revert 62 JS files (Biome formatter and unrelated controller changes)
- Revert db/schema.rb dump churn (no new migrations in this branch)
- Revert unrelated split transaction view changes (edit/new.html.erb)
- Fix User#transfer_to_family! role default: role: role evaluates to nil
when omitted; use explicit self.role to read model attribute
Keeps the PR focused on user/family management (~18-20 files).
* Fix last login and session count in admin user management
Store last_login_at and sessions_count directly on the users table
so they remain accurate after a user logs out.
- Add migration to add last_login_at (datetime) and sessions_count
(integer, default 0) columns to users, with backfill from sessions
- Add counter_cache: :sessions_count to Session#belongs_to :user so
the count auto-increments/decrements on session create/destroy
- Add after_create callback on Session to stamp user.last_login_at
- Update Admin::UsersController to read both values from users table
instead of aggregating Session rows (which disappear on logout)
* Fix user management PR pending CI items
* Keep test current session after sign in
* Address PR review comments for user transfers
* refactor: update user removal label to "Delete User" and standardize component attribute naming
* Address PR Review Feedback for User Management
* test: Fix families and users controller tests for user management PR
* Limit PR 2868 schema diff
* Fix PR 2868 user management CI failures
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
258 lines
8.6 KiB
Ruby
258 lines
8.6 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(:oidc_identities, 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 = User.where(id: user_ids).pluck(:id, :last_login_at).to_h
|
|
@sessions_count_by_user = User.where(id: user_ids).pluck(:id, :sessions_count).to_h
|
|
|
|
@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)
|
|
|
|
@families = Family.order(:name, :created_at)
|
|
@unused_families = Family.left_joins(:users).where(users: { id: nil }).order(:name, :created_at)
|
|
|
|
@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 demoting_last_super_admin?
|
|
redirect_to admin_users_path, alert: t(".last_super_admin_error")
|
|
return
|
|
end
|
|
|
|
if membership_change_requested? && password_change_requested?
|
|
redirect_to admin_users_path, alert: t(".password_and_family_conflict")
|
|
return
|
|
end
|
|
|
|
if password_change_requested?
|
|
errors = validate_password_criteria(user_params[:password])
|
|
if errors.any?
|
|
redirect_to admin_users_path, alert: errors.join(" ")
|
|
return
|
|
end
|
|
end
|
|
|
|
if membership_change_requested?
|
|
target_family = nil
|
|
|
|
ActiveRecord::Base.transaction do
|
|
target_family = target_family_for_update
|
|
|
|
if target_family.nil?
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
@user.transfer_to_family!(target_family, role: user_params[:role])
|
|
end
|
|
|
|
if target_family.nil?
|
|
redirect_to admin_users_path, alert: t(".family_required")
|
|
return
|
|
end
|
|
|
|
Rails.logger.info(
|
|
"[Admin::Users] Family changed - " \
|
|
"by_user_id=#{Current.user.id} " \
|
|
"target_user_id=#{@user.id} " \
|
|
"new_family_id=#{@user.family_id} " \
|
|
"new_role=#{@user.role}"
|
|
)
|
|
|
|
redirect_to admin_users_path, notice: t(".success_family")
|
|
elsif @user.update(user_update_attributes)
|
|
changes = []
|
|
changes << :role if @user.saved_change_to_role?
|
|
changes << :password if @user.saved_change_to_password_digest?
|
|
|
|
success_key = case changes
|
|
when [ :role, :password ] then ".success_role_and_password"
|
|
when [ :password ] then ".success_password"
|
|
else ".success_role"
|
|
end
|
|
|
|
Rails.logger.info(
|
|
"[Admin::Users] User details changed (#{changes.join(', ')}) - " \
|
|
"by_user_id=#{Current.user.id} " \
|
|
"target_user_id=#{@user.id} " \
|
|
"new_role=#{@user.role}"
|
|
)
|
|
|
|
redirect_to admin_users_path, notice: t(success_key)
|
|
else
|
|
redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence.presence || t(".failure")
|
|
end
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
redirect_to admin_users_path, alert: e.record.errors.full_messages.to_sentence
|
|
rescue ActiveRecord::RecordNotFound
|
|
redirect_to admin_users_path, alert: t(".failure")
|
|
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
|
|
|
|
helper_method :family_label_for
|
|
|
|
def set_user
|
|
@user = User.find(params[:id])
|
|
end
|
|
|
|
def user_params
|
|
params.require(:user).permit(:role, :family_id, :new_family_name, :new_family_moniker, :password)
|
|
end
|
|
|
|
def user_update_attributes
|
|
attrs = {}
|
|
attrs[:role] = user_params[:role] if user_params[:role].present?
|
|
if user_params[:password].present? && @user.has_local_password?
|
|
attrs[:password] = user_params[:password]
|
|
end
|
|
attrs
|
|
end
|
|
|
|
def password_change_requested?
|
|
user_params[:password].present? && @user.has_local_password?
|
|
end
|
|
|
|
def validate_password_criteria(password)
|
|
errors = []
|
|
errors << t(".password_too_short") if password.length < 8
|
|
errors << t(".password_missing_case") unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
|
|
errors << t(".password_missing_number") unless password.match?(/\d/)
|
|
errors << t(".password_missing_special") unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
|
|
errors
|
|
end
|
|
|
|
def membership_change_requested?
|
|
return true if user_params[:new_family_name].to_s.strip.present?
|
|
return true if user_params[:family_id] == "new"
|
|
|
|
user_params[:family_id].present? && user_params[:family_id] != @user.family_id.to_s
|
|
end
|
|
|
|
def target_family_for_update
|
|
new_family_name = user_params[:new_family_name].to_s.strip
|
|
|
|
if new_family_name.present?
|
|
Family.create!(
|
|
name: new_family_name,
|
|
moniker: user_params[:new_family_moniker].presence || "Family"
|
|
)
|
|
elsif user_params[:family_id].present? && user_params[:family_id] != "new"
|
|
Family.find(user_params[:family_id])
|
|
end
|
|
end
|
|
|
|
def family_label_for(family)
|
|
return "" if family.nil?
|
|
|
|
family.name.presence || "#{family.moniker_label} (#{family.id.to_s.first(8)})"
|
|
end
|
|
|
|
def demoting_last_super_admin?
|
|
user_params[:role].present? &&
|
|
@user.super_admin? &&
|
|
user_params[:role] != "super_admin" &&
|
|
User.where(role: :super_admin).where.not(id: @user.id).none?
|
|
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
|