diff --git a/app/controllers/admin/account_merger_controller.rb b/app/controllers/admin/account_merger_controller.rb
deleted file mode 100644
index 7825e46f6..000000000
--- a/app/controllers/admin/account_merger_controller.rb
+++ /dev/null
@@ -1,162 +0,0 @@
-class Admin::AccountMergerController < InertiaController
- layout "inertia"
-
- before_action :require_ultraadmin!
-
- def show = render(inertia: "Admin/AccountMerger")
-
- def search_users
- query_term = params[:query].to_s.strip
- return render json: [] if query_term.blank?
-
- users = User.fuzzy_ranked_search(query_term, limit: 20).includes(:email_addresses)
- render json: users.map { |user| format_user(user) }
- end
-
- def merge
- older_id = params[:older_id].to_i
- newer_id = params[:newer_id].to_i
-
- return merge_error("Cannot merge a user into themselves.") if older_id == newer_id
-
- older_user = User.find_by(id: older_id)
- newer_user = User.find_by(id: newer_id)
-
- return merge_error("One or both users not found.") unless older_user && newer_user
- return merge_error("You cannot merge your own account.") if older_user == current_user || newer_user == current_user
-
- privileged = [ older_user, newer_user ].select { |u| u.admin_level != "default" }
- if privileged.any?
- names = privileged.map { |u| "#{u.display_name} (#{u.admin_level})" }.to_sentence
- return merge_error("Refusing to merge accounts with elevated admin_level: #{names}. Demote them to `default` first.")
- end
-
- if newer_user.created_at < older_user.created_at
- return merge_error("The NEWER user (right side) must have been created after the OLDER user (left side). #{newer_user.display_name} was created #{newer_user.created_at.to_date} which is before #{older_user.display_name} created #{older_user.created_at.to_date}.")
- end
-
- merge_results = perform_merge(older_user, newer_user)
- redirect_to admin_account_merger_path, notice: "Merge complete! #{merge_results}"
- rescue => e
- Rails.logger.error("Account merge failed and was rolled back: #{e.message}")
- redirect_to admin_account_merger_path, alert: "Merge failed and was rolled back: #{e.message}"
- end
-
- private
-
- def merge_error(message) = redirect_to(admin_account_merger_path, alert: message)
-
- def format_user(user)
- {
- id: user.id,
- display_name: user.display_name,
- avatar_url: user.avatar_url,
- created_at: user.created_at&.strftime("%Y-%m-%d"),
- username: user.username,
- email: user.email_addresses.first&.email
- }
- end
-
- def perform_merge(older_user, newer_user)
- results = []
-
- ActiveRecord::Base.transaction do
- # 1. Move heartbeats from newer to older
- results << "#{Heartbeat.where(user_id: newer_user.id).update_all(user_id: older_user.id)} heartbeats moved"
-
- # 2. Transfer API keys from newer to older
- results << "#{transfer_api_keys(older_user:, newer_user:)} API keys transferred"
-
- # 3. Transfer goals from newer to older
- results << "#{newer_user.goals.update_all(user_id: older_user.id)} goals transferred"
-
- # 4. Reconcile instance import sources before deleting the newer user.
- deleted_records = reconcile_instance_import_source(older_user:, newer_user:)
-
- # 5. Revoke newer user's sessions
- revoked_tokens = newer_user.sign_in_tokens.destroy_all.count
- revoked_tokens += Doorkeeper::AccessToken.where(resource_owner_id: newer_user.id).update_all(revoked_at: Time.current)
- revoked_tokens += Doorkeeper::AccessGrant.where(resource_owner_id: newer_user.id).update_all(revoked_at: Time.current)
- results << "#{revoked_tokens} sessions/tokens revoked"
-
- # 6. Delete all related data for the newer user
- deleted_records += newer_user.email_addresses.destroy_all.count
- deleted_records += newer_user.email_verification_requests.destroy_all.count
- deleted_records += newer_user.goals.destroy_all.count
- deleted_records += newer_user.admin_api_keys.destroy_all.count
- deleted_records += ProjectRepoMapping.where(user_id: newer_user.id).delete_all
- deleted_records += newer_user.heartbeat_import_runs.destroy_all.count
- deleted_records += delete_rows("heartbeat_import_sources", user_id: newer_user.id)
- deleted_records += delete_rows("wakatime_mirrors", user_id: newer_user.id)
- deleted_records += Commit.where(user_id: newer_user.id).delete_all
- deleted_records += RepoHostEvent.where(user_id: newer_user.id).delete_all
- deleted_records += TrustLevelAuditLog.where(user_id: newer_user.id).delete_all
- deleted_records += TrustLevelAuditLog.where(changed_by_id: newer_user.id).delete_all
- deleted_records += DeletionRequest.where(user_id: newer_user.id).delete_all
- deleted_records += LeaderboardEntry.where(user_id: newer_user.id).delete_all
- deleted_records += Doorkeeper::Application.where(owner_id: newer_user.id, owner_type: "User").destroy_all.count
- Doorkeeper::AccessToken.where(resource_owner_id: newer_user.id).delete_all
- Doorkeeper::AccessGrant.where(resource_owner_id: newer_user.id).delete_all
- deleted_records += delete_rows("project_labels", user_id: newer_user.id.to_s)
- deleted_records += PaperTrail::Version.where(item_type: "User", item_id: newer_user.id).delete_all
- results << "#{deleted_records} related records cleaned up"
-
- # 7. Finally, delete the newer user
- newer_user.reload
- newer_user.destroy!
- results << "user ##{newer_user.id} deleted"
- end
-
- results.join(", ")
- end
-
- DELETABLE_TABLES = %w[heartbeat_import_sources wakatime_mirrors project_labels].freeze
-
- def transfer_api_keys(older_user:, newer_user:)
- transferred_count = 0
- reserved_names = older_user.api_keys.pluck(:name).index_with(true)
-
- ApiKey.where(user_id: newer_user.id).find_each do |api_key|
- api_key.update!(user: older_user, name: unique_api_key_name_for(reserved_names, api_key.name))
- transferred_count += 1
- end
-
- transferred_count
- end
-
- def unique_api_key_name_for(reserved_names, original_name)
- unless reserved_names[original_name]
- reserved_names[original_name] = true
- return original_name
- end
-
- suffix = " (transferred)"
- candidate_name = "#{original_name}#{suffix}"
- counter = 2
- while reserved_names[candidate_name]
- candidate_name = "#{original_name}#{suffix} #{counter}"
- counter += 1
- end
-
- reserved_names[candidate_name] = true
- candidate_name
- end
-
- def reconcile_instance_import_source(older_user:, newer_user:)
- newer_source = InstanceImportSource.find_by(user_id: newer_user.id)
- return 0 unless newer_source
- if InstanceImportSource.exists?(user_id: older_user.id)
- newer_source.destroy!; 1
- else
- newer_source.update!(user_id: older_user.id); 0
- end
- end
-
- def delete_rows(table_name, conditions)
- raise ArgumentError, "Table '#{table_name}' is not in the allowlist" unless DELETABLE_TABLES.include?(table_name)
-
- quoted = ActiveRecord::Base.connection.quote_table_name(table_name)
- sql = ActiveRecord::Base.sanitize_sql_array([ "DELETE FROM #{quoted} WHERE user_id = ?", conditions.fetch(:user_id) ])
- ActiveRecord::Base.connection.delete(sql)
- end
-end
diff --git a/app/controllers/concerns/auth_helpers.rb b/app/controllers/concerns/auth_helpers.rb
index ff1838c6d..fe4d61e2f 100644
--- a/app/controllers/concerns/auth_helpers.rb
+++ b/app/controllers/concerns/auth_helpers.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Shared signin/admin authorization helpers used by HTML controllers
-# (sessions, account_merger, settings/*, admin/*, my/*).
+# (sessions, settings/*, admin/*, my/*).
#
# These helpers redirect on failure and return falsy so callers can do
# `return unless require_signed_in!(...)` or chain off of them.
diff --git a/app/controllers/inertia_controller.rb b/app/controllers/inertia_controller.rb
index af46c3bca..9d566f983 100644
--- a/app/controllers/inertia_controller.rb
+++ b/app/controllers/inertia_controller.rb
@@ -119,8 +119,7 @@ def inertia_ultraadmin_links
return [] unless current_user&.admin_level == "ultraadmin"
[
inertia_link("GoodBoy", good_job_path, active: helpers.current_page?(good_job_path), inertia: false),
- inertia_link("Feature Flags", flipper_path, active: helpers.current_page?(flipper_path), inertia: false),
- inertia_link("Account Merger", admin_account_merger_path, active: helpers.current_page?(admin_account_merger_path) || request.path.start_with?("/admin/account_merger"))
+ inertia_link("Feature Flags", flipper_path, active: helpers.current_page?(flipper_path), inertia: false)
]
end
diff --git a/app/javascript/pages/Admin/AccountMerger.svelte b/app/javascript/pages/Admin/AccountMerger.svelte
deleted file mode 100644
index 8314ac3a6..000000000
--- a/app/javascript/pages/Admin/AccountMerger.svelte
+++ /dev/null
@@ -1,192 +0,0 @@
-
-
-
- Merge a newer account's heartbeats into an older account, then delete the - newer account. -
-- This account will receive the heartbeats and be kept. -
-- This account's heartbeats will be moved, sessions revoked, then - deleted. -
-- Heartbeats flow from NEWER - to OLDER -
- {#if orderError} -- Are you ABSOLUTELY sure? This will move all heartbeats - from - {newerUser?.display_name} (#{newerUser?.id}) - to - {olderUser?.display_name} (#{olderUser?.id}), revoke all sessions, and PERMANENTLY DELETE the newer account. -
- {/snippet} - {#snippet actions()} -