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() {
|
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));
|
||||||
},
|
},
|
||||||
|
|
|
@ -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));
|
||||||
},
|
},
|
||||||
|
|
|
@ -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}} />
|
<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>
|
||||||
|
|
|
@ -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"}}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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
|
# 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,37 +116,44 @@ 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
|
||||||
|
|
||||||
|
all_users.each do |user|
|
||||||
|
user.suspended_till = params[:suspend_until]
|
||||||
|
user.suspended_at = DateTime.now
|
||||||
|
|
||||||
|
message = params[:message]
|
||||||
|
|
||||||
User.transaction do
|
User.transaction do
|
||||||
@user.save!
|
user.save!
|
||||||
|
|
||||||
user_history = StaffActionLogger.new(current_user).log_user_suspend(
|
user_history = StaffActionLogger.new(current_user).log_user_suspend(
|
||||||
@user,
|
user,
|
||||||
params[:reason],
|
params[:reason],
|
||||||
message: message,
|
message: message,
|
||||||
post_id: params[:post_id]
|
post_id: params[:post_id]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@user.logged_out
|
user.logged_out
|
||||||
|
|
||||||
if message.present?
|
if message.present?
|
||||||
Jobs.enqueue(
|
Jobs.enqueue(
|
||||||
:critical_user_email,
|
:critical_user_email,
|
||||||
type: "account_suspended",
|
type: "account_suspended",
|
||||||
user_id: @user.id,
|
user_id: user.id,
|
||||||
user_history_id: user_history.id
|
user_history_id: user_history.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
DiscourseEvent.trigger(
|
DiscourseEvent.trigger(
|
||||||
:user_suspended,
|
:user_suspended,
|
||||||
user: @user,
|
user: user,
|
||||||
reason: params[:reason],
|
reason: params[:reason],
|
||||||
message: message,
|
message: message,
|
||||||
user_history: user_history,
|
user_history: user_history,
|
||||||
|
@ -142,6 +161,7 @@ class Admin::UsersController < Admin::StaffController
|
||||||
suspended_till: params[:suspend_until],
|
suspended_till: params[:suspend_until],
|
||||||
suspended_at: DateTime.now
|
suspended_at: DateTime.now
|
||||||
)
|
)
|
||||||
|
end
|
||||||
|
|
||||||
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]
|
||||||
|
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(
|
silencer = UserSilencer.new(
|
||||||
@user,
|
user,
|
||||||
current_user,
|
current_user,
|
||||||
silenced_till: params[:silenced_till],
|
silenced_till: params[:silenced_till],
|
||||||
reason: params[:reason],
|
reason: params[:reason],
|
||||||
message_body: message,
|
message_body: params[:message],
|
||||||
keep_posts: true,
|
keep_posts: true,
|
||||||
post_id: params[:post_id]
|
post_id: params[:post_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
if silencer.silence
|
if silencer.silence
|
||||||
|
user_history = silencer.user_history
|
||||||
Jobs.enqueue(
|
Jobs.enqueue(
|
||||||
:critical_user_email,
|
:critical_user_email,
|
||||||
type: "account_silenced",
|
type: "account_silenced",
|
||||||
user_id: @user.id,
|
user_id: user.id,
|
||||||
user_history_id: silencer.user_history.id
|
user_history_id: user_history.id
|
||||||
)
|
)
|
||||||
end
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue