diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1738481cf..0482bfff3 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -25,16 +25,21 @@ module Admin last_moderation_notification last_notification_activity ].freeze + MODROLE_ACTIONS_TO_POLICIES = { + user_status: :toggle_suspension_status?, + unpublish_all_articles: :unpublish_all_articles? + }.freeze + after_action only: %i[update user_status banish full_delete unpublish_all_articles merge] do Audit::Logger.log(:moderator, current_user, params.dup) end # Having this method here (which also exists in admin/application_controller) - # allows us to authorize the moderator role specifically for the user_status - # action, while preserving the implementation for all other admin actions + # allows us to authorize the actions of the moderator role specifically, + # while preserving the implementation for all other admin actions def authorize_admin - if action_name == "user_status" - authorize(User, :toggle_suspension_status?) + if MODROLE_ACTIONS_TO_POLICIES.key?(action_name.to_sym) + authorize(User, MODROLE_ACTIONS_TO_POLICIES[action_name.to_sym]) else super end @@ -184,8 +189,17 @@ module Admin def unpublish_all_articles Moderator::UnpublishAllArticlesWorker.perform_async(params[:id].to_i) - flash[:success] = I18n.t("admin.users_controller.unpublished") - redirect_to admin_user_path(params[:id]) + message = I18n.t("admin.users_controller.unpublished") + respond_to do |format| + format.html do + flash[:success] = message + redirect_to admin_user_path(params[:id]) + end + + format.json do + render json: { message: message } + end + end end def merge diff --git a/app/javascript/actionsPanel/actionsPanel.js b/app/javascript/actionsPanel/actionsPanel.js index b40cda99d..2e585fa90 100644 --- a/app/javascript/actionsPanel/actionsPanel.js +++ b/app/javascript/actionsPanel/actionsPanel.js @@ -1,6 +1,7 @@ import { toggleFlagUserModal } from '../packs/flagUserModal'; import { toggleModal } from '../packs/toggleUserSuspensionModal'; import { toggleUnpublishPostModal } from '../packs/unpublishPostModal'; +import { toggleUnpublishAllPostsModal } from '../packs/modals/unpublishAllPosts'; import { request } from '@utilities/http'; export function addCloseListener() { @@ -404,6 +405,9 @@ export function addBottomActionsListeners() { document .getElementById('unsuspend-user-btn') ?.addEventListener('click', toggleModal); + + document.getElementById('unpublish-all-posts-btn') + ?.addEventListener('click', toggleUnpublishAllPostsModal); } export function initializeActionsPanel() { diff --git a/app/javascript/packs/modals/unpublishAllPosts.js b/app/javascript/packs/modals/unpublishAllPosts.js new file mode 100644 index 000000000..30d8ca7d6 --- /dev/null +++ b/app/javascript/packs/modals/unpublishAllPosts.js @@ -0,0 +1,78 @@ +import { closeWindowModal, showWindowModal } from '@utilities/showModal'; +import { request } from '@utilities/http'; + +const unpublishAllPosts = async (event) => { + event.preventDefault(); + const { userId } = event.target.dataset; + + try { + const response = await request( + `/admin/member_manager/users/${userId}/unpublish_all_articles`, + { + method: 'POST', + body: JSON.stringify({ id: userId }), + credentials: 'same-origin', + }, + ); + + const outcome = await response.json(); + + top.addSnackbarItem({ + message: outcome.message, + addCloseButton: true, + }); + } catch (error) { + top.addSnackbarItem({ + message: `Error: ${error}`, + addCloseButton: true, + }); + } + + closeWindowModal(window.parent.document); +}; + +const modalContents = new Map(); + +/** + * Helper function to handle finding and caching modal content. Since our Preact modal helper works by duplicating HTML content, + * and our modals rely on IDs to label form controls, we remove the original hidden content from the DOM to avoid ID conflicts. + * + * @param {string} modalContentSelector The CSS selector used to identify the correct modal content + */ +function getModalContents(modalContentSelector) { + if (!modalContents.has(modalContentSelector)) { + const modalContentElement = + window.parent.document.querySelector(modalContentSelector); + const modalContent = modalContentElement.innerHTML; + + modalContentElement.remove(); + modalContents.set(modalContentSelector, modalContent); + } + + return modalContents.get(modalContentSelector); +} + +function activateUnpublishAllPostsBtn() { + const unpublishAllPostsBtn = window.parent.document.getElementById( + 'unpublish-all-posts-submit-btn', + ); + + unpublishAllPostsBtn.addEventListener('click', unpublishAllPosts); +} + +export function toggleUnpublishAllPostsModal() { + const unpublishAllPostsBtn = document.getElementById( + 'unpublish-all-posts-btn', + ); + + const { modalTitle, modalSize, modalContentSelector } = + unpublishAllPostsBtn.dataset; + + showWindowModal({ + document: window.parent.document, + modalContent: getModalContents(modalContentSelector), + title: modalTitle, + size: modalSize, + onOpen: activateUnpublishAllPostsBtn, + }); +} diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 4d1c7d30e..2160e8a4f 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -94,6 +94,7 @@ class UserPolicy < ApplicationPolicy end alias toggle_suspension_status? elevated_user? + alias unpublish_all_articles? elevated_user? def moderation_routes? (user.has_trusted_role? || elevated_user?) && !user.suspended? diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index 1242d0fec..c8720eee9 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -220,8 +220,9 @@ -<%= render "moderations/suspend_user_modal" %> -<%= render "moderations/unsuspend_user_modal" %> +<%= render "moderations/modals/suspend_user_modal" %> +<%= render "moderations/modals/unsuspend_user_modal" %> +<%= render "moderations/modals/unpublish_all_posts" %>
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %> diff --git a/app/views/moderations/actions_panel.html.erb b/app/views/moderations/actions_panel.html.erb index a8de9f235..1306186c0 100644 --- a/app/views/moderations/actions_panel.html.erb +++ b/app/views/moderations/actions_panel.html.erb @@ -211,6 +211,17 @@ data-modal-size="small" data-modal-content-selector="#<%= user_suspended ? "unsuspend" : "suspend" %>-modal-content" data-username="<%= user_username %>"> <%= t("views.moderations.actions.suspend.#{user_suspended ? 'unsuspend' : 'suspend'}_user", username: user_username) %> + + <% end %> diff --git a/app/views/moderations/_suspend_user_modal.html.erb b/app/views/moderations/modals/_suspend_user_modal.html.erb similarity index 100% rename from app/views/moderations/_suspend_user_modal.html.erb rename to app/views/moderations/modals/_suspend_user_modal.html.erb diff --git a/app/views/moderations/modals/_unpublish_all_posts.html.erb b/app/views/moderations/modals/_unpublish_all_posts.html.erb new file mode 100644 index 000000000..4cc65b584 --- /dev/null +++ b/app/views/moderations/modals/_unpublish_all_posts.html.erb @@ -0,0 +1,18 @@ + diff --git a/app/views/moderations/_unsuspend_user_modal.html.erb b/app/views/moderations/modals/_unsuspend_user_modal.html.erb similarity index 100% rename from app/views/moderations/_unsuspend_user_modal.html.erb rename to app/views/moderations/modals/_unsuspend_user_modal.html.erb diff --git a/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js b/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js index 17cf5e775..2e00c1446 100644 --- a/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js +++ b/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js @@ -152,6 +152,38 @@ describe('Moderation Tools for Posts', () => { }); }); + context('when unpublishing all posts', () => { + beforeEach(() => { + cy.get('@moderatorUser').then((user) => { + cy.loginAndVisit(user, '/series_user/series-test-article-slug'); + cy.findByRole('heading', { level: 1, name: 'Series test article' }); + cy.findByRole('button', { name: 'Moderation' }).click(); + }); + }); + + it('unpublishes all posts', () => { + cy.getIframeBody('[title="Moderation panel actions"]').within(() => { + cy.findByRole('button', { name: 'Open admin actions' }) + .as('moderatingActionsButton') + .pipe(click) + .should('have.attr', 'aria-expanded', 'true'); + cy.findByRole('button', { + name: /Unpublish all posts for series_user/i, + }).click(); + }); + + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Unpublish all posts' }).click(); + }); + + cy.findByTestId('snackbar') + .contains( + 'Posts are being unpublished in the background. The job will complete soon.', + ) + .should('exist'); + }); + }); + context('when suspending user', () => { beforeEach(() => { cy.get('@moderatorUser').then((user) => {