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:
parent
ae40965896
commit
187b0bfb43
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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));
|
||||
},
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -18,6 +18,10 @@
|
|||
<PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if this.user.similar_users}}
|
||||
<AdminPenaltySimilarUsers @type="silence" @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
|
||||
{{/if}}
|
||||
|
||||
</ConditionalLoadingSpinner>
|
||||
|
||||
</DModalBody>
|
||||
|
|
|
@ -13,12 +13,15 @@
|
|||
<FutureDateInput @class="suspend-until" @label="admin.user.suspend_duration" @clearable={{false}} @input={{this.suspendUntil}} @onChangeInput={{action (mut this.suspendUntil)}} />
|
||||
</label>
|
||||
</div>
|
||||
<SuspensionDetails @reason={{this.reason}} @message={{this.message}} />
|
||||
|
||||
<SuspensionDetails @reason={{this.reason}} @message={{this.message}} />
|
||||
{{#if this.postId}}
|
||||
<PenaltyPostAction @postId={{this.postId}} @postAction={{this.postAction}} @postEdit={{this.postEdit}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if this.user.similar_users}}
|
||||
<AdminPenaltySimilarUsers @type="suspend" @user={{this.user}} @selectedUserIds={{this.otherUserIds}} />
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="cant-suspend">
|
||||
{{i18n "admin.user.cant_suspend"}}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.silence-user-modal,
|
||||
.suspend-user-modal {
|
||||
.table {
|
||||
width: 100%;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 0px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue