diff --git a/app/assets/javascripts/admin/addon/controllers/admin-user-index.js b/app/assets/javascripts/admin/addon/controllers/admin-user-index.js
index b3133a9f053..d1fac2a80d3 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-user-index.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-user-index.js
@@ -78,8 +78,8 @@ export default class AdminUserIndexController extends Controller.extend(
@discourseComputed("model.associated_accounts")
associatedAccounts(associatedAccounts) {
return associatedAccounts
- .map((provider) => `${provider.name} (${provider.description})`)
- .join(", ");
+ ?.map((provider) => `${provider.name} (${provider.description})`)
+ ?.join(", ");
}
@discourseComputed("model.user_fields.[]")
@@ -319,6 +319,16 @@ export default class AdminUserIndexController extends Controller.extend(
return this.model.silence();
}
+ @action
+ deleteAssociatedAccounts() {
+ this.dialog.yesNoConfirm({
+ message: I18n.t("admin.user.delete_associated_accounts_confirm"),
+ didConfirm: () => {
+ this.model.deleteAssociatedAccounts().catch(popupAjaxError);
+ },
+ });
+ }
+
@action
anonymize() {
const user = this.model;
diff --git a/app/assets/javascripts/admin/addon/models/admin-user.js b/app/assets/javascripts/admin/addon/models/admin-user.js
index 79489398db6..0449fd5056c 100644
--- a/app/assets/javascripts/admin/addon/models/admin-user.js
+++ b/app/assets/javascripts/admin/addon/models/admin-user.js
@@ -287,6 +287,17 @@ export default class AdminUser extends User {
});
}
+ deleteAssociatedAccounts() {
+ return ajax(`/admin/users/${this.id}/delete_associated_accounts`, {
+ type: "PUT",
+ data: {
+ context: window.location.pathname,
+ },
+ }).then(() => {
+ this.set("associated_accounts", []);
+ });
+ }
+
destroy(formData) {
return ajax(`/admin/users/${this.id}.json`, {
type: "DELETE",
diff --git a/app/assets/javascripts/admin/addon/templates/user-index.hbs b/app/assets/javascripts/admin/addon/templates/user-index.hbs
index a2c69d3864f..6244c0f7022 100644
--- a/app/assets/javascripts/admin/addon/templates/user-index.hbs
+++ b/app/assets/javascripts/admin/addon/templates/user-index.hbs
@@ -157,6 +157,17 @@
/>
{{/if}}
+ {{#if (and this.currentUser.admin this.associatedAccounts)}}
+
+
+
+ {{/if}}
{{/if}}
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index f4878dd9138..94594b8f085 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -26,6 +26,7 @@ class Admin::UsersController < Admin::StaffController
disable_second_factor
delete_posts_batch
sso_record
+ delete_associated_accounts
]
def index
@@ -514,6 +515,29 @@ class Admin::UsersController < Admin::StaffController
render json: success_json
end
+ def delete_associated_accounts
+ guardian.ensure_can_delete_user_associated_accounts!(@user)
+ previous_value =
+ @user
+ .user_associated_accounts
+ .select(:provider_name, :provider_uid, :info)
+ .map do |associated_account|
+ {
+ provider: associated_account.provider_name,
+ uid: associated_account.provider_uid,
+ info: associated_account.info,
+ }.to_s
+ end
+ .join(",")
+ StaffActionLogger.new(current_user).log_delete_associated_accounts(
+ @user,
+ previous_value:,
+ context: params[:context],
+ )
+ @user.user_associated_accounts.delete_all
+ render json: success_json
+ end
+
private
def fetch_user
diff --git a/app/models/user_history.rb b/app/models/user_history.rb
index 14b83cc3454..92562313b21 100644
--- a/app/models/user_history.rb
+++ b/app/models/user_history.rb
@@ -155,6 +155,7 @@ class UserHistory < ActiveRecord::Base
tag_group_create: 116,
tag_group_destroy: 117,
tag_group_change: 118,
+ delete_associated_accounts: 119,
)
end
@@ -272,6 +273,7 @@ class UserHistory < ActiveRecord::Base
tag_group_create
tag_group_destroy
tag_group_change
+ delete_associated_accounts
]
end
diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb
index ee16aa51c9d..29e2f241cb4 100644
--- a/app/services/staff_action_logger.rb
+++ b/app/services/staff_action_logger.rb
@@ -1079,6 +1079,17 @@ class StaffActionLogger
)
end
+ def log_delete_associated_accounts(user, previous_value:, context:)
+ UserHistory.create!(
+ params.merge(
+ action: UserHistory.actions[:delete_associated_accounts],
+ target_user_id: user.id,
+ previous_value:,
+ context:,
+ ),
+ )
+ end
+
private
def json_params(previous_value, new_value)
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 6a6879e175b..9ef3816efb9 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -6346,6 +6346,7 @@ en:
tag_group_create: "create tag group"
tag_group_destroy: "delete tag group"
tag_group_change: "change tag group"
+ delete_associated_accounts: "delete associated accounts"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."
@@ -6575,6 +6576,9 @@ en:
check_sso:
title: "Reveal SSO payload"
text: "Show"
+ delete_associated_accounts:
+ title: "Delete all associated accounts for this user"
+ text: "Delete associated accounts"
user:
suspend_failed: "Something went wrong suspending this user %{error}"
@@ -6692,6 +6696,7 @@ en:
post_edits_count: "Post Edits"
anonymize: "Anonymize User"
anonymize_confirm: "Are you SURE you want to anonymize this account? This will change the username and email, and reset all profile information."
+ delete_associated_accounts_confirm: "Are you SURE you want to delete associated accounts from this account? They may not be able to log in."
anonymize_yes: "Yes, anonymize this account"
anonymize_failed: "There was a problem anonymizing the account."
delete: "Delete User"
diff --git a/config/routes.rb b/config/routes.rb
index 88ccaa708ba..89a10b2eff9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -162,6 +162,7 @@ Discourse::Application.routes.draw do
put "disable_second_factor"
delete "sso_record"
get "similar-users.json" => "users#similar_users"
+ put "delete_associated_accounts"
end
get "users/:id.json" => "users#show", :defaults => { format: "json" }
get "users/:id/:username" => "users#show",
diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb
index 28da0c247b6..79f2c7b3876 100644
--- a/lib/guardian/user_guardian.rb
+++ b/lib/guardian/user_guardian.rb
@@ -204,6 +204,10 @@ module UserGuardian
SiteSetting.enable_discourse_connect && user && is_admin?
end
+ def can_delete_user_associated_accounts?(user)
+ user && is_admin?
+ end
+
def can_change_tracking_preferences?(user)
(SiteSetting.allow_changing_staged_user_tracking || !user.staged) && can_edit_user?(user)
end
diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb
index 8c7879308c0..6c643fa7171 100644
--- a/spec/requests/admin/users_controller_spec.rb
+++ b/spec/requests/admin/users_controller_spec.rb
@@ -2440,6 +2440,58 @@ RSpec.describe Admin::UsersController do
end
end
+ describe "#delete_associated_accounts" do
+ fab!(:user_associated_accounts) do
+ UserAssociatedAccount.create!(
+ provider_name: "github",
+ provider_uid: "123456789",
+ user_id: user.id,
+ last_used: 1.seconds.ago,
+ )
+ end
+
+ context "when logged in as an admin" do
+ before { sign_in(admin) }
+
+ it "deletes the record and logs the deletion" do
+ put "/admin/users/#{user.id}/delete_associated_accounts.json"
+
+ expect(response.status).to eq(200)
+ expect(user.user_associated_accounts).to eq([])
+ expect(UserHistory.last).to have_attributes(
+ acting_user_id: admin.id,
+ target_user_id: user.id,
+ action: UserHistory.actions[:delete_associated_accounts],
+ )
+ expect(UserHistory.last.previous_value).to include(':uid=>"123456789"')
+ end
+ end
+
+ context "when logged in as a moderator" do
+ before { sign_in(moderator) }
+
+ it "prevents deletion of associated accounts with a 403 response" do
+ put "/admin/users/#{user.id}/delete_associated_accounts.json"
+
+ expect(response.status).to eq(403)
+ expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access"))
+ expect(user.user_associated_accounts).to be_present
+ end
+ end
+
+ context "when logged in as a non-staff user" do
+ before { sign_in(user) }
+
+ it "prevents deletion of associated accounts with a 404 response" do
+ put "/admin/users/#{user.id}/delete_associated_accounts.json"
+
+ expect(response.status).to eq(404)
+ expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
+ expect(user.user_associated_accounts).to be_present
+ end
+ end
+ end
+
describe "#anonymize" do
shared_examples "user anonymization possible" do
it "will make the user anonymous" do