From 750d379807367b404fbb00bfe3509d6063db03a8 Mon Sep 17 00:00:00 2001 From: Arit Amana <32520970+msarit@users.noreply.github.com> Date: Mon, 27 Jun 2022 12:45:35 -0400 Subject: [PATCH] Allow ModRole & Admin to Suspend & Unsuspend User (#17946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gotta commit at some point * out of gas * IT'S WORKING NOW! đŸ’Ĩ * Cypress tests * fix Travis failures * incorporate PR review suggestions * fix one failing Cypress test * update API call * add Unsuspend flow; fix Travis failures * add tests for unsuspend flow * remove unnecessary atrribute * add article_policy spec * fix failing Travis * nudge Travis * fix apparent typo o o 🤷 * refactor a bunch of duplication * nudge Travis * fix Cypress failure related to authorizing moderator for user_status action * rename modal partial * remove unnecessary button-exists check * address PR review suggestions * hide suspend user btn after action * toggle btn based on author suspension status * add data-testids * use data-testids * controller refactor --- app/controllers/admin/users_controller.rb | 40 +++- app/javascript/actionsPanel/actionsPanel.js | 9 + .../components/Help/BasicEditor.jsx | 2 +- app/javascript/crayons/Modal/Modal.jsx | 2 + .../packs/admin/users/gdprDeleteRequests.jsx | 2 +- app/javascript/packs/base.jsx | 2 +- .../packs/toggleUserSuspensionModal.js | 144 +++++++++++++++ .../shared/components/focusTrap.jsx | 4 +- app/javascript/utilities/showModal.jsx | 5 +- app/policies/article_policy.rb | 9 +- app/policies/user_policy.rb | 9 +- app/views/articles/show.html.erb | 2 + .../moderations/_suspend_user_modal.html.erb | 41 +++++ .../_unsuspend_user_modal.html.erb | 41 +++++ app/views/moderations/actions_panel.html.erb | 23 ++- config/locales/controllers/admin/en.yml | 29 +-- config/locales/controllers/admin/fr.yml | 29 +-- config/locales/views/moderations/en.yml | 35 ++-- config/locales/views/moderations/fr.yml | 35 ++-- .../articleFlows/postModerationTools.spec.js | 171 +++++++++++++++++- spec/policies/article_policy_spec.rb | 2 +- spec/support/seeds/seeds_e2e.rb | 43 +++++ 22 files changed, 588 insertions(+), 91 deletions(-) create mode 100644 app/javascript/packs/toggleUserSuspensionModal.js create mode 100644 app/views/moderations/_suspend_user_modal.html.erb create mode 100644 app/views/moderations/_unsuspend_user_modal.html.erb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1b38381ce..1738481cf 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -29,6 +29,17 @@ module Admin 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 + def authorize_admin + if action_name == "user_status" + authorize(User, :toggle_suspension_status?) + else + super + end + end + def index @users = Admin::UsersQuery.call( relation: User.registered, @@ -104,18 +115,33 @@ module Admin begin Moderator::ManageActivityAndRoles.handle_user_roles(admin: current_user, user: @user, user_params: user_params) flash[:success] = I18n.t("admin.users_controller.updated") + respond_to do |format| + format.html do + redirect_back_or_to admin_users_path + end + format.json do + render json: { + success: true, + message: I18n.t("admin.users_controller.updated_json", username: @user.username) + }, status: :ok + end + end rescue StandardError => e flash[:danger] = e.message + respond_to do |format| + format.html do + redirect_back_or_to admin_users_path + end + format.json do + render json: { + success: false, + message: @user.errors_as_sentence + }, status: :unprocessable_entity + end + end end - Credits::Manage.call(@user, credit_params) add_note if user_params[:new_note] - - if request.referer&.include?(admin_user_path(params[:id])) - redirect_to admin_user_path(params[:id]) - else - redirect_to admin_users_path - end end def export_data diff --git a/app/javascript/actionsPanel/actionsPanel.js b/app/javascript/actionsPanel/actionsPanel.js index 1f37610a5..b40cda99d 100644 --- a/app/javascript/actionsPanel/actionsPanel.js +++ b/app/javascript/actionsPanel/actionsPanel.js @@ -1,4 +1,5 @@ import { toggleFlagUserModal } from '../packs/flagUserModal'; +import { toggleModal } from '../packs/toggleUserSuspensionModal'; import { toggleUnpublishPostModal } from '../packs/unpublishPostModal'; import { request } from '@utilities/http'; @@ -395,6 +396,14 @@ export function addBottomActionsListeners() { document .getElementById('open-flag-user-modal') .addEventListener('click', toggleFlagUserModal); + + document + .getElementById('suspend-user-btn') + ?.addEventListener('click', toggleModal); + + document + .getElementById('unsuspend-user-btn') + ?.addEventListener('click', toggleModal); } export function initializeActionsPanel() { diff --git a/app/javascript/article-form/components/Help/BasicEditor.jsx b/app/javascript/article-form/components/Help/BasicEditor.jsx index a25ece38e..da47dd8be 100644 --- a/app/javascript/article-form/components/Help/BasicEditor.jsx +++ b/app/javascript/article-form/components/Help/BasicEditor.jsx @@ -31,5 +31,5 @@ export const BasicEditor = ({ openModal }) => ( ); BasicEditor.propTypes = { - toggleModal: PropTypes.func.isRequired, + openModal: PropTypes.func.isRequired, }; diff --git a/app/javascript/crayons/Modal/Modal.jsx b/app/javascript/crayons/Modal/Modal.jsx index b26d710a4..9da5e2145 100644 --- a/app/javascript/crayons/Modal/Modal.jsx +++ b/app/javascript/crayons/Modal/Modal.jsx @@ -20,6 +20,7 @@ export const Modal = ({ backdropDismissible = false, onClose = () => {}, focusTrapSelector = '.crayons-modal__box', + document = window.document, }) => { const classes = classNames('crayons-modal', { [`crayons-modal--${size}`]: size && size !== 'medium', @@ -36,6 +37,7 @@ export const Modal = ({ onDeactivate={onClose} clickOutsideDeactivates={backdropDismissible} selector={focusTrapSelector} + document={document} >
{ // Update cancel button to close the modal document .querySelector(`#${WINDOW_MODAL_ID} .js-gdpr-cancel-deleted`) - .addEventListener('click', closeWindowModal); + .addEventListener('click', () => closeWindowModal()); }, }); }; diff --git a/app/javascript/packs/base.jsx b/app/javascript/packs/base.jsx index f249a8b2b..7006c3eeb 100644 --- a/app/javascript/packs/base.jsx +++ b/app/javascript/packs/base.jsx @@ -52,7 +52,7 @@ window.Forem = { ); }, showModal: showWindowModal, - closeModal: closeWindowModal, + closeModal: () => closeWindowModal(), Runtime, }; diff --git a/app/javascript/packs/toggleUserSuspensionModal.js b/app/javascript/packs/toggleUserSuspensionModal.js new file mode 100644 index 000000000..673ccc7b8 --- /dev/null +++ b/app/javascript/packs/toggleUserSuspensionModal.js @@ -0,0 +1,144 @@ +import { + closeWindowModal, + showWindowModal, + WINDOW_MODAL_ID, +} from '@utilities/showModal'; +import { request } from '@utilities/http'; + +const suspendOrUnsuspendUser = async ({ + event, + btnAction, + userId, + username, + suspendOrUnsuspendReason, +}) => { + event.preventDefault(); + closeModal(); + + try { + const response = await request( + `/admin/member_manager/users/${userId}/user_status`, + { + method: 'PATCH', + body: JSON.stringify({ + id: userId, + user: { + note_for_current_role: suspendOrUnsuspendReason, + user_status: btnAction == 'suspend' ? 'Suspended' : 'Good standing', + }, + }), + credentials: 'same-origin', + }, + ); + + const outcome = await response.json(); + + if (outcome.success) { + top.addSnackbarItem({ + message: outcome.message, + addCloseButton: true, + }); + + updateBtnFlow(btnAction, username); + } else { + top.addSnackbarItem({ + message: 'Error: something went wrong.', + addCloseButton: true, + }); + } + } catch (error) { + top.addSnackbarItem({ + message: `Error: ${error}`, + addCloseButton: true, + }); + } +}; + +function capitalizeFirst(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +function updateBtnFlow(btnAction, username) { + const btn = window.parent.document + .getElementById('mod-container') + .contentDocument.querySelector(`#${btnAction}-user-btn`); + const oppositeAction = btnAction == 'suspend' ? 'unsuspend' : 'suspend'; + + btn.textContent = `${capitalizeFirst(oppositeAction)} ${username}`; + btn.dataset.modalTitle = `${capitalizeFirst(oppositeAction)} ${username}`; + btn.id = `${oppositeAction}-user-btn`; + btn.dataset.modalContentSelector = `#${oppositeAction}-modal-content`; + + oppositeAction == 'suspend' + ? btn.classList.add('c-btn--destructive') + : btn.classList.remove('c-btn--destructive'); +} + +function closeModal() { + closeWindowModal(window.parent.document); +} + +// let modalContents; +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 + */ +const 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 checkReason(event) { + const { btnAction, reasonSelector, userId, username } = event.target.dataset; + const modal = window.parent.document.getElementById(WINDOW_MODAL_ID); + const actionReason = modal.querySelector(reasonSelector).value; + + if (!actionReason) { + modal.querySelector(`.${btnAction}-reason-error`).innerText = + 'You must give a reason for this action.'; + } else { + suspendOrUnsuspendUser({ + event, + btnAction, + userId, + username, + actionReason, + }); + } +} + +function activateModalSubmitBtn() { + const suspendBtn = window.parent.document.getElementById( + 'submit-user-suspend-btn', + ); + const unsuspendBtn = window.parent.document.getElementById( + 'submit-user-unsuspend-btn', + ); + + suspendBtn?.addEventListener('click', checkReason); + unsuspendBtn?.addEventListener('click', checkReason); +} + +export function toggleModal(event) { + event.preventDefault; + const { modalTitle, modalSize, modalContentSelector } = event.target.dataset; + showWindowModal({ + document: window.parent.document, + modalContent: getModalContents(modalContentSelector), + title: modalTitle, + size: modalSize, + onOpen: activateModalSubmitBtn, + }); +} diff --git a/app/javascript/shared/components/focusTrap.jsx b/app/javascript/shared/components/focusTrap.jsx index 42473f8ca..04d049891 100644 --- a/app/javascript/shared/components/focusTrap.jsx +++ b/app/javascript/shared/components/focusTrap.jsx @@ -29,6 +29,7 @@ export const FocusTrap = ({ children, onDeactivate, clickOutsideDeactivates = false, + document = window.document, }) => { const focusTrap = useRef(null); const deactivate = useCallback(() => onDeactivate(), [onDeactivate]); @@ -51,6 +52,7 @@ export const FocusTrap = ({ escapeDeactivates: false, clickOutsideDeactivates, onDeactivate: deactivate, + document, }); focusTrap.current.activate(); @@ -62,7 +64,7 @@ export const FocusTrap = ({ focusTrap.current.deactivate(); routeChangeObserver.disconnect(); }; - }, [clickOutsideDeactivates, selector, deactivate]); + }, [clickOutsideDeactivates, selector, deactivate, document]); const shortcuts = { escape: onDeactivate, diff --git a/app/javascript/utilities/showModal.jsx b/app/javascript/utilities/showModal.jsx index 26d074437..e1693e76d 100644 --- a/app/javascript/utilities/showModal.jsx +++ b/app/javascript/utilities/showModal.jsx @@ -32,11 +32,13 @@ const getModalImports = () => { * @param {HTMLElement} args.modalContent The HTML to display inside of the modal * @param {string} args.contentSelector The CSS query to locate the HTML to be presented in the modal, as an alternative to passing the actual HTML (e.g. '#my-modal-content') * @param {Function} args.onOpen A callback function to run when the modal opens. This can be useful, for example, to attach any event listeners to items inside the modal. + * @param {Object} args.document Allows us to specify which "document" is relevant; e.g. use within iframes. */ export const showWindowModal = async ({ modalContent, contentSelector, onOpen, + document = window.document, ...modalProps }) => { const [{ Modal }, { render, h }] = await getModalImports(); @@ -57,6 +59,7 @@ export const showWindowModal = async ({ render(null, currentModalContainer); }} focusTrapSelector={`#${WINDOW_MODAL_ID}`} + document={document} {...modalProps} >
{ +export const closeWindowModal = async (document = window.document) => { const currentModalContainer = document.getElementById(WINDOW_MODAL_ID); if (currentModalContainer) { const { render } = await getPreactImport(); diff --git a/app/policies/article_policy.rb b/app/policies/article_policy.rb index 340ba84f6..0284832cf 100644 --- a/app/policies/article_policy.rb +++ b/app/policies/article_policy.rb @@ -149,9 +149,10 @@ class ArticlePolicy < ApplicationPolicy user_any_admin? || user_moderator? end - # this method performs the same checks that determine - # if the record can be featured or if user can adjust - # any tag + # this method performs the same checks that determine: + # if the record can be featured + # if user can adjust any tag + # if user can perform moderator actions def revoke_publication? require_user! return false unless @record.published? @@ -211,6 +212,8 @@ class ArticlePolicy < ApplicationPolicy alias can_adjust_any_tag? revoke_publication? + alias can_perform_moderator_actions? revoke_publication? + # Due to the associated controller method "admin_unpublish", we # alias "admin_ubpublish" to the "revoke_publication" method. alias admin_unpublish? revoke_publication? diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index e6caa1b9e..4d1c7d30e 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -50,6 +50,9 @@ class UserPolicy < ApplicationPolicy current_user? end + alias remove_identity? edit? + alias update_password? edit? + # The analytics? policy method is also on the OrganizationPolicy. This exists specifically to allow for # "duck-typing" on the tests. alias analytics? edit? @@ -82,8 +85,6 @@ class UserPolicy < ApplicationPolicy OrganizationMembership.exists?(user_id: user.id, organization_id: record.id) end - alias remove_identity? edit? - def dashboard_show? current_user? || user_super_admin? || user_any_admin? end @@ -92,12 +93,12 @@ class UserPolicy < ApplicationPolicy user_any_admin? || user_moderator? end + alias toggle_suspension_status? elevated_user? + def moderation_routes? (user.has_trusted_role? || elevated_user?) && !user.suspended? end - alias update_password? edit? - def permitted_attributes PERMITTED_ATTRIBUTES end diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index 7aadd2e09..1242d0fec 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -220,6 +220,8 @@ +<%= render "moderations/suspend_user_modal" %> +<%= render "moderations/unsuspend_user_modal" %>
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %> diff --git a/app/views/moderations/_suspend_user_modal.html.erb b/app/views/moderations/_suspend_user_modal.html.erb new file mode 100644 index 000000000..6e4931840 --- /dev/null +++ b/app/views/moderations/_suspend_user_modal.html.erb @@ -0,0 +1,41 @@ +<% user_id = @article.user.id %> +<% author_username = @article.username %> + diff --git a/app/views/moderations/_unsuspend_user_modal.html.erb b/app/views/moderations/_unsuspend_user_modal.html.erb new file mode 100644 index 000000000..91f9d5342 --- /dev/null +++ b/app/views/moderations/_unsuspend_user_modal.html.erb @@ -0,0 +1,41 @@ +<% user_id = @article.user.id %> +<% author_username = @article.username %> + diff --git a/app/views/moderations/actions_panel.html.erb b/app/views/moderations/actions_panel.html.erb index bcc91a903..a8de9f235 100644 --- a/app/views/moderations/actions_panel.html.erb +++ b/app/views/moderations/actions_panel.html.erb @@ -8,6 +8,9 @@ <%= javascript_packs_with_chunks_tag "actionsPanel", defer: true %> +<% user_suspended = @moderatable.user.suspended? %> +<% user_username = @moderatable.username %> +

<%= t("views.moderations.actions.heading") %>

@@ -61,7 +64,7 @@ id="feature-article-btn" data-article-featured="<%= @moderatable.featured %>" data-article-id="<%= @moderatable.id %>" - data-article-author="<%= @moderatable.username %>" + data-article-author="<%= user_username %>" data-article-slug="<%= @moderatable.slug %>"> <%= @moderatable.featured ? t("views.moderations.actions.unfeature") : t("views.moderations.actions.feature") %> @@ -183,22 +186,32 @@
- <%# Hiding this Admin Actions panel until Flag User, Unpublish All Posts and Suspend User are moved into it %> - <% if current_user.any_admin? %> - + + <% end %>
diff --git a/config/locales/controllers/admin/en.yml b/config/locales/controllers/admin/en.yml index a9266118f..bd7d7f3ce 100644 --- a/config/locales/controllers/admin/en.yml +++ b/config/locales/controllers/admin/en.yml @@ -20,9 +20,9 @@ en: updated: Broadcast has been updated! wrong: Something went wrong with deleting the broadcast. consumer_apps_controller: - created: "%{app} has been created!" - deleted: "%{app} has been deleted!" - updated: "%{app} has been updated!" + created: '%{app} has been created!' + deleted: '%{app} has been deleted!' + updated: '%{app} has been updated!' wrong: Something went wrong with deleting %{app}. display_ads_controller: created: Display Ad has been created! @@ -30,11 +30,11 @@ en: updated: Display Ad has been updated! wrong: Something went wrong with deleting the Display Ad. gdpr_delete_requests_controller: - deleted: Successfully marked as deleted + deleted: Successfully marked as deleted extensions_controller: update_success: Extensions have been updated. html_variants_controller: - fork: "%{name} FORK-%{rand}" + fork: '%{name} FORK-%{rand}' created: HTML Variant has been created! deleted: HTML Variant has been deleted! updated: HTML Variant has been updated! @@ -53,7 +53,7 @@ en: destroyed: "'%{title}' was destroyed successfully" no_credit: Not enough available credits mods_controller: - trusted: "%{username} now has a Trusted role!" + trusted: '%{username} now has a Trusted role!' navigation_links_controller: deleted: Navigation Link %{link} deleted created: 'Successfully created navigation link: %{link}' @@ -99,7 +99,7 @@ en: secrets_controller: not_in_vault: Not In Vault updated: Secret %{key} was successfully updated in Vault. - value: "%{first8}******" + value: '%{first8}******' settings_controller: confirmation: My username is @%{username} and this action is 100% safe and appropriate. spaces_controller: @@ -111,24 +111,24 @@ en: destroyed: Sponsorship was successfully destroyed tags: moderators_controller: - not_found: "User ID #%{user_id} was not found" + not_found: 'User ID #%{user_id} was not found' not_found_or: 'User ID #%{user_id} was not found, or their account has errors: %{errors}' - removed: "@%{username} - ID #%{user_id} was removed as a tag moderator." - added: "%{username} was added as a tag moderator!" + removed: '@%{username} - ID #%{user_id} was removed as a tag moderator.' + added: '%{username} was added as a tag moderator!' tags_controller: - created: "%{tag_name} has been created!" - updated: "%{tag_name} tag successfully updated!" + created: '%{tag_name} has been created!' + updated: '%{tag_name} tag successfully updated!' update_fail: 'The tag update failed: %{errors}' tools_controller: article_busted: 'Article #%{article} was successfully busted' user_busted: 'User #%{user} was successfully busted' - link_busted: "%{link} was successfully busted" + link_busted: '%{link} was successfully busted' users_controller: exported: Data exported to the %{receiver}. The job will complete momentarily. email_fail: Email failed to send! email_sent: Email sent! verify_sent: Verification email sent! - full_delete_html: "@%{user} (email: %{email}, user_id: %{id}) has been fully deleted. If this is a GDPR delete, delete them from Mailchimp & Google Analytics and confirm on %{the_page}." + full_delete_html: '@%{user} (email: %{email}, user_id: %{id}) has been fully deleted. If this is a GDPR delete, delete them from Mailchimp & Google Analytics and confirm on %{the_page}.' no_email: no email parameter_missing: Both subject and body are required! role_removed: 'Role: %{role} has been successfully removed from the user!' @@ -138,6 +138,7 @@ en: unlocked: Unlocked User account! unpublished: Posts are being unpublished in the background. The job will complete soon. updated: User has been updated + updated_json: Success! %{username} has been updated. credits_added: Credits have been added! credits_removed: Credits have been removed. welcome_controller: diff --git a/config/locales/controllers/admin/fr.yml b/config/locales/controllers/admin/fr.yml index df57c48b7..e3836065b 100644 --- a/config/locales/controllers/admin/fr.yml +++ b/config/locales/controllers/admin/fr.yml @@ -20,9 +20,9 @@ fr: updated: Broadcast has been updated! wrong: Something went wrong with deleting the broadcast. consumer_apps_controller: - created: "%{app} has been created!" - deleted: "%{app} has been deleted!" - updated: "%{app} has been updated!" + created: '%{app} has been created!' + deleted: '%{app} has been deleted!' + updated: '%{app} has been updated!' wrong: Something went wrong with deleting %{app}. display_ads_controller: created: Display Ad has been created! @@ -32,9 +32,9 @@ fr: extensions_controller: update_success: Les extensions ont ÊtÊ mises à jour. gdpr_delete_requests_controller: - deleted: Successfully marked as deleted + deleted: Successfully marked as deleted html_variants_controller: - fork: "%{name} FORK-%{rand}" + fork: '%{name} FORK-%{rand}' created: HTML Variant has been created! deleted: HTML Variant has been deleted! updated: HTML Variant has been updated! @@ -53,7 +53,7 @@ fr: destroyed: "'%{title}' was destroyed successfully" no_credit: Not enough available credits mods_controller: - trusted: "%{username} now has a Trusted role!" + trusted: '%{username} now has a Trusted role!' navigation_links_controller: deleted: Navigation Link %{link} deleted created: 'Successfully created navigation link: %{link}' @@ -99,7 +99,7 @@ fr: secrets_controller: not_in_vault: Not In Vault updated: Secret %{key} was successfully updated in Vault. - value: "%{first8}******" + value: '%{first8}******' settings_controller: confirmation: My username is @%{username} and this action is 100% safe and appropriate. spaces_controller: @@ -111,24 +111,24 @@ fr: destroyed: Sponsorship was successfully destroyed tags: moderators_controller: - not_found: "User ID #%{user_id} was not found" + not_found: 'User ID #%{user_id} was not found' not_found_or: 'User ID #%{user_id} was not found, or their account has errors: %{errors}' - removed: "@%{username} - ID #%{user_id} was removed as a tag moderator." - added: "%{username} was added as a tag moderator!" + removed: '@%{username} - ID #%{user_id} was removed as a tag moderator.' + added: '%{username} was added as a tag moderator!' tags_controller: - created: "%{tag_name} has been created!" - updated: "%{tag_name} tag successfully updated!" + created: '%{tag_name} has been created!' + updated: '%{tag_name} tag successfully updated!' update_fail: 'The tag update failed: %{errors}' tools_controller: article_busted: 'Article #%{article} was successfully busted' user_busted: 'User #%{user} was successfully busted' - link_busted: "%{link} was successfully busted" + link_busted: '%{link} was successfully busted' users_controller: exported: Data exported to the %{receiver}. The job will complete momentarily. email_fail: Email failed to send! email_sent: Email sent! verify_sent: Verification email sent! - full_delete_html: "@%{user} (email: %{email}, user_id: %{id}) has been fully deleted. If this is a GDPR delete, delete them from Mailchimp & Google Analytics and confirm on %{the_page}." + full_delete_html: '@%{user} (email: %{email}, user_id: %{id}) has been fully deleted. If this is a GDPR delete, delete them from Mailchimp & Google Analytics and confirm on %{the_page}.' no_email: no email parameter_missing: Both subject and body are required! role_removed: 'Role: %{role} has been successfully removed from the user!' @@ -138,6 +138,7 @@ fr: unlocked: Unlocked User account! unpublished: Posts are being unpublished in the background. The job will complete soon. updated: User has been updated + updated_json: Success! %{username} has been updated. credits_added: Credits have been added! credits_removed: Credits have been removed. welcome_controller: diff --git a/config/locales/views/moderations/en.yml b/config/locales/views/moderations/en.yml index a34e122f5..7d6dbaaf4 100644 --- a/config/locales/views/moderations/en.yml +++ b/config/locales/views/moderations/en.yml @@ -6,7 +6,7 @@ en: actions: meta: title: Moderating %{title} - title2: "[Moderate] %{title}" + title2: '[Moderate] %{title}' heading: Moderate Post subtitle: Rate the quality of this post abusive: @@ -38,7 +38,7 @@ en: resource_admin: ResourceAdmin:Article errors: one: '1 error prohibited this block from being saved:' - other: "%{count} errors prohibited this block from being saved:" + other: '%{count} errors prohibited this block from being saved:' experience: heading: Set experience level heading2: Experience Level of Post @@ -46,7 +46,7 @@ en: aria_label: Open experience level section icon: Book desc_html: Who might find this post most valuable, based on overall experience level? - from: "%{lvl} - " + from: '%{lvl} - ' level: Advanced: Advanced Beginner: Beginner @@ -54,9 +54,9 @@ en: Mid-level: Mid-level Novice: Novice admin: - heading: Admin actions - heading2: Higher permission post options - subtitle: Higher permission post options + heading: Moderating actions + heading2: Moderating actions for %{user} + subtitle: Moderating actions for %{user} aria_label: Open admin actions icon: Crown flag: Flag user @@ -74,20 +74,27 @@ en: Use Flag to Admins for code of conduct violations (harassment, being a jerk, spam, etc.). other: Other things you can do add_reaction: Add a reaction + suspend: + suspend_user: Suspend %{username} + suspend_modal_text: Once suspended, %{username} will not be able to comment or publish posts until their suspension is removed. + suspend_modal_button: Submit & Suspend + unsuspend_user: Unsuspend %{username} + unsuspend_modal_text: Once unsuspended, %{username} will be able to comment and publish posts again. + unsuspend_modal_button: Submit & Unsuspend suspicious: Suspicious tag: subtitle: Tag Adjustments add: Add - added_html: "Currently added tag: %{tag}" - live_html: "Current live tags: %{tags}" + added_html: 'Currently added tag: %{tag}' + live_html: 'Current live tags: %{tags}' reason: Reason for adjustment (Be super kind) - Only the reason is needed, the notification will take care of the rest. remove: Remove - removed_html: "Currently removed tag: %{tag}" + removed_html: 'Currently removed tag: %{tag}' select: Select Tag submit: Submit Tag Adjustment tag_name: Tag Name undo: - button: "×" + button: '×' confirm: Are you sure you want to undo the %{type} of the %{tag} tag? type: addition: addition @@ -102,8 +109,8 @@ en: flag_to_admins: Flag to Admins vote_up: High Quality featured_past_day: - one: "%{count} post from the past day is featured." - other: "%{count} posts from the past day are featured." + one: '%{count} post from the past day is featured.' + other: '%{count} posts from the past day are featured.' all: All topics aside: all: All topics @@ -112,7 +119,7 @@ en: feedback: subtitle: Have feedback to improve your Mod experience? description: Please email %{email}! - hello: "Hello! 👋" + hello: 'Hello! 👋' thanks: Thank you for helping to keep %{community} safe! â¤ī¸ inbox: Inbox resources: Resources @@ -122,7 +129,7 @@ en: author: Author date: Date notice: - subtitle: "%{community} Mods" + subtitle: '%{community} Mods' desc1_html: We periodically award some %{community} members with heightened privileges to help moderate the community. desc2_html: Check out our %{code} and read through our %{trusted} and %{tag}. desc3_html: If you'd like to assist us as a trusted user or tag mod, please email us at %{email} and let us know which role you're interested in and why. If it's tag moderation, please tell us what tags you'd like to moderate for. diff --git a/config/locales/views/moderations/fr.yml b/config/locales/views/moderations/fr.yml index f340c4626..4d4b5f26c 100644 --- a/config/locales/views/moderations/fr.yml +++ b/config/locales/views/moderations/fr.yml @@ -6,7 +6,7 @@ fr: actions: meta: title: Moderating %{title} - title2: "[Moderate] %{title}" + title2: '[Moderate] %{title}' heading: Moderate Post subtitle: Rate the quality of this post abusive: @@ -38,7 +38,7 @@ fr: resource_admin: ResourceAdmin:Article errors: one: '1 error prohibited this block from being saved:' - other: "%{count} errors prohibited this block from being saved:" + other: '%{count} errors prohibited this block from being saved:' experience: heading: Set experience level heading2: Experience Level of Post @@ -46,7 +46,7 @@ fr: aria_label: Open experience level section icon: Book desc_html: Who might find this post most valuable, based on overall experience level? - from: "%{lvl} - " + from: '%{lvl} - ' level: Advanced: Advanced Beginner: Beginner @@ -54,9 +54,9 @@ fr: Mid-level: Mid-level Novice: Novice admin: - heading: Admin actions - heading2: Higher permission post options - subtitle: Higher permission post options + heading: Moderating actions + heading2: Moderating actions to %{user} + subtitle: Moderating actions to %{user} aria_label: Open admin actions icon: Crown flag: Flag user @@ -73,20 +73,27 @@ fr:
Use Flag to Admins for code of conduct violations (harassment, being a jerk, spam, etc.). other: Other things you can do + suspend: + suspend_user: Suspend %{username} + suspend_modal_text: Once suspended, %{username} will not be able to comment or publish posts until their suspension is removed. + suspend_modal_button: Submit & Suspend + unsuspend_user: Unsuspend %{username} + unsuspend_modal_text: Once unsuspended, %{username} will be able to comment and publish posts again. + unsuspend_modal_button: Submit & Unsuspend suspicious: Suspicious tag: subtitle: Tag Adjustments add: Add - added_html: "Currently added tag: %{tag}" - live_html: "Current live tags: %{tags}" + added_html: 'Currently added tag: %{tag}' + live_html: 'Current live tags: %{tags}' reason: Reason for adjustment (Be super kind) - Only the reason is needed, the notification will take care of the rest. remove: Remove - removed_html: "Currently removed tag: %{tag}" + removed_html: 'Currently removed tag: %{tag}' select: Select Tag submit: Submit Tag Adjustment tag_name: Tag Name undo: - button: "×" + button: '×' confirm: Are you sure you want to undo the %{type} of the %{tag} tag? type: addition: addition @@ -101,8 +108,8 @@ fr: flag_to_admins: Flag to Admins vote_up: High Quality featured_past_day: - one: "%{count} post from the past day is featured." - other: "%{count} posts from the past day are featured." + one: '%{count} post from the past day is featured.' + other: '%{count} posts from the past day are featured.' all: All topics aside: all: All topics @@ -111,7 +118,7 @@ fr: feedback: subtitle: Have feedback to improve your Mod experience? description: Please email %{email}! - hello: "Hello! 👋" + hello: 'Hello! 👋' thanks: Thank you for helping to keep %{community} safe! â¤ī¸ inbox: Inbox resources: Resources @@ -121,7 +128,7 @@ fr: author: Author date: Date notice: - subtitle: "%{community} Mods" + subtitle: '%{community} Mods' desc1_html: We periodically award some %{community} members with heightened privileges to help moderate the community. desc2_html: Check out our %{code} and read through our %{trusted} and %{tag}. desc3_html: If you'd like to assist us as a trusted user or tag mod, please email us at %{email} and let us know which role you're interested in and why. If it's tag moderation, please tell us what tags you'd like to moderate for. diff --git a/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js b/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js index 3d2213dfd..17cf5e775 100644 --- a/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js +++ b/cypress/integration/seededFlows/articleFlows/postModerationTools.spec.js @@ -50,7 +50,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should show Feature Post button on an unfeatured post for an admin user', () => { + it('should show Feature Post button on an unfeatured post', () => { cy.get('@adminUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/unfeatured-article-slug').then( () => { @@ -68,7 +68,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should show Unfeature Post button on a featured post for an admin user', () => { + it('should show Unfeature Post button on a featured post', () => { cy.get('@adminUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => { cy.findByRole('button', { name: 'Moderation' }).click(); @@ -80,7 +80,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should show Unpublish Post button on a published post for an admin user', () => { + it('should show Unpublish Post button on a published post', () => { cy.get('@adminUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => { cy.findByRole('button', { name: 'Moderation' }).click(); @@ -93,12 +93,12 @@ describe('Moderation Tools for Posts', () => { }); }); - context('as moderator user', () => { + describe('moderator user', () => { beforeEach(() => { cy.fixture('users/moderatorUser.json').as('moderatorUser'); }); - it('should load moderation tools on a post for a moderator user', () => { + it('should load moderation tools on a post', () => { cy.get('@moderatorUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => { cy.findByRole('button', { name: 'Moderation' }).should('exist'); @@ -106,7 +106,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should not show Feature Post button on a post for a moderator user', () => { + it('should not show Feature Post button on a post', () => { cy.get('@moderatorUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/unfeatured-article-slug').then( () => { @@ -124,7 +124,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should show Unpublish Post button on a published post for a moderator user', () => { + it('should show Unpublish Post button on a published post', () => { cy.get('@moderatorUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => { cy.findByRole('button', { name: 'Moderation' }).click(); @@ -136,7 +136,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should show Adjust tags button on a published post for a moderator user', () => { + it('should show Adjust tags button on a published post', () => { cy.get('@moderatorUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => { cy.findByRole('button', { name: 'Moderation' }).click(); @@ -151,6 +151,157 @@ describe('Moderation Tools for Posts', () => { }); }); }); + + context('when suspending user', () => { + 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('should show Suspend User button', () => { + 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: 'Suspend series_user', + }).should('exist'); + }); + }); + + it('should not suspend the user when no reason given', () => { + 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: 'Suspend series_user', + }).click(); + }); + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Submit & Suspend' }).click(); + + cy.findByTestId('suspension-reason-error') + .contains('You must give a reason for this action.') + .should('exist'); + }); + }); + + it('should suspend the user when suspension reason given', () => { + 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: 'Suspend series_user', + }).click(); + }); + cy.findByRole('dialog').within(() => { + cy.findByRole('textbox', { name: 'Note:' }).type( + 'My suspension reason', + ); + + cy.findByRole('button', { name: 'Submit & Suspend' }).click(); + }); + + cy.findByTestId('snackbar') + .contains('Success! series_user has been updated.') + .should('exist'); + + 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: 'Suspend series_user', + }).should('not.exist'); + + cy.findByRole('button', { + name: 'Unsuspend series_user', + }).should('exist'); + }); + }); + }); + + context('when unsuspending user', () => { + beforeEach(() => { + cy.get('@moderatorUser').then((user) => { + cy.loginAndVisit(user, '/suspended_user/suspended-user-article-slug'); + cy.findByRole('heading', { + level: 1, + name: 'Suspended user article', + }); + cy.findByRole('button', { name: 'Moderation' }).click(); + }); + }); + + it('should not unsuspend the user when no reason given', () => { + 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: 'Unsuspend suspended_user', + }).click(); + }); + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Submit & Unsuspend' }).click(); + cy.findByTestId('unsuspension-reason-error') + .contains('You must give a reason for this action.') + .should('exist'); + }); + }); + + it('should unsuspend the user when reason given', () => { + 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: 'Unsuspend suspended_user', + }).click(); + }); + + cy.findByRole('dialog').within(() => { + cy.findByRole('textbox', { name: 'Note:' }).type( + 'My unsuspension reason', + ); + cy.findByRole('button', { name: 'Submit & Unsuspend' }).click(); + }); + + cy.findByTestId('snackbar') + .contains('Success! suspended_user has been updated.') + .should('exist'); + + 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: 'Unsuspend suspended_user', + }).should('not.exist'); + + cy.findByRole('button', { + name: 'Suspend suspended_user', + }).should('exist'); + }); + }); + }); }); context('as trusted user', () => { @@ -158,7 +309,7 @@ describe('Moderation Tools for Posts', () => { cy.fixture('users/trustedUser.json').as('trustedUser'); }); - it('should load moderation tools on a post for a trusted user', () => { + it('should load moderation tools on a post', () => { cy.get('@trustedUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => { cy.findByRole('button', { name: 'Moderation' }).should('exist'); @@ -166,7 +317,7 @@ describe('Moderation Tools for Posts', () => { }); }); - it('should not show Feature Post button on a post for a trusted user', () => { + it('should not show Feature Post button on a post', () => { cy.get('@trustedUser').then((user) => { cy.loginAndVisit(user, '/admin_mcadmin/unfeatured-article-slug').then( () => { diff --git a/spec/policies/article_policy_spec.rb b/spec/policies/article_policy_spec.rb index 21bbd2d3f..aeed9906b 100644 --- a/spec/policies/article_policy_spec.rb +++ b/spec/policies/article_policy_spec.rb @@ -230,7 +230,7 @@ RSpec.describe ArticlePolicy do end %i[admin_unpublish? admin_featured_toggle? revoke_publication? toggle_featured_status? - can_adjust_any_tag?].each do |method_name| + can_adjust_any_tag? can_perform_moderator_actions?].each do |method_name| describe "##{method_name}" do let(:policy_method) { method_name } diff --git a/spec/support/seeds/seeds_e2e.rb b/spec/support/seeds/seeds_e2e.rb index af719593c..9daaf3f21 100644 --- a/spec/support/seeds/seeds_e2e.rb +++ b/spec/support/seeds/seeds_e2e.rb @@ -722,6 +722,48 @@ end ############################################################################## +seeder.create_if_doesnt_exist(User, "email", "suspended-user@forem.local") do + suspended_user = User.create!( + name: "Suspended User", + email: "suspended-user@forem.local", + username: "suspended_user", + profile_image: File.open(Rails.root.join("app/assets/images/#{rand(1..40)}.png")), + confirmed_at: Time.current, + registered_at: Time.current, + password: "password", + password_confirmation: "password", + saw_onboarding: true, + checked_code_of_conduct: true, + checked_terms_and_conditions: true, + ) + + suspended_user.add_role(:suspended) +end + +############################################################################## + +seeder.create_if_doesnt_exist(Article, "title", "Suspended user article") do + markdown = <<~MARKDOWN + --- + title: Suspended user article + published: true + cover_image: #{Faker::Company.logo} + --- + #{Faker::Hipster.paragraph(sentence_count: 2)} + #{Faker::Markdown.random} + #{Faker::Hipster.paragraph(sentence_count: 2)} + MARKDOWN + Article.create( + body_markdown: markdown, + featured: false, + show_comments: true, + slug: "suspended-user-article-slug", + user_id: User.find_by(email: "suspended-user@forem.local").id, + ) +end + +############################################################################## + seeder.create_if_doesnt_exist(Article, "title", "Series test article") do markdown = <<~MARKDOWN --- @@ -738,6 +780,7 @@ seeder.create_if_doesnt_exist(Article, "title", "Series test article") do body_markdown: markdown, featured: true, show_comments: true, + slug: "series-test-article-slug", user_id: User.find_by(email: "series-user@forem.local").id, ) end