FEATURE: Show similar users when penalizing a user (#19334)

* FEATURE: Show similar users when penalizing a user

Moderators will be notified if other users with the same IP address
exist before penalizing a user.

* FEATURE: Allow staff to penalize multiple users

This allows staff members to suspend or silence multiple users belonging
to the same person.
This commit is contained in:
Bianca Nenciu 2022-12-08 14:42:33 +02:00 committed by GitHub
parent ae40965896
commit 187b0bfb43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 259 additions and 56 deletions

View File

@ -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);
}
},
});

View File

@ -9,7 +9,11 @@ export default Controller.extend(PenaltyController, {
onShow() { onShow() {
this.resetModal(); this.resetModal();
this.setProperties({ silenceUntil: null, silencing: false }); this.setProperties({
silenceUntil: null,
silencing: false,
otherUserIds: [],
});
}, },
finishedSetup() { finishedSetup() {
@ -36,6 +40,7 @@ export default Controller.extend(PenaltyController, {
post_id: this.postId, post_id: this.postId,
post_action: this.postAction, post_action: this.postAction,
post_edit: this.postEdit, post_edit: this.postEdit,
other_user_ids: this.otherUserIds,
}); });
}).finally(() => this.set("silencing", false)); }).finally(() => this.set("silencing", false));
}, },

View File

