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 @@ - - - - Account Merger - - -
-
-

Account Merger

-

- Merge a newer account's heartbeats into an older account, then delete the - newer account. -

-
- -
-
-
-

← OLDER (Keep)

-

- This account will receive the heartbeats and be kept. -

- -
- -
-

NEWER (Delete) →

-

- This account's heartbeats will be moved, sessions revoked, then - deleted. -

- -
-
- -
-
-

- Heartbeats flow from NEWER - to OLDER -

- {#if orderError} -
- {orderError} -
- {/if} - -
-
- -
-

- What happens during a merge? -

- {#snippet newer()} - NEWER - {/snippet} - {#snippet older()} - OLDER - {/snippet} -
    -
  1. - All heartbeats from the {@render newer()} account are transferred to the - {@render older()} account -
  2. -
  3. - All sessions and API tokens for the {@render newer()} account are revoked -
  4. -
  5. - All related data (email addresses, goals, API keys, imports, etc.) for - the - {@render newer()} account are deleted -
  6. -
  7. The {@render newer()} account is permanently deleted
  8. -
-
-
- - - {#snippet body()} -

- 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()} -
- - -
- {/snippet} -
diff --git a/config/routes.rb b/config/routes.rb index 43371fdcc..0f5a10868 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,15 +51,6 @@ def matches?(request) end end - constraints AdminLevelConstraint.new(:ultraadmin) do - namespace :admin, defaults: { export: true } do - resource :account_merger, only: [ :show ], controller: "account_merger" do - get :search_users - post :merge - end - end - end - # Read-only admin surfaces — viewers are allowed. constraints AdminLevelConstraint.new(:superadmin, :admin, :viewer, :ultraadmin) do namespace :admin, defaults: { export: true } do diff --git a/test/controllers/admin/account_merger_controller_test.rb b/test/controllers/admin/account_merger_controller_test.rb deleted file mode 100644 index 23d77d2d3..000000000 --- a/test/controllers/admin/account_merger_controller_test.rb +++ /dev/null @@ -1,143 +0,0 @@ -require "test_helper" - -class Admin::AccountMergerControllerTest < ActionDispatch::IntegrationTest - test "search_users returns formatted user results" do - admin = create(:user, :ultraadmin) - user = create(:user, :with_email, email: "merge-target@example.com", username: "merge_target") - sign_in_as(admin) - - get search_users_admin_account_merger_path, params: { query: "merge_target" } - - assert_response :success - body = JSON.parse(response.body) - assert_equal 1, body.length - assert_equal user.id, body.first["id"] - assert_equal user.display_name, body.first["display_name"] - assert_equal user.avatar_url, body.first["avatar_url"] - assert_equal user.username, body.first["username"] - assert_equal "merge-target@example.com", body.first["email"] - assert_equal user.created_at.strftime("%Y-%m-%d"), body.first["created_at"] - end - - test "merge does not double count revoked doorkeeper rows in success message" do - admin = create(:user, :ultraadmin) - older = create(:user, username: "older_user") - newer = create(:user, :with_email, email: "newer@example.com", username: "newer_user") - sign_in_as(admin) - - heartbeat = create(:heartbeat, user: newer, time: Time.current.to_i, source_type: :test_entry) - api_key = create(:api_key, user: newer, name: "Merge Test Key") - create(:sign_in_token, user: newer, auth_type: :email) - oauth_app = create(:oauth_application, owner: newer, - name: "Merge Test App", - redirect_uri: "https://example.com/callback", - scopes: "profile", - confidential: true - ) - create(:oauth_access_token, - application: oauth_app, - resource_owner_id: newer.id, - scopes: "profile", - expires_in: 1.hour.to_i - ) - create(:oauth_access_grant, - application: oauth_app, - resource_owner_id: newer.id, - redirect_uri: oauth_app.redirect_uri, - scopes: "profile", - expires_in: 10.minutes.to_i - ) - - post merge_admin_account_merger_path, params: { older_id: older.id, newer_id: newer.id } - - assert_redirected_to admin_account_merger_path - assert_equal 1, Heartbeat.where(user_id: older.id).count - assert_equal heartbeat.id, Heartbeat.find_by(user_id: older.id)&.id - assert_equal older.id, ApiKey.find(api_key.id).user_id - assert_nil User.find_by(id: newer.id) - assert_equal 0, Doorkeeper::AccessToken.where(resource_owner_id: newer.id).count - assert_equal 0, Doorkeeper::AccessGrant.where(resource_owner_id: newer.id).count - assert_includes flash[:notice], "3 sessions/tokens revoked" - assert_includes flash[:notice], "3 related records cleaned up" - end - - test "merge renames transferred api keys when the older account already has the same key name" do - admin = create(:user, :ultraadmin) - older = create(:user, username: "older_user") - newer = create(:user, username: "newer_user") - sign_in_as(admin) - - older.update_column(:created_at, 2.days.ago) - newer.update_column(:created_at, 1.day.ago) - - create(:api_key, user: older, name: "Wakatime API Key") - transferred_key = create(:api_key, user: newer, name: "Wakatime API Key") - - post merge_admin_account_merger_path, params: { older_id: older.id, newer_id: newer.id } - - assert_redirected_to admin_account_merger_path - assert_nil User.find_by(id: newer.id) - assert_equal older.id, transferred_key.reload.user_id - assert_equal "Wakatime API Key (transferred)", transferred_key.name - assert_equal [ "Wakatime API Key", "Wakatime API Key (transferred)" ], older.api_keys.order(:name).pluck(:name) - end - - test "merge transfers instance import sources to the older account when it does not have one" do - admin = create(:user, :ultraadmin) - older = create(:user, username: "older_user") - newer = create(:user, username: "newer_user") - sign_in_as(admin) - - older.update_column(:created_at, 2.days.ago) - newer.update_column(:created_at, 1.day.ago) - - create_instance_import_source_for(newer, endpoint_url: "https://newer.example.com") - - post merge_admin_account_merger_path, params: { older_id: older.id, newer_id: newer.id } - - assert_redirected_to admin_account_merger_path - assert_nil User.find_by(id: newer.id) - assert_equal 1, instance_import_source_count_for(older) - assert_equal 0, instance_import_source_count_for(newer) - assert_equal "https://newer.example.com", instance_import_source_endpoint_for(older) - end - - test "merge removes the newer instance import source when the older account already has one" do - admin = create(:user, :ultraadmin) - older = create(:user, username: "older_user") - newer = create(:user, username: "newer_user") - sign_in_as(admin) - - older.update_column(:created_at, 2.days.ago) - newer.update_column(:created_at, 1.day.ago) - - create_instance_import_source_for(older, endpoint_url: "https://older.example.com") - create_instance_import_source_for(newer, endpoint_url: "https://newer.example.com") - - post merge_admin_account_merger_path, params: { older_id: older.id, newer_id: newer.id } - - assert_redirected_to admin_account_merger_path - assert_nil User.find_by(id: newer.id) - assert_equal 1, instance_import_source_count_for(older) - assert_equal 0, instance_import_source_count_for(newer) - assert_equal "https://older.example.com", instance_import_source_endpoint_for(older) - end - - private - - def create_instance_import_source_for(user, endpoint_url:) - create(:instance_import_source, - user: user, - endpoint_url: endpoint_url, - encrypted_api_key: "encrypted-api-key" - ) - end - - def instance_import_source_count_for(user) - InstanceImportSource.where(user_id: user.id).count - end - - def instance_import_source_endpoint_for(user) - InstanceImportSource.find_by(user_id: user.id)&.endpoint_url - end -end diff --git a/test/system/admin/account_merger_test.rb b/test/system/admin/account_merger_test.rb deleted file mode 100644 index 19b4c02e3..000000000 --- a/test/system/admin/account_merger_test.rb +++ /dev/null @@ -1,63 +0,0 @@ -require "application_system_test_case" - -class Admin::AccountMergerTest < ApplicationSystemTestCase - test "ultraadmin can merge a newer account into an older account" do - admin = create(:user, :with_email, :ultraadmin, username: "ultraadmin") - older = create(:user, username: "older_merge_target") - newer = create(:user, username: "newer_merge_target") - - older.update_column(:created_at, 2.days.ago) - newer.update_column(:created_at, 1.day.ago) - - heartbeat = create(:heartbeat, user: newer, time: Time.current.to_i, source_type: :test_entry) - api_key = create(:api_key, user: newer, name: "Merge Test Key") - create(:email_address, user: newer, email: "newer@example.com", source: :signing_in) - create(:sign_in_token, user: newer, auth_type: :email) - oauth_app = create( - :oauth_application, - owner: newer, - name: "Merge Test App", - redirect_uri: "https://example.com/callback", - scopes: "profile", - confidential: true - ) - create(:oauth_access_token, - application: oauth_app, - resource_owner_id: newer.id, - scopes: "profile", - expires_in: 1.hour.to_i - ) - create(:oauth_access_grant, - application: oauth_app, - resource_owner_id: newer.id, - redirect_uri: oauth_app.redirect_uri, - scopes: "profile", - expires_in: 10.minutes.to_i - ) - - sign_in_as(admin) - - visit admin_account_merger_path - - fill_in "Older user", with: older.username - find("[role='option']", text: "ID: #{older.id}").click - - fill_in "Newer user", with: newer.username - find("[role='option']", text: "ID: #{newer.id}").click - - click_on "Merge & Delete" - within("[role='dialog']", text: "Confirm Account Merge") do - click_on "Merge & Delete" - end - - assert_text "Merge complete!" - assert_text "3 sessions/tokens revoked" - assert_text "3 related records cleaned up" - - assert_equal older.id, Heartbeat.find(heartbeat.id).user_id - assert_equal older.id, ApiKey.find(api_key.id).user_id - assert_nil User.find_by(id: newer.id) - assert_equal 0, Doorkeeper::AccessToken.where(resource_owner_id: newer.id).count - assert_equal 0, Doorkeeper::AccessGrant.where(resource_owner_id: newer.id).count - end -end