FEAT: Allow admin delete user's associated accounts (#29018)

This commit introduces a feature that allows an admin to delete a user's
associated account. After deletion, a log will be recorded in staff
actions.

ref=t/136675
This commit is contained in:
Linca 2024-09-27 20:08:05 +08:00 committed by GitHub
parent e2f3474bc3
commit a1e5796ba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 133 additions and 2 deletions

View File

@ -78,8 +78,8 @@ export default class AdminUserIndexController extends Controller.extend(
@discourseComputed("model.associated_accounts") @discourseComputed("model.associated_accounts")
associatedAccounts(associatedAccounts) { associatedAccounts(associatedAccounts) {
return associatedAccounts return associatedAccounts
.map((provider) => `${provider.name} (${provider.description})`) ?.map((provider) => `${provider.name} (${provider.description})`)
.join(", "); ?.join(", ");
} }
@discourseComputed("model.user_fields.[]") @discourseComputed("model.user_fields.[]")
@ -319,6 +319,16 @@ export default class AdminUserIndexController extends Controller.extend(
return this.model.silence(); 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 @action
anonymize() { anonymize() {
const user = this.model; const user = this.model;

View File

@ -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) { destroy(formData) {
return ajax(`/admin/users/${this.id}.json`, { return ajax(`/admin/users/${this.id}.json`, {
type: "DELETE", type: "DELETE",

View File

@ -157,6 +157,17 @@
/> />
{{/if}} {{/if}}
</div> </div>
{{#if (and this.currentUser.admin this.associatedAccounts)}}
<div class="controls">
<DButton
@action={{this.deleteAssociatedAccounts}}
@icon="trash-can"
@label="admin.users.delete_associated_accounts.text"
@title="admin.users.delete_associated_accounts.title"
class="btn-danger"
/>
</div>
{{/if}}
</div> </div>
{{/if}} {{/if}}

View File

@ -26,6 +26,7 @@ class Admin::UsersController < Admin::StaffController
disable_second_factor disable_second_factor
delete_posts_batch delete_posts_batch
sso_record sso_record
delete_associated_accounts
] ]
def index def index
@ -514,6 +515,29 @@ class Admin::UsersController < Admin::StaffController
render json: success_json render json: success_json
end 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 private
def fetch_user def fetch_user

View File

@ -155,6 +155,7 @@ class UserHistory < ActiveRecord::Base
tag_group_create: 116, tag_group_create: 116,
tag_group_destroy: 117, tag_group_destroy: 117,
tag_group_change: 118, tag_group_change: 118,
delete_associated_accounts: 119,
) )
end end
@ -272,6 +273,7 @@ class UserHistory < ActiveRecord::Base
tag_group_create tag_group_create
tag_group_destroy tag_group_destroy
tag_group_change tag_group_change
delete_associated_accounts
] ]
end end

View File

@ -1079,6 +1079,17 @@ class StaffActionLogger
) )
end 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 private
def json_params(previous_value, new_value) def json_params(previous_value, new_value)

View File

@ -6346,6 +6346,7 @@ en:
tag_group_create: "create tag group" tag_group_create: "create tag group"
tag_group_destroy: "delete tag group" tag_group_destroy: "delete tag group"
tag_group_change: "change tag group" tag_group_change: "change tag group"
delete_associated_accounts: "delete associated accounts"
screened_emails: screened_emails:
title: "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." 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: check_sso:
title: "Reveal SSO payload" title: "Reveal SSO payload"
text: "Show" text: "Show"
delete_associated_accounts:
title: "Delete all associated accounts for this user"
text: "Delete associated accounts"
user: user:
suspend_failed: "Something went wrong suspending this user %{error}" suspend_failed: "Something went wrong suspending this user %{error}"
@ -6692,6 +6696,7 @@ en:
post_edits_count: "Post Edits" post_edits_count: "Post Edits"
anonymize: "Anonymize User" 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." 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_yes: "Yes, anonymize this account"
anonymize_failed: "There was a problem anonymizing the account." anonymize_failed: "There was a problem anonymizing the account."
delete: "Delete User" delete: "Delete User"

View File

@ -162,6 +162,7 @@ Discourse::Application.routes.draw do
put "disable_second_factor" put "disable_second_factor"
delete "sso_record" delete "sso_record"
get "similar-users.json" => "users#similar_users" get "similar-users.json" => "users#similar_users"
put "delete_associated_accounts"
end end
get "users/:id.json" => "users#show", :defaults => { format: "json" } get "users/:id.json" => "users#show", :defaults => { format: "json" }
get "users/:id/:username" => "users#show", get "users/:id/:username" => "users#show",

View File

@ -204,6 +204,10 @@ module UserGuardian
SiteSetting.enable_discourse_connect && user && is_admin? SiteSetting.enable_discourse_connect && user && is_admin?
end end
def can_delete_user_associated_accounts?(user)
user && is_admin?
end
def can_change_tracking_preferences?(user) def can_change_tracking_preferences?(user)
(SiteSetting.allow_changing_staged_user_tracking || !user.staged) && can_edit_user?(user) (SiteSetting.allow_changing_staged_user_tracking || !user.staged) && can_edit_user?(user)
end end

View File

@ -2440,6 +2440,58 @@ RSpec.describe Admin::UsersController do
end end
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 describe "#anonymize" do
shared_examples "user anonymization possible" do shared_examples "user anonymization possible" do
it "will make the user anonymous" do it "will make the user anonymous" do