diff --git a/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js b/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js
new file mode 100644
index 00000000000..0e0158e8684
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/components/admin-penalty-similar-users.js
@@ -0,0 +1,29 @@
+import Component from "@ember/component";
+import { action } from "@ember/object";
+import discourseComputed from "discourse-common/utils/decorators";
+
+export default Component.extend({
+ tagName: "",
+
+ @discourseComputed("type")
+ penaltyField(penaltyType) {
+ if (penaltyType === "suspend") {
+ return "can_be_suspended";
+ } else if (penaltyType === "silence") {
+ return "can_be_silenced";
+ }
+ },
+
+ @action
+ selectUserId(userId, event) {
+ if (!this.selectedUserIds) {
+ return;
+ }
+
+ if (event.target.checked) {
+ this.selectedUserIds.pushObject(userId);
+ } else {
+ this.selectedUserIds.removeObject(userId);
+ }
+ },
+});
diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js
index 506598625d4..077a0863306 100644
--- a/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js
+++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-silence-user.js
@@ -9,7 +9,11 @@ export default Controller.extend(PenaltyController, {
onShow() {
this.resetModal();
- this.setProperties({ silenceUntil: null, silencing: false });
+ this.setProperties({
+ silenceUntil: null,
+ silencing: false,
+ otherUserIds: [],
+ });
},
finishedSetup() {
@@ -36,6 +40,7 @@ export default Controller.extend(PenaltyController, {
post_id: this.postId,
post_action: this.postAction,
post_edit: this.postEdit,
+ other_user_ids: this.otherUserIds,
});
}).finally(() => this.set("silencing", false));
},
diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js
index c82a5628084..c9f34d89577 100644
--- a/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js
+++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-suspend-user.js
@@ -9,7 +9,11 @@ export default Controller.extend(PenaltyController, {
onShow() {
this.resetModal();
- this.setProperties({ suspendUntil: null, suspending: false });
+ this.setProperties({
+ suspendUntil: null,
+ suspending: false,
+ otherUserIds: [],
+ });
},
finishedSetup() {
@@ -28,7 +32,6 @@ export default Controller.extend(PenaltyController, {
}
this.set("suspending", true);
-
this.penalize(() => {
return this.user.suspend({
suspend_until: this.suspendUntil,
@@ -37,6 +40,7 @@ export default Controller.extend(PenaltyController, {
post_id: this.postId,
post_action: this.postAction,
post_edit: this.postEdit,
+ other_user_ids: this.otherUserIds,
});
}).finally(() => this.set("suspending", false));
},
diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-penalty-similar-users.hbs b/app/assets/javascripts/admin/addon/templates/components/admin-penalty-similar-users.hbs
new file mode 100644
index 00000000000..ccb35da4d87
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/templates/components/admin-penalty-similar-users.hbs
@@ -0,0 +1,35 @@
+
+
+ {{i18n "admin.user.other_matches" (hash count=this.user.similar_users_count username=this.user.username)}}
+
+
+
+
diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-silence-user.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-silence-user.hbs
index 08f3e0a909b..374cd5a7a85 100644
--- a/app/assets/javascripts/admin/addon/templates/modal/admin-silence-user.hbs
+++ b/app/assets/javascripts/admin/addon/templates/modal/admin-silence-user.hbs
@@ -18,6 +18,10 @@
{{/if}}
+ {{#if this.user.similar_users}}
+
+ {{/if}}
+
diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-suspend-user.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-suspend-user.hbs
index 71daba820cd..2aa787a65c6 100644
--- a/app/assets/javascripts/admin/addon/templates/modal/admin-suspend-user.hbs
+++ b/app/assets/javascripts/admin/addon/templates/modal/admin-suspend-user.hbs
@@ -13,12 +13,15 @@
-
+
{{#if this.postId}}
{{/if}}
+ {{#if this.user.similar_users}}
+
+ {{/if}}
{{else}}
{{i18n "admin.user.cant_suspend"}}
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 07177c845eb..4687bda1890 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -1009,6 +1009,7 @@ a.inline-editable-field {
@import "common/admin/dashboard";
@import "common/admin/settings";
@import "common/admin/users";
+@import "common/admin/penalty";
@import "common/admin/suspend";
@import "common/admin/badges";
@import "common/admin/emails";
diff --git a/app/assets/stylesheets/common/admin/penalty.scss b/app/assets/stylesheets/common/admin/penalty.scss
new file mode 100644
index 00000000000..c54f63d8cf1
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/penalty.scss
@@ -0,0 +1,11 @@
+.silence-user-modal,
+.suspend-user-modal {
+ .table {
+ width: 100%;
+
+ th,
+ td {
+ padding: 8px 0px;
+ }
+ }
+}
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 6d2cf1d3fd0..7d7526417de 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Admin::UsersController < Admin::StaffController
+ MAX_SIMILAR_USERS = 10
before_action :fetch_user, only: [:suspend,
:unsuspend,
@@ -40,7 +41,18 @@ class Admin::UsersController < Admin::StaffController
def show
@user = User.find_by(id: params[:id])
raise Discourse::NotFound unless @user
- render_serialized(@user, AdminDetailedUserSerializer, root: false)
+
+ similar_users = User.real
+ .where.not(id: @user.id)
+ .where(ip_address: @user.ip_address)
+
+ render_serialized(
+ @user,
+ AdminDetailedUserSerializer,
+ root: false,
+ similar_users: similar_users.limit(MAX_SIMILAR_USERS),
+ similar_users_count: similar_users.count,
+ )
end
def delete_posts_batch
@@ -104,44 +116,52 @@ class Admin::UsersController < Admin::StaffController
params.require([:suspend_until, :reason])
- @user.suspended_till = params[:suspend_until]
- @user.suspended_at = DateTime.now
-
- message = params[:message]
+ all_users = [@user]
+ if Array === params[:other_user_ids]
+ all_users.concat(User.where(id: params[:other_user_ids]).to_a)
+ all_users.uniq!
+ end
user_history = nil
- User.transaction do
- @user.save!
+ all_users.each do |user|
+ user.suspended_till = params[:suspend_until]
+ user.suspended_at = DateTime.now
- user_history = StaffActionLogger.new(current_user).log_user_suspend(
- @user,
- params[:reason],
+ message = params[:message]
+
+ User.transaction do
+ user.save!
+
+ user_history = StaffActionLogger.new(current_user).log_user_suspend(
+ user,
+ params[:reason],
+ message: message,
+ post_id: params[:post_id]
+ )
+ end
+ user.logged_out
+
+ if message.present?
+ Jobs.enqueue(
+ :critical_user_email,
+ type: "account_suspended",
+ user_id: user.id,
+ user_history_id: user_history.id
+ )
+ end
+
+ DiscourseEvent.trigger(
+ :user_suspended,
+ user: user,
+ reason: params[:reason],
message: message,
- post_id: params[:post_id]
+ user_history: user_history,
+ post_id: params[:post_id],
+ suspended_till: params[:suspend_until],
+ suspended_at: DateTime.now
)
end
- @user.logged_out
-
- if message.present?
- Jobs.enqueue(
- :critical_user_email,
- type: "account_suspended",
- user_id: @user.id,
- user_history_id: user_history.id
- )
- end
-
- DiscourseEvent.trigger(
- :user_suspended,
- user: @user,
- reason: params[:reason],
- message: message,
- user_history: user_history,
- post_id: params[:post_id],
- suspended_till: params[:suspend_until],
- suspended_at: DateTime.now
- )
perform_post_action
@@ -341,31 +361,42 @@ class Admin::UsersController < Admin::StaffController
return render json: failed_json.merge(message: message), status: 409
end
- message = params[:message]
-
- silencer = UserSilencer.new(
- @user,
- current_user,
- silenced_till: params[:silenced_till],
- reason: params[:reason],
- message_body: message,
- keep_posts: true,
- post_id: params[:post_id]
- )
- if silencer.silence
- Jobs.enqueue(
- :critical_user_email,
- type: "account_silenced",
- user_id: @user.id,
- user_history_id: silencer.user_history.id
- )
+ all_users = [@user]
+ if Array === params[:other_user_ids]
+ all_users.concat(User.where(id: params[:other_user_ids]).to_a)
+ all_users.uniq!
end
+
+ user_history = nil
+
+ all_users.each do |user|
+ silencer = UserSilencer.new(
+ user,
+ current_user,
+ silenced_till: params[:silenced_till],
+ reason: params[:reason],
+ message_body: params[:message],
+ keep_posts: true,
+ post_id: params[:post_id]
+ )
+
+ if silencer.silence
+ user_history = silencer.user_history
+ Jobs.enqueue(
+ :critical_user_email,
+ type: "account_silenced",
+ user_id: user.id,
+ user_history_id: user_history.id
+ )
+ end
+ end
+
perform_post_action
render_json_dump(
silence: {
silenced: true,
- silence_reason: silencer.user_history.try(:details),
+ silence_reason: user_history.try(:details),
silenced_till: @user.silenced_till,
silenced_at: @user.silenced_at,
silenced_by: BasicUserSerializer.new(current_user, root: false).as_json
diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb
index aab939247c5..ec0d682d570 100644
--- a/app/serializers/admin_detailed_user_serializer.rb
+++ b/app/serializers/admin_detailed_user_serializer.rb
@@ -36,7 +36,9 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:can_disable_second_factor,
:can_delete_sso_record,
:api_key_count,
- :external_ids
+ :external_ids,
+ :similar_users,
+ :similar_users_count
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects
@@ -156,6 +158,28 @@ class AdminDetailedUserSerializer < AdminUserSerializer
external_ids
end
+ def similar_users
+ ActiveModel::ArraySerializer.new(
+ @options[:similar_users],
+ each_serializer: AdminUserListSerializer,
+ each_serializer: SimilarAdminUserSerializer,
+ scope: scope,
+ root: false,
+ ).as_json
+ end
+
+ def include_similar_users?
+ @options[:similar_users].present?
+ end
+
+ def similar_users_count
+ @options[:similar_users_count]
+ end
+
+ def include_similar_users_count?
+ @options[:similar_users].present?
+ end
+
def can_delete_sso_record
scope.can_delete_sso_record?(object)
end
diff --git a/app/serializers/similar_admin_user_serializer.rb b/app/serializers/similar_admin_user_serializer.rb
new file mode 100644
index 00000000000..3f343ed8416
--- /dev/null
+++ b/app/serializers/similar_admin_user_serializer.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class SimilarAdminUserSerializer < AdminUserListSerializer
+ attributes :can_be_suspended,
+ :can_be_silenced
+
+ def can_be_suspended
+ scope.can_suspend?(object)
+ end
+
+ def can_be_silenced
+ scope.can_silence_user?(object)
+ end
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 5902478635f..61f0eb27035 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -5581,6 +5581,15 @@ en:
silenced_count: "Silenced"
suspended_count: "Suspended"
last_six_months: "Last 6 months"
+ other_matches:
+ one: "There is %{count} other user with the same IP address. Review and select the suspicious ones to suspend along with %{username}."
+ other: "There are %{count} other users with the same IP address. Review and select the suspicious ones to suspend along with %{username}."
+ other_matches_list:
+ username: "Username"
+ trust_level: "Trust Level"
+ read_time: "Read Time"
+ topics_entered: "Topics Entered"
+ posts: "Posts"
tl3_requirements:
title: "Requirements for Trust Level 3"
table_title:
diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb
index 7acd62b9809..9417d449cb6 100644
--- a/spec/requests/admin/users_controller_spec.rb
+++ b/spec/requests/admin/users_controller_spec.rb
@@ -103,6 +103,18 @@ RSpec.describe Admin::UsersController do
expect(response.status).to eq(200)
expect(response.parsed_body["id"]).to eq(user.id)
end
+
+ it 'returns similar users' do
+ Fabricate(:user, ip_address: '88.88.88.88')
+ similar_user = Fabricate(:user, ip_address: user.ip_address)
+
+ get "/admin/users/#{user.id}.json"
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["id"]).to eq(user.id)
+ expect(response.parsed_body["similar_users_count"]).to eq(1)
+ expect(response.parsed_body["similar_users"].map { |u| u["id"] }).to contain_exactly(similar_user.id)
+ end
end
context "when logged in as a non-staff user" do
@@ -229,6 +241,7 @@ RSpec.describe Admin::UsersController do
describe '#suspend' do
fab!(:created_post) { Fabricate(:post) }
+ fab!(:other_user) { Fabricate(:user) }
let(:suspend_params) do
{ suspend_until: 5.hours.from_now,
reason: "because of this post",
@@ -421,6 +434,18 @@ RSpec.describe Admin::UsersController do
}, headers: { HTTP_API_KEY: api_key.key }
expect(response.status).to eq(403)
end
+
+ it "can silence multiple users" do
+ put "/admin/users/#{user.id}/suspend.json", params: {
+ suspend_until: 10.days.from_now,
+ reason: "short reason",
+ message: "long reason",
+ other_user_ids: [other_user.id],
+ }
+ expect(response.status).to eq(200)
+ expect(user.reload).to be_suspended
+ expect(other_user.reload).to be_suspended
+ end
end
context "when logged in as a moderator" do
@@ -1386,6 +1411,7 @@ RSpec.describe Admin::UsersController do
describe '#silence' do
fab!(:reg_user) { Fabricate(:user) }
+ fab!(:other_user) { Fabricate(:user) }
context "when logged in as an admin" do
before { sign_in(admin) }
@@ -1471,6 +1497,13 @@ RSpec.describe Admin::UsersController do
)
)
end
+
+ it "can silence multiple users" do
+ put "/admin/users/#{reg_user.id}/silence.json", params: { other_user_ids: [other_user.id] }
+ expect(response.status).to eq(200)
+ expect(reg_user.reload).to be_silenced
+ expect(other_user.reload).to be_silenced
+ end
end
context "when logged in as a moderator" do