@ -9,7 +9,11 @@ export default Controller.extend(PenaltyController, {
onShow() { onShow() {
this.resetModal(); this.resetModal();
this.setProperties({ suspendUntil: null, suspending: false }); this.setProperties({
suspendUntil: null,
suspending: false,
otherUserIds: [],
});
}, },
finishedSetup() { finishedSetup() {
@ -28,7 +32,6 @@ export default Controller.extend(PenaltyController, {
} }
this.set("suspending", true); this.set("suspending", true);
this.penalize(() => { this.penalize(() => {
return this.user.suspend({ return this.user.suspend({
suspend_until: this.suspendUntil, suspend_until: this.suspendUntil,
@ -37,6 +40,7 @@ export default Controller.extend(PenaltyController, {
post_id: this.postId, post_id: this.postId,
post_action: this.postAction, post_action: this.postAction,
post_edit: this.postEdit, post_edit: this.postEdit,
other_user_ids: this.otherUserIds,
}); });
}).finally(() => this.set("suspending", false)); }).finally(() => this.set("suspending", false));
}, },

View File

@ -0,0 +1,35 @@
<div class="penalty-similar-users">
<p class="alert alert-danger">
{{i18n "admin.user.other_matches" (hash count=this.user.similar_users_count username=this.user.username)}}
</p>
<table class="table">
<thead>
<tr>
<th></th>
<th>{{i18n "username"}}</th>
<th>{{i18n "last_seen"}}</th>
<th>{{i18n "admin.user.topics_entered"}}</th>
<th>{{i18n "admin.user.posts_read_count"}}</th>
<th>{{i18n "admin.user.time_read"}}</th>
<th>{{i18n "created"}}</th>
</tr>
</thead>
<tbody>
{{#each this.user.similar_users as |user|}}
<tr>
<td>
<Input @type="checkbox" disabled={{not (get user this.penaltyField)}} {{on "click" (action "selectUserId" user.id)}} />
</td>
<td>{{avatar user imageSize="small"}} {{user.username}}</td>
<td>{{format-duration user.last_seen_age}}</td>
<td>{{number user.topics_entered}}</td>
<td>{{number user.posts_read_count}}</td>
<td>{{format-duration user.time_read}}</td>
<td>{{format-duration user.created_at_age}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>

View File

@ -18,6 +18,10 @@
<PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} /> <PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
{{/if}} {{/if}}
{{#if this.user.similar_users}}
<AdminPenaltySimilarUsers @type="silence" @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
{{/if}}
</ConditionalLoadingSpinner> </ConditionalLoadingSpinner>
</DModalBody> </DModalBody>

View File

@ -13,12 +13,15 @@
<FutureDateInput @class="suspend-until" @label="admin.user.suspend_duration" @clearable={{false}} @input={{this.suspendUntil}} @onChangeInput={{action (mut this.suspendUntil)}} /> <FutureDateInput @class="suspend-until" @label="admin.user.suspend_duration" @clearable={{false}} @input={{this.suspendUntil}} @onChangeInput={{action (mut this.suspendUntil)}} />
</label> </label>
</div> </div>
<SuspensionDetails @reason={{this.reason}} @message={{this.message}} />
<SuspensionDetails @reason={{this.reason}} @message={{this.message}} />
{{#if this.postId}} {{#if this.postId}}
<PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} /> <PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
{{/if}} {{/if}}
{{#if this.user.similar_users}}
<AdminPenaltySimilarUsers @type="suspend" @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
{{/if}}
{{else}} {{else}}
<div class="cant-suspend"> <div class="cant-suspend">
{{i18n "admin.user.cant_suspend"}} {{i18n "admin.user.cant_suspend"}}

View File

@ -1009,6 +1009,7 @@ a.inline-editable-field {
@import "common/admin/dashboard"; @import "common/admin/dashboard";
@import "common/admin/settings"; @import "common/admin/settings";
@import "common/admin/users"; @import "common/admin/users";
@import "common/admin/penalty";
@import "common/admin/suspend"; @import "common/admin/suspend";
@import "common/admin/badges"; @import "common/admin/badges";
@import "common/admin/emails"; @import "common/admin/emails";

View File

@ -0,0 +1,11 @@
.silence-user-modal,
.suspend-user-modal {
.table {
width: 100%;
th,
td {
padding: 8px 0px;
}
}
}

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::UsersController < Admin::StaffController class Admin::UsersController < Admin::StaffController
MAX_SIMILAR_USERS = 10
before_action :fetch_user, only: [:suspend, before_action :fetch_user, only: [:suspend,
:unsuspend, :unsuspend,
@ -40,7 +41,18 @@ class Admin::UsersController < Admin::StaffController
def show def show
@user = User.find_by(id: params[:id]) @user = User.find_by(id: params[:id])
raise Discourse::NotFound unless @user 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 end
def delete_posts_batch def delete_posts_batch
@ -104,44 +116,52 @@ class Admin::UsersController < Admin::StaffController
params.require([:suspend_until, :reason]) params.require([:suspend_until, :reason])
@user.suspended_till = params[:suspend_until] all_users = [@user]
@user.suspended_at = DateTime.now if Array === params[:other_user_ids]
all_users.concat(User.where(id: params[:other_user_ids]).to_a)
message = params[:message] all_users.uniq!
end
user_history = nil user_history = nil
User.transaction do all_users.each do |user|
@user.save! user.suspended_till = params[:suspend_until]
user.suspended_at = DateTime.now
user_history = StaffActionLogger.new(current_user).log_user_suspend( message = params[:message]
@user,
params[:reason], 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, 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 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 perform_post_action
@ -341,31 +361,42 @@ class Admin::UsersController < Admin::StaffController
return render json: failed_json.merge(message: message), status: 409 return render json: failed_json.merge(message: message), status: 409
end end
message = params[:message] all_users = [@user]
if Array === params[:other_user_ids]
silencer = UserSilencer.new( all_users.concat(User.where(id: params[:other_user_ids]).to_a)
@user, all_users.uniq!
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
)
end 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 perform_post_action
render_json_dump( render_json_dump(
silence: { silence: {
silenced: true, silenced: true,
silence_reason: silencer.user_history.try(:details), silence_reason: user_history.try(:details),
silenced_till: @user.silenced_till, silenced_till: @user.silenced_till,
silenced_at: @user.silenced_at, silenced_at: @user.silenced_at,
silenced_by: BasicUserSerializer.new(current_user, root: false).as_json silenced_by: BasicUserSerializer.new(current_user, root: false).as_json

View File

@ -36,7 +36,9 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:can_disable_second_factor, :can_disable_second_factor,
:can_delete_sso_record, :can_delete_sso_record,
:api_key_count, :api_key_count,
:external_ids :external_ids,
:similar_users,
:similar_users_count
has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects
@ -156,6 +158,28 @@ class AdminDetailedUserSerializer < AdminUserSerializer
external_ids external_ids
end 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 def can_delete_sso_record
scope.can_delete_sso_record?(object) scope.can_delete_sso_record?(object)
end end

View File

@ -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

View File

@ -5581,6 +5581,15 @@ en:
silenced_count: "Silenced" silenced_count: "Silenced"
suspended_count: "Suspended" suspended_count: "Suspended"
last_six_months: "Last 6 months" 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: tl3_requirements:
title: "Requirements for Trust Level 3" title: "Requirements for Trust Level 3"
table_title: table_title:

View File

@ -103,6 +103,18 @@ RSpec.describe Admin::UsersController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["id"]).to eq(user.id) expect(response.parsed_body["id"]).to eq(user.id)
end 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 end
context "when logged in as a non-staff user" do context "when logged in as a non-staff user" do
@ -229,6 +241,7 @@ RSpec.describe Admin::UsersController do
describe '#suspend' do describe '#suspend' do
fab!(:created_post) { Fabricate(:post) } fab!(:created_post) { Fabricate(:post) }
fab!(:other_user) { Fabricate(:user) }
let(:suspend_params) do let(:suspend_params) do
{ suspend_until: 5.hours.from_now, { suspend_until: 5.hours.from_now,
reason: "because of this post", reason: "because of this post",
@ -421,6 +434,18 @@ RSpec.describe Admin::UsersController do
}, headers: { HTTP_API_KEY: api_key.key } }, headers: { HTTP_API_KEY: api_key.key }
expect(response.status).to eq(403) expect(response.status).to eq(403)
end 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 end
context "when logged in as a moderator" do context "when logged in as a moderator" do
@ -1386,6 +1411,7 @@ RSpec.describe Admin::UsersController do
describe '#silence' do describe '#silence' do
fab!(:reg_user) { Fabricate(:user) } fab!(:reg_user) { Fabricate(:user) }
fab!(:other_user) { Fabricate(:user) }
context "when logged in as an admin" do context "when logged in as an admin" do
before { sign_in(admin) } before { sign_in(admin) }
@ -1471,6 +1497,13 @@ RSpec.describe Admin::UsersController do
) )
) )
end 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 end
context "when logged in as a moderator" do context "when logged in as a moderator" do