mirror of
https://github.com/discourse/discourse.git
synced 2025-04-29 07:34:49 +08:00
progress tracking and disable checkboxes for undeletable users
This commit is contained in:
parent
372b2c6d7c
commit
728fcab49d
@ -0,0 +1,199 @@
|
|||||||
|
import Component from "@glimmer/component";
|
||||||
|
import { tracked } from "@glimmer/tracking";
|
||||||
|
import { on } from "@ember/modifier";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||||
|
import DButton from "discourse/components/d-button";
|
||||||
|
import DModal from "discourse/components/d-modal";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { extractError } from "discourse/lib/ajax-error";
|
||||||
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
import { i18n } from "discourse-i18n";
|
||||||
|
|
||||||
|
const BULK_DELETE_CHANNEL = "/bulk-user-delete";
|
||||||
|
|
||||||
|
export default class BulkUserDeleteConfirmation extends Component {
|
||||||
|
@service messageBus;
|
||||||
|
|
||||||
|
@tracked confirmButtonDisabled = true;
|
||||||
|
@tracked deleteStarted = false;
|
||||||
|
@tracked logs = new TrackedArray();
|
||||||
|
failedUsernames = [];
|
||||||
|
|
||||||
|
callAfterBulkDelete = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.messageBus.subscribe(BULK_DELETE_CHANNEL, this.onDeleteProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
this.messageBus.unsubscribe(BULK_DELETE_CHANNEL, this.onDeleteProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
get confirmDeletePhrase() {
|
||||||
|
return i18n(
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.confirmation_phrase",
|
||||||
|
{ count: this.args.model.userIds.length }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#logError(line) {
|
||||||
|
this.#log(line, "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#logSuccess(line) {
|
||||||
|
this.#log(line, "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
#logNeutral(line) {
|
||||||
|
this.#log(line, "neutral");
|
||||||
|
}
|
||||||
|
|
||||||
|
#log(line, type) {
|
||||||
|
this.logs.push({
|
||||||
|
line,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
onDeleteProgress(data) {
|
||||||
|
if (data.success) {
|
||||||
|
this.#logSuccess(
|
||||||
|
i18n(
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.user_delete_succeeded",
|
||||||
|
{
|
||||||
|
position: data.position,
|
||||||
|
total: data.total,
|
||||||
|
username: data.username,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (data.failed) {
|
||||||
|
this.failedUsernames.push(data.username);
|
||||||
|
this.#logError(
|
||||||
|
i18n(
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.user_delete_failed",
|
||||||
|
{
|
||||||
|
position: data.position,
|
||||||
|
total: data.total,
|
||||||
|
username: data.username,
|
||||||
|
error: data.error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.position === data.total) {
|
||||||
|
this.callAfterBulkDelete = true;
|
||||||
|
this.#logNeutral(
|
||||||
|
i18n(
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.bulk_delete_finished"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (this.failedUsernames.length > 0) {
|
||||||
|
this.#logNeutral(
|
||||||
|
i18n(
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.failed_to_delete_users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const username of this.failedUsernames) {
|
||||||
|
this.#logNeutral(`* ${username}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onPromptInput(event) {
|
||||||
|
this.confirmButtonDisabled =
|
||||||
|
event.target.value.toLowerCase() !== this.confirmDeletePhrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async startDelete() {
|
||||||
|
this.deleteStarted = true;
|
||||||
|
this.confirmButtonDisabled = true;
|
||||||
|
this.#logNeutral(
|
||||||
|
i18n(
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.bulk_delete_starting"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ajax("/admin/users/destroy-bulk.json", {
|
||||||
|
type: "DELETE",
|
||||||
|
data: { user_ids: this.args.model.userIds },
|
||||||
|
});
|
||||||
|
this.callAfterBulkDelete = true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logError(extractError(err));
|
||||||
|
this.confirmButtonDisabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeModal() {
|
||||||
|
this.args.closeModal();
|
||||||
|
if (this.callAfterBulkDelete) {
|
||||||
|
this.args.model?.afterBulkDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DModal
|
||||||
|
class="bulk-user-delete-confirmation"
|
||||||
|
@closeModal={{this.closeModal}}
|
||||||
|
@title={{i18n
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.title"
|
||||||
|
count=@model.userIds.length
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<:body>
|
||||||
|
{{#if this.deleteStarted}}
|
||||||
|
<div class="bulk-user-delete-confirmation__progress">
|
||||||
|
{{#each this.logs as |entry|}}
|
||||||
|
<div
|
||||||
|
class="bulk-user-delete-confirmation__progress-line -{{entry.type}}"
|
||||||
|
>
|
||||||
|
{{entry.line}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
<div class="bulk-user-delete-confirmation__progress-anchor">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p>{{i18n
|
||||||
|
"admin.users.bulk_actions.delete.confirmation_modal.prompt_text"
|
||||||
|
count=@model.userIds.length
|
||||||
|
confirmation_phrase=this.confirmDeletePhrase
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
class="confirmation-phrase"
|
||||||
|
type="text"
|
||||||
|
placeholder={{this.confirmDeletePhrase}}
|
||||||
|
{{on "input" this.onPromptInput}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</:body>
|
||||||
|
<:footer>
|
||||||
|
<DButton
|
||||||
|
class="confirm-delete btn-danger"
|
||||||
|
@icon="trash-can"
|
||||||
|
@label="admin.users.bulk_actions.delete.confirmation_modal.confirm"
|
||||||
|
@disabled={{this.confirmButtonDisabled}}
|
||||||
|
@action={{this.startDelete}}
|
||||||
|
/>
|
||||||
|
<DButton
|
||||||
|
class="btn-default"
|
||||||
|
@label="admin.users.bulk_actions.delete.confirmation_modal.close"
|
||||||
|
@action={{this.closeModal}}
|
||||||
|
/>
|
||||||
|
</:footer>
|
||||||
|
</DModal>
|
||||||
|
</template>
|
||||||
|
}
|
@ -2,25 +2,25 @@ import { tracked } from "@glimmer/tracking";
|
|||||||
import Controller from "@ember/controller";
|
import Controller from "@ember/controller";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { observes } from "@ember-decorators/object";
|
|
||||||
import { computedI18n } from "discourse/lib/computed";
|
import { computedI18n } from "discourse/lib/computed";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
|
||||||
import { extractError } from "discourse/lib/ajax-error";
|
|
||||||
import CanCheckEmails from "discourse/mixins/can-check-emails";
|
import CanCheckEmails from "discourse/mixins/can-check-emails";
|
||||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||||
import discourseDebounce from "discourse-common/lib/debounce";
|
import discourseDebounce from "discourse-common/lib/debounce";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed, { bind } from "discourse-common/utils/decorators";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
|
import BulkUserDeleteConfirmation from "admin/components/bulk-user-delete-confirmation";
|
||||||
import AdminUser from "admin/models/admin-user";
|
import AdminUser from "admin/models/admin-user";
|
||||||
|
|
||||||
export default class AdminUsersListShowController extends Controller.extend(
|
export default class AdminUsersListShowController extends Controller.extend(
|
||||||
CanCheckEmails
|
CanCheckEmails
|
||||||
) {
|
) {
|
||||||
@service dialog;
|
@service dialog;
|
||||||
|
@service modal;
|
||||||
|
|
||||||
@tracked bulkSelect = false;
|
@tracked bulkSelect = false;
|
||||||
@tracked displayBulkActions = false;
|
@tracked displayBulkActions = false;
|
||||||
@tracked bulkSelectedUsers = null;
|
@tracked bulkSelectedUserIdsSet = new Set();
|
||||||
|
@tracked bulkSelectedUsersMap = {};
|
||||||
|
|
||||||
query = null;
|
query = null;
|
||||||
order = null;
|
order = null;
|
||||||
@ -56,11 +56,6 @@ export default class AdminUsersListShowController extends Controller.extend(
|
|||||||
return colCount;
|
return colCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observes("listFilter")
|
|
||||||
_filterUsers() {
|
|
||||||
discourseDebounce(this, this.resetFilters, INPUT_DELAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetFilters() {
|
resetFilters() {
|
||||||
this._page = 1;
|
this._page = 1;
|
||||||
this._results = [];
|
this._results = [];
|
||||||
@ -96,6 +91,12 @@ export default class AdminUsersListShowController extends Controller.extend(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onListFilterChange(event) {
|
||||||
|
this.set("listFilter", event.target.value);
|
||||||
|
discourseDebounce(this, this.resetFilters, INPUT_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
loadMore() {
|
loadMore() {
|
||||||
this._page += 1;
|
this._page += 1;
|
||||||
@ -120,48 +121,36 @@ export default class AdminUsersListShowController extends Controller.extend(
|
|||||||
toggleBulkSelect() {
|
toggleBulkSelect() {
|
||||||
this.bulkSelect = !this.bulkSelect;
|
this.bulkSelect = !this.bulkSelect;
|
||||||
this.displayBulkActions = false;
|
this.displayBulkActions = false;
|
||||||
this.bulkSelectedUsers = null;
|
this.bulkSelectedUsersMap = {};
|
||||||
|
this.bulkSelectedUserIdsSet = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
bulkSelectItemToggle(userId, event) {
|
bulkSelectItemToggle(userId, event) {
|
||||||
if (!this.bulkSelectedUsers) {
|
if (event.target.checked) {
|
||||||
this.bulkSelectedUsers = {};
|
this.bulkSelectedUserIdsSet.add(userId);
|
||||||
|
this.bulkSelectedUsersMap[userId] = 1;
|
||||||
|
} else {
|
||||||
|
this.bulkSelectedUserIdsSet.delete(userId);
|
||||||
|
delete this.bulkSelectedUsersMap[userId];
|
||||||
|
}
|
||||||
|
this.displayBulkActions = this.bulkSelectedUserIdsSet.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.checked) {
|
@bind
|
||||||
this.bulkSelectedUsers[userId] = 1;
|
async afterBulkDelete() {
|
||||||
} else {
|
await this.resetFilters();
|
||||||
delete this.bulkSelectedUsers[userId];
|
this.bulkSelectedUsersMap = {};
|
||||||
}
|
this.bulkSelectedUserIdsSet = new Set();
|
||||||
this.displayBulkActions = Object.keys(this.bulkSelectedUsers).length > 0;
|
this.displayBulkActions = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
performBulkDelete() {
|
openBulkDeleteConfirmation() {
|
||||||
const userIds = Object.keys(this.bulkSelectedUsers);
|
this.modal.show(BulkUserDeleteConfirmation, {
|
||||||
const count = userIds.length;
|
model: {
|
||||||
this.dialog.deleteConfirm({
|
userIds: Array.from(this.bulkSelectedUserIdsSet),
|
||||||
title: I18n.t("admin.users.bulk_actions.confirm_delete_title", {
|
afterBulkDelete: this.afterBulkDelete,
|
||||||
count,
|
|
||||||
}),
|
|
||||||
message: I18n.t("admin.users.bulk_actions.confirm_delete_body", {
|
|
||||||
count,
|
|
||||||
}),
|
|
||||||
confirmButtonClass: "btn-danger",
|
|
||||||
confirmButtonIcon: "trash-can",
|
|
||||||
didConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await ajax("/admin/users/destroy-bulk.json", {
|
|
||||||
type: "DELETE",
|
|
||||||
data: { user_ids: userIds },
|
|
||||||
});
|
|
||||||
await this.resetFilters();
|
|
||||||
this.bulkSelectedUsers = null;
|
|
||||||
this.displayBulkActions = false;
|
|
||||||
} catch (err) {
|
|
||||||
this.dialog.alert(extractError(err));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,8 @@ export default class AdminUsersListShowRoute extends DiscourseRoute {
|
|||||||
listFilter: transition.to.queryParams.username,
|
listFilter: transition.to.queryParams.username,
|
||||||
query: params.filter,
|
query: params.filter,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
bulkSelectedUsers: null,
|
bulkSelectedUsersMap: {},
|
||||||
|
bulkSelectedUserIdsSet: new Set(),
|
||||||
displayBulkActions: false,
|
displayBulkActions: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -21,10 +21,12 @@
|
|||||||
|
|
||||||
<div class="admin-users-list__controls">
|
<div class="admin-users-list__controls">
|
||||||
<div class="username">
|
<div class="username">
|
||||||
<TextField
|
<input
|
||||||
@value={{this.listFilter}}
|
type="text"
|
||||||
@placeholder={{this.searchHint}}
|
dir="auto"
|
||||||
@title={{this.searchHint}}
|
placeholder={{this.searchHint}}
|
||||||
|
title={{this.searchHint}}
|
||||||
|
{{on "input" this.onListFilterChange}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{{#if this.displayBulkActions}}
|
{{#if this.displayBulkActions}}
|
||||||
@ -41,9 +43,11 @@
|
|||||||
<DropdownMenu as |dropdown|>
|
<DropdownMenu as |dropdown|>
|
||||||
<dropdown.item>
|
<dropdown.item>
|
||||||
<DButton
|
<DButton
|
||||||
@translatedLabel={{i18n "admin.users.bulk_actions.delete"}}
|
@translatedLabel={{i18n
|
||||||
|
"admin.users.bulk_actions.delete.label"
|
||||||
|
}}
|
||||||
@icon="trash-can"
|
@icon="trash-can"
|
||||||
@action={{this.performBulkDelete}}
|
@action={{this.openBulkDeleteConfirmation}}
|
||||||
class="bulk-delete btn-danger"
|
class="bulk-delete btn-danger"
|
||||||
/>
|
/>
|
||||||
</dropdown.item>
|
</dropdown.item>
|
||||||
@ -173,12 +177,38 @@
|
|||||||
>
|
>
|
||||||
<div class="directory-table__cell username">
|
<div class="directory-table__cell username">
|
||||||
{{#if this.bulkSelect}}
|
{{#if this.bulkSelect}}
|
||||||
|
{{#if user.can_be_deleted}}
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="directory-table__cell-bulk-select"
|
class="directory-table__cell-bulk-select"
|
||||||
checked={{eq (get this.bulkSelectedUsers user.id) 1}}
|
checked={{eq (get this.bulkSelectedUsersMap user.id) 1}}
|
||||||
{{on "click" (fn this.bulkSelectItemToggle user.id)}}
|
{{on "click" (fn this.bulkSelectItemToggle user.id)}}
|
||||||
/>
|
/>
|
||||||
|
{{else}}
|
||||||
|
<DTooltip
|
||||||
|
@identifier="bulk-delete-unavailable-reason"
|
||||||
|
@placement="bottom-start"
|
||||||
|
>
|
||||||
|
<:trigger>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="directory-table__cell-bulk-select"
|
||||||
|
disabled={{true}}
|
||||||
|
/>
|
||||||
|
</:trigger>
|
||||||
|
<:content>
|
||||||
|
{{#if user.admin}}
|
||||||
|
{{i18n
|
||||||
|
"admin.users.bulk_actions.admin_cant_be_deleted"
|
||||||
|
}}
|
||||||
|
{{else}}
|
||||||
|
{{i18n
|
||||||
|
"admin.users.bulk_actions.too_many_or_old_posts"
|
||||||
|
}}
|
||||||
|
{{/if}}
|
||||||
|
</:content>
|
||||||
|
</DTooltip>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<a
|
<a
|
||||||
class="avatar"
|
class="avatar"
|
||||||
@ -325,9 +355,8 @@
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
</:body>
|
</:body>
|
||||||
</ResponsiveTable>
|
</ResponsiveTable>
|
||||||
|
{{else if (not this.refreshing)}}
|
||||||
<ConditionalLoadingSpinner @condition={{this.refreshing}} />
|
|
||||||
{{else}}
|
|
||||||
<p>{{i18n "search.no_results"}}</p>
|
<p>{{i18n "search.no_results"}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
<ConditionalLoadingSpinner @condition={{this.refreshing}} />
|
||||||
</LoadMore>
|
</LoadMore>
|
@ -1105,3 +1105,4 @@ a.inline-editable-field {
|
|||||||
@import "common/admin/mini_profiler";
|
@import "common/admin/mini_profiler";
|
||||||
@import "common/admin/schema_theme_setting_editor";
|
@import "common/admin/schema_theme_setting_editor";
|
||||||
@import "common/admin/customize_themes_show_schema";
|
@import "common/admin/customize_themes_show_schema";
|
||||||
|
@import "common/admin/admin_bulk_users_delete_modal";
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
.bulk-user-delete-confirmation {
|
||||||
|
&__progress {
|
||||||
|
font-family: var(--d-font-family--monospace);
|
||||||
|
max-height: 400px;
|
||||||
|
background: var(--blend-primary-secondary-5);
|
||||||
|
padding: 1em;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
&__progress-line {
|
||||||
|
overflow-anchor: none;
|
||||||
|
|
||||||
|
&.-success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
&.-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__progress-anchor {
|
||||||
|
overflow-anchor: auto;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
}
|
@ -414,6 +414,8 @@ class Admin::UsersController < Admin::StaffController
|
|||||||
on_failed_policy(:can_delete_users) do
|
on_failed_policy(:can_delete_users) do
|
||||||
render json: failed_json.merge(errors: [I18n.t("user.cannot_bulk_delete")]), status: 403
|
render json: failed_json.merge(errors: [I18n.t("user.cannot_bulk_delete")]), status: 403
|
||||||
end
|
end
|
||||||
|
|
||||||
|
on_model_not_found(:users) { render json: failed_json, status: 404 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -24,7 +24,8 @@ class AdminUserListSerializer < BasicUserSerializer
|
|||||||
:silenced_till,
|
:silenced_till,
|
||||||
:time_read,
|
:time_read,
|
||||||
:staged,
|
:staged,
|
||||||
:second_factor_enabled
|
:second_factor_enabled,
|
||||||
|
:can_be_deleted
|
||||||
|
|
||||||
%i[days_visited posts_read_count topics_entered post_count].each do |sym|
|
%i[days_visited posts_read_count topics_entered post_count].each do |sym|
|
||||||
attributes sym
|
attributes sym
|
||||||
@ -111,4 +112,8 @@ class AdminUserListSerializer < BasicUserSerializer
|
|||||||
def second_factor_enabled
|
def second_factor_enabled
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_be_deleted
|
||||||
|
scope.can_delete_user?(object)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -16,7 +16,11 @@ class User::BulkDestroy
|
|||||||
private
|
private
|
||||||
|
|
||||||
def fetch_users(params:)
|
def fetch_users(params:)
|
||||||
User.where(id: params.user_ids.to_a)
|
ids = params.user_ids.to_a
|
||||||
|
# this order cluase ensures we retrieve the users in the same order as the
|
||||||
|
# IDs in the param. we do this to ensure the users are deleted in the same
|
||||||
|
# order as they're selected in the UI
|
||||||
|
User.where(id: ids).order(DB.sql_fragment("array_position(ARRAY[?], users.id)", ids))
|
||||||
end
|
end
|
||||||
|
|
||||||
def can_delete_users(guardian:, users:)
|
def can_delete_users(guardian:, users:)
|
||||||
@ -24,12 +28,42 @@ class User::BulkDestroy
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete(users:, guardian:)
|
def delete(users:, guardian:)
|
||||||
users.each do |u|
|
users.each.with_index do |u, index|
|
||||||
|
position = index + 1
|
||||||
|
success =
|
||||||
UserDestroyer.new(guardian.user).destroy(
|
UserDestroyer.new(guardian.user).destroy(
|
||||||
u,
|
u,
|
||||||
delete_posts: true,
|
delete_posts: true,
|
||||||
|
prepare_for_destroy: true,
|
||||||
context: I18n.t("staff_action_logs.bulk_user_delete", users: users.map(&:id).inspect),
|
context: I18n.t("staff_action_logs.bulk_user_delete", users: users.map(&:id).inspect),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if success
|
||||||
|
publish_progress(
|
||||||
|
guardian.user,
|
||||||
|
{ position:, username: u.username, total: users.size, success: true },
|
||||||
|
)
|
||||||
|
else
|
||||||
|
publish_progress(
|
||||||
|
guardian.user,
|
||||||
|
{
|
||||||
|
position:,
|
||||||
|
username: u.username,
|
||||||
|
total: users.size,
|
||||||
|
failed: true,
|
||||||
|
error: u.errors.full_messages.join(", "),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
rescue => err
|
||||||
|
publish_progress(
|
||||||
|
guardian.user,
|
||||||
|
{ position:, username: u.username, total: users.size, failed: true, error: err.message },
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def publish_progress(actor, data)
|
||||||
|
::MessageBus.publish("/bulk-user-delete", data, user_ids: [actor.id])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -6641,13 +6641,27 @@ en:
|
|||||||
hide_emails: "Hide Emails"
|
hide_emails: "Hide Emails"
|
||||||
bulk_actions:
|
bulk_actions:
|
||||||
title: "Bulk actions"
|
title: "Bulk actions"
|
||||||
delete: "Delete users…"
|
admin_cant_be_deleted: "This user can't be deleted because they're an admin"
|
||||||
confirm_delete_title:
|
too_many_or_old_posts: "This user can't be deleted they have too many posts or a very old post"
|
||||||
one: "Delete %{count} user?"
|
delete:
|
||||||
other: "Delete %{count} users?"
|
label: "Delete users…"
|
||||||
confirm_delete_body:
|
confirmation_modal:
|
||||||
one: "Are you sure you want to delete %{count} user? This action is permanent and cannot be reversed."
|
prompt_text:
|
||||||
other: "Are you sure you want to delete %{count} users? This action is permanent and cannot be reversed."
|
one: "You're about to delete %{count} user permanently. Type \"%{confirmation_phrase}\" below to proceed:"
|
||||||
|
other: "You're about to delete %{count} users permanently. Type \"%{confirmation_phrase}\" below to proceed:"
|
||||||
|
confirmation_phrase:
|
||||||
|
one: "delete %{count} user"
|
||||||
|
other: "delete %{count} users"
|
||||||
|
close: "Close"
|
||||||
|
confirm: "Delete"
|
||||||
|
title:
|
||||||
|
one: "Delete %{count} user"
|
||||||
|
other: "Delete %{count} users"
|
||||||
|
bulk_delete_starting: "Starting bulk delete…"
|
||||||
|
user_delete_succeeded: "[%{position}/%{total}] Successfully deleted @%{username}"
|
||||||
|
user_delete_failed: "[%{position}/%{total}] Failed to delete @%{username} - %{error}"
|
||||||
|
bulk_delete_finished: "Bulk delete operation completed."
|
||||||
|
failed_to_delete_users: "The following users failed to be deleted:"
|
||||||
nav:
|
nav:
|
||||||
new: "New"
|
new: "New"
|
||||||
active: "Active"
|
active: "Active"
|
||||||
|
@ -1445,6 +1445,12 @@ RSpec.describe Admin::UsersController do
|
|||||||
expect(User.where(id: deleted_users.map(&:id)).count).to eq(0)
|
expect(User.where(id: deleted_users.map(&:id)).count).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "responds with 404 when sending an empty user_ids list" do
|
||||||
|
delete "/admin/users/destroy-bulk.json", params: { user_ids: [] }
|
||||||
|
|
||||||
|
expect(response.status).to eq(404)
|
||||||
|
end
|
||||||
|
|
||||||
it "doesn't allow deleting a user that can't be deleted" do
|
it "doesn't allow deleting a user that can't be deleted" do
|
||||||
deleted_users[0].update!(admin: true)
|
deleted_users[0].update!(admin: true)
|
||||||
|
|
||||||
|
@ -2,13 +2,31 @@
|
|||||||
|
|
||||||
describe "Admin Users Page", type: :system do
|
describe "Admin Users Page", type: :system do
|
||||||
fab!(:current_user) { Fabricate(:admin) }
|
fab!(:current_user) { Fabricate(:admin) }
|
||||||
|
fab!(:another_admin) { Fabricate(:admin) }
|
||||||
fab!(:users) { Fabricate.times(3, :user) }
|
fab!(:users) { Fabricate.times(3, :user) }
|
||||||
|
|
||||||
let(:admin_users_page) { PageObjects::Pages::AdminUsers.new }
|
let(:admin_users_page) { PageObjects::Pages::AdminUsers.new }
|
||||||
let(:dialog) { PageObjects::Components::Dialog.new }
|
|
||||||
|
|
||||||
before { sign_in(current_user) }
|
before { sign_in(current_user) }
|
||||||
|
|
||||||
|
describe "bulk user delete" do
|
||||||
|
let(:confirmation_modal) { PageObjects::Modals::BulkUserDeleteConfirmation.new }
|
||||||
|
|
||||||
|
it "disables checkboxes for users that can't be deleted" do
|
||||||
|
admin_users_page.visit
|
||||||
|
|
||||||
|
admin_users_page.bulk_select_button.click
|
||||||
|
|
||||||
|
expect(admin_users_page.user_row(current_user.id).bulk_select_checkbox.disabled?).to eq(true)
|
||||||
|
expect(admin_users_page.user_row(another_admin.id).bulk_select_checkbox.disabled?).to eq(true)
|
||||||
|
expect(admin_users_page.user_row(users[0].id).bulk_select_checkbox.disabled?).to eq(false)
|
||||||
|
|
||||||
|
admin_users_page.user_row(another_admin.id).bulk_select_checkbox.hover
|
||||||
|
expect(PageObjects::Components::Tooltips.new("bulk-delete-unavailable-reason")).to be_present(
|
||||||
|
text: I18n.t("admin_js.admin.users.bulk_actions.admin_cant_be_deleted"),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it "has a button that toggles the bulk select checkboxes" do
|
it "has a button that toggles the bulk select checkboxes" do
|
||||||
admin_users_page.visit
|
admin_users_page.visit
|
||||||
|
|
||||||
@ -34,9 +52,30 @@ describe "Admin Users Page", type: :system do
|
|||||||
admin_users_page.bulk_actions_dropdown.expand
|
admin_users_page.bulk_actions_dropdown.expand
|
||||||
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
|
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
|
||||||
|
|
||||||
expect(dialog).to be_open
|
expect(confirmation_modal).to be_open
|
||||||
dialog.click_danger
|
expect(confirmation_modal).to have_confirm_button_disabled
|
||||||
|
|
||||||
|
confirmation_modal.fill_in_confirmation_phase(user_count: 3)
|
||||||
|
expect(confirmation_modal).to have_confirm_button_disabled
|
||||||
|
|
||||||
|
confirmation_modal.fill_in_confirmation_phase(user_count: 2)
|
||||||
|
expect(confirmation_modal).to have_confirm_button_enabled
|
||||||
|
|
||||||
|
confirmation_modal.confirm_button.click
|
||||||
|
|
||||||
|
expect(confirmation_modal).to have_successful_log_entry_for_user(
|
||||||
|
user: users[0],
|
||||||
|
position: 1,
|
||||||
|
total: 2,
|
||||||
|
)
|
||||||
|
expect(confirmation_modal).to have_successful_log_entry_for_user(
|
||||||
|
user: users[1],
|
||||||
|
position: 2,
|
||||||
|
total: 2,
|
||||||
|
)
|
||||||
|
expect(confirmation_modal).to have_no_error_log_entries
|
||||||
|
|
||||||
|
confirmation_modal.close
|
||||||
deleted_ids = users[0..1].map(&:id)
|
deleted_ids = users[0..1].map(&:id)
|
||||||
expect(admin_users_page).to have_no_users(deleted_ids)
|
expect(admin_users_page).to have_no_users(deleted_ids)
|
||||||
expect(User.where(id: deleted_ids).count).to eq(0)
|
expect(User.where(id: deleted_ids).count).to eq(0)
|
||||||
@ -61,11 +100,43 @@ describe "Admin Users Page", type: :system do
|
|||||||
admin_users_page.bulk_actions_dropdown.expand
|
admin_users_page.bulk_actions_dropdown.expand
|
||||||
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
|
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
|
||||||
|
|
||||||
expect(dialog).to be_open
|
expect(confirmation_modal).to be_open
|
||||||
dialog.click_danger
|
confirmation_modal.fill_in_confirmation_phase(user_count: 2)
|
||||||
|
confirmation_modal.confirm_button.click
|
||||||
|
expect(confirmation_modal).to have_successful_log_entry_for_user(
|
||||||
|
user: users[0],
|
||||||
|
position: 1,
|
||||||
|
total: 2,
|
||||||
|
)
|
||||||
|
expect(confirmation_modal).to have_successful_log_entry_for_user(
|
||||||
|
user: users[1],
|
||||||
|
position: 2,
|
||||||
|
total: 2,
|
||||||
|
)
|
||||||
|
confirmation_modal.close
|
||||||
|
|
||||||
deleted_ids = users[0..1].map(&:id)
|
deleted_ids = users[0..1].map(&:id)
|
||||||
expect(admin_users_page).to have_no_users(deleted_ids)
|
expect(admin_users_page).to have_no_users(deleted_ids)
|
||||||
expect(User.where(id: deleted_ids).count).to eq(0)
|
expect(User.where(id: deleted_ids).count).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "displays an error message if bulk delete fails" do
|
||||||
|
admin_users_page.visit
|
||||||
|
admin_users_page.bulk_select_button.click
|
||||||
|
|
||||||
|
admin_users_page.user_row(users[0].id).bulk_select_checkbox.click
|
||||||
|
admin_users_page.bulk_actions_dropdown.expand
|
||||||
|
admin_users_page.bulk_actions_dropdown.option(".bulk-delete").click
|
||||||
|
confirmation_modal.fill_in_confirmation_phase(user_count: 1)
|
||||||
|
|
||||||
|
users[0].update!(admin: true)
|
||||||
|
|
||||||
|
confirmation_modal.confirm_button.click
|
||||||
|
expect(confirmation_modal).to have_error_log_entry(
|
||||||
|
I18n.t("js.generic_error_with_reason", error: I18n.t("user.cannot_bulk_delete")),
|
||||||
|
)
|
||||||
|
confirmation_modal.close
|
||||||
|
expect(admin_users_page).to have_users([users[0].id])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,66 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PageObjects
|
||||||
|
module Modals
|
||||||
|
class BulkUserDeleteConfirmation < Base
|
||||||
|
MODAL_SELECTOR = ".bulk-user-delete-confirmation"
|
||||||
|
|
||||||
|
def confirm_button
|
||||||
|
within(modal) { find(".btn.confirm-delete") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_confirm_button_disabled?
|
||||||
|
within(modal) { has_css?(".btn.confirm-delete[disabled]") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_confirm_button_enabled?
|
||||||
|
within(modal) do
|
||||||
|
has_no_css?(".btn.confirm-delete[disabled]") && has_css?(".btn.confirm-delete")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fill_in_confirmation_phase(user_count:)
|
||||||
|
within(modal) do
|
||||||
|
find("input.confirmation-phrase").fill_in(
|
||||||
|
with:
|
||||||
|
I18n.t(
|
||||||
|
"admin_js.admin.users.bulk_actions.delete.confirmation_modal.confirmation_phrase",
|
||||||
|
count: user_count,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_successful_log_entry_for_user?(user:, position:, total:)
|
||||||
|
within(modal) do
|
||||||
|
has_css?(
|
||||||
|
".bulk-user-delete-confirmation__progress-line.-success",
|
||||||
|
text:
|
||||||
|
I18n.t(
|
||||||
|
"admin_js.admin.users.bulk_actions.delete.confirmation_modal.user_delete_succeeded",
|
||||||
|
position:,
|
||||||
|
total:,
|
||||||
|
username: user.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_no_error_log_entries?
|
||||||
|
within(modal) { has_no_css?(".bulk-user-delete-confirmation__progress-line.-error") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_error_log_entry?(message)
|
||||||
|
within(modal) do
|
||||||
|
has_css?(".bulk-user-delete-confirmation__progress-line.-error", text: message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def modal
|
||||||
|
find(MODAL_SELECTOR)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user