FEATURE: admin UI to merge two users. (#9509)

This commit is contained in:
Vinoth Kannan 2020-04-22 14:07:51 +05:30 committed by GitHub
parent 13956017da
commit a511bea4cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 238 additions and 1 deletions

View File

@ -9,6 +9,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseComputed from "discourse-common/utils/decorators";
import { fmt } from "discourse/lib/computed";
import { htmlSafe } from "@ember/template";
import showModal from "discourse/lib/show-modal";
export default Controller.extend(CanCheckEmails, {
adminTools: service(),
@ -207,6 +208,27 @@ export default Controller.extend(CanCheckEmails, {
}
},
promptTargetUser() {
showModal("admin-merge-users-prompt", {
admin: true,
model: this.model
});
},
showMergeConfirmation(targetUsername) {
showModal("admin-merge-users-confirmation", {
admin: true,
model: {
username: this.model.username,
targetUsername: targetUsername
}
});
},
merge(targetUsername) {
return this.model.merge({ targetUsername: targetUsername });
},
viewActionLogs() {
this.adminTools.showActionLogs(this, {
target_user: this.get("model.username")

View File

@ -0,0 +1,35 @@
import Controller, { inject as controller } from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import discourseComputed from "discourse-common/utils/decorators";
import { alias } from "@ember/object/computed";
export default Controller.extend(ModalFunctionality, {
adminUserIndex: controller(),
username: alias("model.username"),
targetUsername: alias("model.targetUsername"),
onShow() {
this.set("value", null);
},
@discourseComputed("username", "targetUsername")
text(username, targetUsername) {
return `transfer @${username} to @${targetUsername}`;
},
@discourseComputed("value", "text")
mergeDisabled(value, text) {
return !value || text !== value;
},
actions: {
merge() {
this.adminUserIndex.send("merge", this.targetUsername);
this.send("closeModal");
},
cancel() {
this.send("closeModal");
}
}
});

View File

@ -0,0 +1,29 @@
import Controller, { inject as controller } from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import discourseComputed from "discourse-common/utils/decorators";
import { alias } from "@ember/object/computed";
export default Controller.extend(ModalFunctionality, {
adminUserIndex: controller(),
username: alias("model.username"),
onShow() {
this.set("targetUsername", null);
},
@discourseComputed("username", "targetUsername")
mergeDisabled(username, targetUsername) {
return !targetUsername || username === targetUsername;
},
actions: {
merge() {
this.send("closeModal");
this.adminUserIndex.send("showMergeConfirmation", this.targetUsername);
},
cancel() {
this.send("closeModal");
}
}
});

View File

@ -503,6 +503,43 @@ const AdminUser = User.extend({
bootbox.dialog(message, buttons, { classes: "delete-user-modal" });
},
merge(opts) {
const user = this;
const location = document.location.pathname;
bootbox.dialog(I18n.t("admin.user.merging_user"));
let formData = { context: location };
if (opts && opts.targetUsername) {
formData["target_username"] = opts.targetUsername;
}
return ajax(`/admin/users/${user.get("id")}/merge.json`, {
type: "POST",
data: formData
})
.then(function(data) {
if (data.merged) {
if (/^\/admin\/users\/list\//.test(location)) {
document.location = location;
} else {
document.location = Discourse.getURL(
`/admin/users/${data.user.id}/${data.user.username}`
);
}
} else {
bootbox.alert(I18n.t("admin.user.merge_failed"));
if (data.user) {
user.setProperties(data.user);
}
}
})
.catch(function() {
AdminUser.find(user.get("id")).then(u => user.setProperties(u));
bootbox.alert(I18n.t("admin.user.merge_failed"));
});
},
loadDetails() {
if (this.loadedDetails) {
return Promise.resolve(this);

View File

@ -0,0 +1,21 @@
<div>
{{#d-modal-body rawTitle=(i18n "admin.user.merge.confirmation.title" username=username)}}
<p>{{html-safe (i18n "admin.user.merge.confirmation.description" username=username targetUsername=targetUsername text=text)}}</p>
{{input type="text" value=value}}
{{/d-modal-body}}
<div class="modal-footer">
{{#d-button
class="btn-danger"
action=(action "merge")
icon="trash-alt"
disabled=mergeDisabled
}}
{{i18n "admin.user.merge.confirmation.transfer_and_delete" username=username}}
{{/d-button}}
{{d-button
action=(action "cancel")
label="admin.user.merge.confirmation.cancel"
}}
</div>
</div>

View File

@ -0,0 +1,24 @@
<div>
{{#d-modal-body rawTitle=(i18n "admin.user.merge.prompt.title" username=username)}}
<p>{{html-safe (i18n "admin.user.merge.prompt.description" username=username)}}</p>
{{user-selector single=true
placeholderKey="admin.user.merge.prompt.target_username_placeholder"
usernames=targetUsername
autocomplete="discourse"}}
{{/d-modal-body}}
<div class="modal-footer">
{{#d-button
class="btn-primary"
action=(action "merge")
icon="trash-alt"
disabled=mergeDisabled
}}
{{i18n "admin.user.merge.prompt.transfer_and_delete" username=username}}
{{/d-button}}
{{d-button
action=(action "cancel")
label="admin.user.merge.prompt.cancel"
}}
</div>
</div>

View File

@ -680,9 +680,13 @@
{{#if model.canBeDeleted}}
{{d-button label="admin.user.delete"
icon="exclamation-triangle"
icon="trash-alt"
class="btn-danger"
action=(action "destroy")}}
{{d-button label="admin.user.merge.transfer_and_delete"
icon="trash-alt"
class="btn-danger"
action=(action "promptTargetUser")}}
{{/if}}
</div>

View File

@ -20,6 +20,7 @@ class Admin::UsersController < Admin::AdminController
:remove_group,
:primary_group,
:anonymize,
:merge,
:reset_bounce_score,
:disable_second_factor,
:delete_posts_batch]
@ -471,6 +472,19 @@ class Admin::UsersController < Admin::AdminController
end
end
def merge
target_username = params.require(:target_username)
target_user = User.find_by_username(target_username)
guardian.ensure_can_merge_user!(@user, target_user)
if user = UserMerger.new(@user, target_user, current_user).merge!
render json: success_json.merge(merged: true, user: user)
else
render json: failed_json.merge(user: AdminDetailedUserSerializer.new(@user, root: false).as_json)
end
end
def reset_bounce_score
guardian.ensure_can_reset_bounce_score!(@user)
@user.user_stat&.reset_bounce_score!

View File

@ -24,6 +24,8 @@ class UserMerger
delete_source_user
delete_source_user_references
log_merge
@target_user.reload
end
protected

View File

@ -4334,6 +4334,32 @@ en:
anonymize_yes: "Yes, anonymize this account"
anonymize_failed: "There was a problem anonymizing the account."
delete: "Delete User"
merge:
transfer_and_delete: "Transfer & Delete"
prompt:
title: "Transfer & Delete @%{username}"
description: |
<p>Please choose a new owner for <b>@%{username}'s</b> content.</p>
<p>All topics, posts, messages and other content created by <b>@%{username}</b> will be transferred.</p>
target_username_placeholder: "Username of new owner"
transfer_and_delete: "Transfer & Delete @%{username}"
cancel: "Cancel"
confirmation:
title: "Transfer & Delete @%{username}"
description: |
<p>All of <b>@%{username}'s</b> content will be transferred and attributed to <b>@%{targetUsername}</b>. After the content is transferred, <b>@%{username}'s</b> account will be deleted.</p>
<p><b>This can not be undone!</b></p>
<p>To continue type: <code>%{text}</code></p>
transfer_and_delete: "Transfer & Delete @%{username}"
cancel: "Cancel"
merging_user: "Merging user..."
merge_failed: "There was an error while merging the users."
delete_forbidden_because_staff: "Admins and moderators can't be deleted."
delete_posts_forbidden_because_staff: "Can't delete all posts of admins and moderators."
delete_forbidden:

View File

@ -137,6 +137,7 @@ Discourse::Application.routes.draw do
get "leader_requirements" => "users#tl3_requirements"
get "tl3_requirements"
put "anonymize"
post "merge"
post "reset_bounce_score"
put "disable_second_factor"
end

View File

@ -76,6 +76,10 @@ module UserGuardian
is_staff? && !user.nil? && !user.staff?
end
def can_merge_user?(source_user, target_user)
is_admin? && !source_user.nil? && !source_user.staff? && !target_user.nil?
end
def can_reset_bounce_score?(user)
user && is_staff?
end

View File

@ -988,4 +988,22 @@ RSpec.describe Admin::UsersController do
end
end
describe "#merge" do
fab!(:target_user) { Fabricate(:user) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:first_post) { Fabricate(:post, topic: topic, user: user) }
it 'should merge source user to target user' do
post "/admin/users/#{user.id}/merge.json", params: {
target_username: target_user.username
}
expect(response.status).to eq(200)
expect(response.parsed_body["merged"]).to be_truthy
expect(response.parsed_body["user"]["id"]).to eq(target_user.id)
expect(topic.reload.user_id).to eq(target_user.id)
expect(first_post.reload.user_id).to eq(target_user.id)
end
end
end