diff --git a/app/assets/javascripts/admin/components/flagged-post.js.es6 b/app/assets/javascripts/admin/components/flagged-post.js.es6 index aaa4759e827..8aff3d2a2d1 100644 --- a/app/assets/javascripts/admin/components/flagged-post.js.es6 +++ b/app/assets/javascripts/admin/components/flagged-post.js.es6 @@ -4,8 +4,6 @@ import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ adminTools: Ember.inject.service(), expanded: false, - suspended: false, - tagName: 'div', classNameBindings: [ ':flagged-post', @@ -21,12 +19,7 @@ export default Ember.Component.extend({ }, removeAfter(promise) { - return promise.then(() => { - this.attrs.removePost(); - }).catch(error => { - if (error._discourse_displayed) { return; } - bootbox.alert(I18n.t("admin.flags.error")); - }); + return promise.then(() => this.attrs.removePost()); }, _spawnModal(name, model, modalClass) { @@ -36,7 +29,7 @@ export default Ember.Component.extend({ actions: { removeAfter(promise) { - this.removeAfter(promise); + return this.removeAfter(promise); }, disagree() { @@ -58,18 +51,6 @@ export default Ember.Component.extend({ filter: 'post', post_id: this.get('flaggedPost.id') }); - }, - - showSuspendModal() { - let post = this.get('flaggedPost'); - let user = post.get('user'); - this.get('adminTools').showSuspendModal( - user, - { - post, - successCallback: result => this.set('suspended', result.suspended) - } - ); } } }); diff --git a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 new file mode 100644 index 00000000000..d89c69a32d0 --- /dev/null +++ b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 @@ -0,0 +1,32 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +const ACTIONS = ['delete', 'edit', 'none']; +export default Ember.Component.extend({ + postAction: null, + postEdit: null, + + @computed + penaltyActions() { + return ACTIONS.map(id => { + return { id, name: I18n.t(`admin.user.penalty_post_${id}`) }; + }); + }, + + editing: Ember.computed.equal('postAction', 'edit'), + + actions: { + penaltyChanged() { + let postAction = this.get('postAction'); + + // If we switch to edit mode, jump to the edit textarea + if (postAction === 'edit') { + Ember.run.scheduleOnce('afterRender', () => { + let $elem = this.$(); + let body = $elem.closest('.modal-body'); + body.scrollTop(body.height()); + $elem.find('.post-editor').focus(); + }); + } + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 index 9f1aef916f8..2e12a2196d3 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 @@ -1,26 +1,14 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; import computed from 'ember-addons/ember-computed-decorators'; import { popupAjaxError } from 'discourse/lib/ajax-error'; +import PenaltyController from 'admin/mixins/penalty-controller'; -export default Ember.Controller.extend(ModalFunctionality, { +export default Ember.Controller.extend(PenaltyController, { silenceUntil: null, - reason: null, - message: null, silencing: false, - user: null, - post: null, - successCallback: null, onShow() { - this.setProperties({ - silenceUntil: null, - reason: null, - message: null, - silencing: false, - loadingUser: true, - post: null, - successCallback: null, - }); + this.resetModal(); + this.setProperties({ silenceUntil: null, silencing: false }); }, @computed('silenceUntil', 'reason', 'silencing') @@ -33,18 +21,16 @@ export default Ember.Controller.extend(ModalFunctionality, { if (this.get('submitDisabled')) { return; } this.set('silencing', true); - this.get('user').silence({ - silenced_till: this.get('silenceUntil'), - reason: this.get('reason'), - message: this.get('message'), - post_id: this.get('post.id') - }).then(result => { - this.send('closeModal'); - let callback = this.get('successCallback'); - if (callback) { - callback(result); - } - }).catch(popupAjaxError).finally(() => this.set('silencing', false)); + this.penalize(() => { + return this.get('user').silence({ + silenced_till: this.get('silenceUntil'), + reason: this.get('reason'), + message: this.get('message'), + post_id: this.get('post.id'), + post_action: this.get('postAction'), + post_edit: this.get('postEdit') + }); + }).finally(() => this.set('silencing', false)); } } }); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 index efcd1426700..f66a1eec694 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 @@ -1,26 +1,13 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; import computed from 'ember-addons/ember-computed-decorators'; -import { popupAjaxError } from 'discourse/lib/ajax-error'; +import PenaltyController from 'admin/mixins/penalty-controller'; -export default Ember.Controller.extend(ModalFunctionality, { +export default Ember.Controller.extend(PenaltyController, { suspendUntil: null, - reason: null, - message: null, suspending: false, - user: null, - post: null, - successCallback: null, onShow() { - this.setProperties({ - suspendUntil: null, - reason: null, - message: null, - suspending: false, - loadingUser: true, - post: null, - successCallback: null, - }); + this.resetModal(); + this.setProperties({ suspendUntil: null, suspending: false }); }, @computed('suspendUntil', 'reason', 'suspending') @@ -33,19 +20,17 @@ export default Ember.Controller.extend(ModalFunctionality, { if (this.get('submitDisabled')) { return; } this.set('suspending', true); - this.get('user').suspend({ - suspend_until: this.get('suspendUntil'), - reason: this.get('reason'), - message: this.get('message'), - post_id: this.get('post.id') - }).then(result => { - this.send('closeModal'); - let callback = this.get('successCallback'); - if (callback) { - callback(result); - } - }).catch(popupAjaxError).finally(() => this.set('suspending', false)); + + this.penalize(() => { + return this.get('user').suspend({ + suspend_until: this.get('suspendUntil'), + reason: this.get('reason'), + message: this.get('message'), + post_id: this.get('post.id'), + post_action: this.get('postAction'), + post_edit: this.get('postEdit') + }); + }).finally(() => this.set('suspending', false)); } } - }); diff --git a/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 b/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 new file mode 100644 index 00000000000..8b5bbb06297 --- /dev/null +++ b/app/assets/javascripts/admin/mixins/penalty-controller.js.es6 @@ -0,0 +1,41 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Mixin.create(ModalFunctionality, { + reason: null, + message: null, + postEdit: null, + postAction: null, + user: null, + post: null, + successCallback: null, + + resetModal() { + this.setProperties({ + reason: null, + message: null, + loadingUser: true, + post: null, + postEdit: null, + postAction: 'delete', + before: null, + successCallback: null + }); + }, + + penalize(cb) { + let before = this.get('before'); + let promise = before ? before() : Ember.RSVP.resolve(); + + return promise + .then(() => cb()) + .then(result => { + this.send('closeModal'); + let callback = this.get('successCallback'); + if (callback) { + callback(result); + } + }) + .catch(popupAjaxError); + } +}); diff --git a/app/assets/javascripts/admin/services/admin-tools.js.es6 b/app/assets/javascripts/admin/services/admin-tools.js.es6 index a6a56d0f145..9f9bec8b036 100644 --- a/app/assets/javascripts/admin/services/admin-tools.js.es6 +++ b/app/assets/javascripts/admin/services/admin-tools.js.es6 @@ -52,7 +52,10 @@ export default Ember.Service.extend({ modalClass: `${type}-user-modal` }); if (opts.post) { - controller.set('post', opts.post); + controller.setProperties({ + post: opts.post, + postEdit: opts.post.get('raw') + }); } return (user.adminUserView ? @@ -62,6 +65,7 @@ export default Ember.Service.extend({ controller.setProperties({ user: loadedUser, loadingUser: false, + before: opts.before, successCallback: opts.successCallback }); }); diff --git a/app/assets/javascripts/admin/templates/components/flagged-post.hbs b/app/assets/javascripts/admin/templates/components/flagged-post.hbs index 04b953c451f..37631f1442f 100644 --- a/app/assets/javascripts/admin/templates/components/flagged-post.hbs +++ b/app/assets/javascripts/admin/templates/components/flagged-post.hbs @@ -68,12 +68,6 @@ {{flag-user-lists flaggedPost=flaggedPost showResolvedBy=showResolvedBy}} - {{#if suspended}} -
- {{i18n "admin.flags.suspended_for_post"}} -
- {{/if}} -
{{#if canAct}} {{admin-agree-flag-dropdown @@ -106,15 +100,6 @@ {{admin-delete-flag-dropdown post=flaggedPost removeAfter=(action "removeAfter")}} - - {{#unless suspended}} - {{d-button - class="btn-danger suspend-user" - icon="ban" - label="admin.flags.suspend_user" - title="admin.flags.suspend_user_title" - action=(action "showSuspendModal")}} - {{/unless}} {{/if}} {{d-button diff --git a/app/assets/javascripts/admin/templates/components/penalty-post-action.hbs b/app/assets/javascripts/admin/templates/components/penalty-post-action.hbs new file mode 100644 index 00000000000..1c8ffe56d8e --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/penalty-post-action.hbs @@ -0,0 +1,16 @@ +
+ + {{combo-box value=postAction content=penaltyActions onSelect=(action "penaltyChanged")}} +
+ +{{#if editing}} +
+ {{textarea + value=postEdit + class="post-editor"}} +
+{{/if}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs b/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs index 6b3b899ac83..b31251a91d5 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs @@ -12,6 +12,12 @@
{{silence-details reason=reason message=message}} + {{#if post}} + {{penalty-post-action + post=post + postAction=postAction + postEdit=postEdit}} + {{/if}} {{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs b/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs index 4c0af11512e..6c2e8e1636a 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs @@ -13,6 +13,13 @@ {{suspension-details reason=reason message=message}} + {{#if post}} + {{penalty-post-action + post=post + postAction=postAction + postEdit=postEdit}} + {{/if}} + {{else}}
{{i18n "admin.user.cant_suspend"}} diff --git a/app/assets/javascripts/discourse/lib/ajax-error.js.es6 b/app/assets/javascripts/discourse/lib/ajax-error.js.es6 index cb155ec2581..9666d9c378f 100644 --- a/app/assets/javascripts/discourse/lib/ajax-error.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax-error.js.es6 @@ -54,9 +54,11 @@ export function throwAjaxError(undoCallback) { } export function popupAjaxError(error) { + if (error && error._discourse_displayed) { return; } bootbox.alert(extractError(error)); error._discourse_displayed = true; + // We re-throw in a catch to not swallow the exception throw error; } diff --git a/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6 index 55708f10eaa..3020529ef65 100644 --- a/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6 +++ b/app/assets/javascripts/select-kit/components/admin-agree-flag-dropdown.js.es6 @@ -55,6 +55,22 @@ export default DropdownSelectBox.extend({ label: I18n.t("admin.flags.agree_flag"), }); + content.push({ + icon: 'ban', + id: 'confirm-agree-suspend', + description: I18n.t('admin.flags.agree_flag_suspend_title'), + action: () => this.send("showSuspendModal"), + label: I18n.t("admin.flags.agree_flag_suspend"), + }); + + content.push({ + icon: 'microphone-slash', + id: 'confirm-agree-silence', + description: I18n.t('admin.flags.agree_flag_silence_title'), + action: () => this.send("showSilenceModal"), + label: I18n.t("admin.flags.agree_flag_silence"), + }); + if (canDeleteSpammer) { content.push({ title: I18n.t("admin.flags.delete_spammer_title"), @@ -79,6 +95,28 @@ export default DropdownSelectBox.extend({ this.attrs.removeAfter(spammerDetails.deleteUser()); }, + showSuspendModal() { + let post = this.get('post'); + let user = post.get('user'); + this.get('adminTools').showSuspendModal(user, { + post, + before: () => { + return this.attrs.removeAfter(post.agreeFlags('suspended')); + } + }); + }, + + showSilenceModal() { + let post = this.get('post'); + let user = post.get('user'); + this.get('adminTools').showSilenceModal(user, { + post, + before: () => { + return this.attrs.removeAfter(post.agreeFlags('silenced')); + } + }); + }, + perform(action) { let flaggedPost = this.get("post"); this.attrs.removeAfter(flaggedPost.agreeFlags(action)); diff --git a/app/assets/stylesheets/common/admin/suspend.scss b/app/assets/stylesheets/common/admin/suspend.scss index efdfb68f791..8e2ab667e1f 100644 --- a/app/assets/stylesheets/common/admin/suspend.scss +++ b/app/assets/stylesheets/common/admin/suspend.scss @@ -21,3 +21,13 @@ float: right; } } + +.modal-body { + .penalty-post-edit { + margin-top: 1em; + + textarea { + height: 10em; + } + } +} diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 2095903ab6e..13aa89d3ca5 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -93,6 +93,8 @@ class Admin::UsersController < Admin::AdminController suspended_at: DateTime.now ) + perform_post_action + render_json_dump( suspension: { suspended: true, @@ -297,6 +299,7 @@ class Admin::UsersController < Admin::AdminController user_history_id: silencer.user_history.id ) end + perform_post_action render_json_dump( silence: { @@ -467,6 +470,27 @@ class Admin::UsersController < Admin::AdminController private + def perform_post_action + return unless params[:post_id].present? && + params[:post_action].present? + + if post = Post.where(id: params[:post_id]).first + case params[:post_action] + when 'delete' + PostDestroyer.new(current_user, post).destroy + when 'edit' + revisor = PostRevisor.new(post) + + # Take what the moderator edited in as gospel + revisor.revise!( + current_user, + { raw: params[:post_edit] }, + skip_validations: true, skip_revision: true + ) + end + end + end + def fetch_user @user = User.find_by(id: params[:user_id]) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index cbd757d0397..7784265fcfa 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2695,7 +2695,12 @@ en: agree_flag_hide_post_title: "Hide this post and automatically send the user a message urging them to edit it." agree_flag_restore_post: "Agree and Restore Post" agree_flag_restore_post_title: "Restore the post so that all users can see it." - agree_flag: "Agree and Keep Post" + agree_flag_suspend: "Suspend User" + agree_flag_suspend_title: "Agree with flag and suspend the user." + agree_flag_silence: "Silence User" + agree_flag_silence_title: "Agree with flag and silence the user." + + agree_flag: "Keep Post" agree_flag_title: "Agree with flag and keep the post unchanged." ignore_flag: "Ignore" ignore_flag_title: "Remove this flag; it requires no action at this time." @@ -2729,7 +2734,6 @@ en: system: "System" error: "Something went wrong" reply_message: "Reply" - suspended_for_post: "The user was suspended for this post." no_results: "There are no flagged posts." topic_flagged: "This topic has been flagged." show_full: "show full post" @@ -3392,6 +3396,10 @@ en: suspended_until: "(until %{until})" cant_suspend: "This user cannot be suspended." delete_all_posts: "Delete all posts" + penalty_post_actions: "What would you like to do with the associated post?" + penalty_post_delete: "Delete the post" + penalty_post_edit: "Edit the post" + penalty_post_none: "Do nothing" # keys ending with _MF use message format, see https://meta.discourse.org/t/message-format-support-for-localization/7035 for details delete_all_posts_confirm_MF: "You are about to delete {POSTS, plural, one {1 post} other {# posts}} and {TOPICS, plural, one {1 topic} other {# topics}}. Are you sure?" diff --git a/lib/flag_query.rb b/lib/flag_query.rb index 15b1591d3f7..cda69b8f913 100644 --- a/lib/flag_query.rb +++ b/lib/flag_query.rb @@ -31,6 +31,7 @@ module FlagQuery posts = SqlBuilder.new(" SELECT p.id, p.cooked, + p.raw, p.user_id, p.topic_id, p.post_number, diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 80f9a865a00..e56fa93b3e6 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -148,24 +148,42 @@ describe Admin::UsersController do expect(log.details).to match(/because I said so/) end - it "can have an associated post" do - post = Fabricate(:post) - - put( - :suspend, - params: { - user_id: user.id, + context "with an associated post" do + let(:post) { Fabricate(:post) } + let(:suspend_params) do + { user_id: user.id, suspend_until: 5.hours.from_now, reason: "because of this post", post_id: post.id, - format: :json - } - ) - expect(response).to be_success + format: :json } + end - log = UserHistory.where(target_user_id: user.id).order('id desc').first - expect(log).to be_present - expect(log.post_id).to eq(post.id) + it "can have an associated post" do + put(:suspend, params: suspend_params) + expect(response).to be_success + + log = UserHistory.where(target_user_id: user.id).order('id desc').first + expect(log).to be_present + expect(log.post_id).to eq(post.id) + end + + it "can delete an associated post" do + put(:suspend, params: suspend_params.merge(post_action: 'delete')) + post.reload + expect(post.deleted_at).to be_present + expect(response).to be_success + end + + it "can edit an associated post" do + put(:suspend, params: suspend_params.merge( + post_action: 'edit', + post_edit: 'this is the edited content' + )) + post.reload + expect(post.deleted_at).to be_blank + expect(post.raw).to eq("this is the edited content") + expect(response).to be_success + end end it "can send a message to the user" do diff --git a/test/javascripts/acceptance/admin-flags-test.js.es6 b/test/javascripts/acceptance/admin-flags-test.js.es6 index e64e823039e..3f38df94b18 100644 --- a/test/javascripts/acceptance/admin-flags-test.js.es6 +++ b/test/javascripts/acceptance/admin-flags-test.js.es6 @@ -104,15 +104,6 @@ QUnit.test("flagged posts - delete + deleteSpammer", assert => { }); }); -QUnit.test("flagged posts - suspend", assert => { - visit("/admin/flags/active"); - click('.suspend-user'); - andThen(() => { - assert.equal(find('.suspend-user-modal:visible').length, 1); - assert.equal(find('.suspend-user-modal .cant-suspend').length, 1); - }); -}); - QUnit.test("topics with flags", assert => { visit("/admin/flags/topics"); andThen(() => {