From a511bea4cc3f50bd1e067a46bdbf5cd19eb335a3 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 22 Apr 2020 14:07:51 +0530 Subject: [PATCH] FEATURE: admin UI to merge two users. (#9509) --- .../admin/controllers/admin-user-index.js | 22 +++++++++++ .../modals/admin-merge-users-confirmation.js | 35 ++++++++++++++++++ .../modals/admin-merge-users-prompt.js | 29 +++++++++++++++ .../javascripts/admin/models/admin-user.js | 37 +++++++++++++++++++ .../modal/admin-merge-users-confirmation.hbs | 21 +++++++++++ .../modal/admin-merge-users-prompt.hbs | 24 ++++++++++++ .../admin/templates/user-index.hbs | 6 ++- app/controllers/admin/users_controller.rb | 14 +++++++ app/services/user_merger.rb | 2 + config/locales/client.en.yml | 26 +++++++++++++ config/routes.rb | 1 + lib/guardian/user_guardian.rb | 4 ++ spec/requests/admin/users_controller_spec.rb | 18 +++++++++ 13 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/admin/controllers/modals/admin-merge-users-confirmation.js create mode 100644 app/assets/javascripts/admin/controllers/modals/admin-merge-users-prompt.js create mode 100644 app/assets/javascripts/admin/templates/modal/admin-merge-users-confirmation.hbs create mode 100644 app/assets/javascripts/admin/templates/modal/admin-merge-users-prompt.hbs diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js b/app/assets/javascripts/admin/controllers/admin-user-index.js index 7bd7abe341b..f69993c1855 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js @@ -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") diff --git a/app/assets/javascripts/admin/controllers/modals/admin-merge-users-confirmation.js b/app/assets/javascripts/admin/controllers/modals/admin-merge-users-confirmation.js new file mode 100644 index 00000000000..2195c296317 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-merge-users-confirmation.js @@ -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"); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-merge-users-prompt.js b/app/assets/javascripts/admin/controllers/modals/admin-merge-users-prompt.js new file mode 100644 index 00000000000..535870cd6fd --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-merge-users-prompt.js @@ -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"); + } + } +}); diff --git a/app/assets/javascripts/admin/models/admin-user.js b/app/assets/javascripts/admin/models/admin-user.js index 25a822f388e..454daf4b19f 100644 --- a/app/assets/javascripts/admin/models/admin-user.js +++ b/app/assets/javascripts/admin/models/admin-user.js @@ -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); diff --git a/app/assets/javascripts/admin/templates/modal/admin-merge-users-confirmation.hbs b/app/assets/javascripts/admin/templates/modal/admin-merge-users-confirmation.hbs new file mode 100644 index 00000000000..119cd3ad082 --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-merge-users-confirmation.hbs @@ -0,0 +1,21 @@ +
+ {{#d-modal-body rawTitle=(i18n "admin.user.merge.confirmation.title" username=username)}} +

{{html-safe (i18n "admin.user.merge.confirmation.description" username=username targetUsername=targetUsername text=text)}}

+ {{input type="text" value=value}} + {{/d-modal-body}} + + +
diff --git a/app/assets/javascripts/admin/templates/modal/admin-merge-users-prompt.hbs b/app/assets/javascripts/admin/templates/modal/admin-merge-users-prompt.hbs new file mode 100644 index 00000000000..69bdd69403a --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-merge-users-prompt.hbs @@ -0,0 +1,24 @@ +
+ {{#d-modal-body rawTitle=(i18n "admin.user.merge.prompt.title" username=username)}} +

{{html-safe (i18n "admin.user.merge.prompt.description" username=username)}}

+ {{user-selector single=true + placeholderKey="admin.user.merge.prompt.target_username_placeholder" + usernames=targetUsername + autocomplete="discourse"}} + {{/d-modal-body}} + + +
diff --git a/app/assets/javascripts/admin/templates/user-index.hbs b/app/assets/javascripts/admin/templates/user-index.hbs index c23c88a3d6d..76aee472715 100644 --- a/app/assets/javascripts/admin/templates/user-index.hbs +++ b/app/assets/javascripts/admin/templates/user-index.hbs @@ -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}} diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index a02ab11dace..4ed8a10a0fe 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -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! diff --git a/app/services/user_merger.rb b/app/services/user_merger.rb index 25bf412625c..b576eea8533 100644 --- a/app/services/user_merger.rb +++ b/app/services/user_merger.rb @@ -24,6 +24,8 @@ class UserMerger delete_source_user delete_source_user_references log_merge + + @target_user.reload end protected diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1d881a17791..39951e2b841 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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: | +

Please choose a new owner for @%{username}'s content.

+ +

All topics, posts, messages and other content created by @%{username} will be transferred.

+ + target_username_placeholder: "Username of new owner" + transfer_and_delete: "Transfer & Delete @%{username}" + cancel: "Cancel" + confirmation: + title: "Transfer & Delete @%{username}" + description: | +

All of @%{username}'s content will be transferred and attributed to @%{targetUsername}. After the content is transferred, @%{username}'s account will be deleted.

+ +

This can not be undone!

+ +

To continue type: %{text}

+ + 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: diff --git a/config/routes.rb b/config/routes.rb index 9be2b3f44da..c98b6b3edba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index ecdff65838d..1461c69d698 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -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 diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb index 984297dadb8..273ff481929 100644 --- a/spec/requests/admin/users_controller_spec.rb +++ b/spec/requests/admin/users_controller_spec.rb @@ -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