Allow ModRole & Admin to Suspend & Unsuspend User (#17946)

* 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
This commit is contained in:
Arit Amana 2022-06-27 12:45:35 -04:00 committed by GitHub
parent 64d970abd2
commit 750d379807
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 588 additions and 91 deletions

View file

@ -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

View file

@ -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() {

View file

@ -31,5 +31,5 @@ export const BasicEditor = ({ openModal }) => (
);
BasicEditor.propTypes = {
toggleModal: PropTypes.func.isRequired,
openModal: PropTypes.func.isRequired,
};

View file

@ -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}
>
<div data-testid="modal-container" className={classes}>
<div

View file

@ -29,7 +29,7 @@ const handleConfirmDelete = (username, formAction) => {
// Update cancel button to close the modal
document
.querySelector(`#${WINDOW_MODAL_ID} .js-gdpr-cancel-deleted`)
.addEventListener('click', closeWindowModal);
.addEventListener('click', () => closeWindowModal());
},
});
};

View file

@ -52,7 +52,7 @@ window.Forem = {
);
},
showModal: showWindowModal,
closeModal: closeWindowModal,
closeModal: () => closeWindowModal(),
Runtime,
};

View file

@ -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,
});
}

View file

@ -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,

View file

@ -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}
>
<div
@ -77,7 +80,7 @@ export const showWindowModal = async ({
/**
* This helper function closes any currently open window modal. This can be useful, for example, if your modal contains a "cancel" button.
*/
export const closeWindowModal = async () => {
export const closeWindowModal = async (document = window.document) => {
const currentModalContainer = document.getElementById(WINDOW_MODAL_ID);
if (currentModalContainer) {
const { render } = await getPreactImport();

View file

@ -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?

View file

@ -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

View file

@ -220,6 +220,8 @@
<div class="mod-actions-menu print-hidden"></div>
<div data-testid="flag-user-modal-container" class="flag-user-modal-container hidden"></div>
<div data-testid="unpublish-post-modal-container" class="unpublish-post-modal-container hidden"></div>
<%= render "moderations/suspend_user_modal" %>
<%= render "moderations/unsuspend_user_modal" %>
<div class="fullscreen-code js-fullscreen-code"></div>
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>

View file

@ -0,0 +1,41 @@
<% user_id = @article.user.id %>
<% author_username = @article.username %>
<div id="suspend-modal-content" class="hidden">
<p>
<%= t("views.moderations.actions.suspend.suspend_modal_text", username: author_username) %>
</p>
<div class="crayons-field mt-4">
<label
for="suspend-reason-userid-<%= user_id %>"
class="crayons-field__label">
Note:
</label>
<textarea
id="suspend-reason-userid-<%= user_id %>"
rows="4"
cols="70"
placeholder="Reason for suspension"
class="crayons-textfield"
aria-describedby="suspension-reason-error"
required></textarea>
<p
id="suspension-reason-error"
data-testid="suspension-reason-error"
class="suspend-reason-error crayons-field__description"
style="color: red;"
aria-live="assertive">
</p>
</div>
<div class="mt-4">
<button
id="submit-user-suspend-btn"
class="c-btn c-btn--primary c-btn--destructive"
data-btn-action="suspend"
data-reason-selector="#suspend-reason-userid-<%= user_id %>"
data-user-id="<%= user_id %>"
data-username="<%= author_username %>">
<%= t("views.moderations.actions.suspend.suspend_modal_button", username: author_username) %>
</button>
</div>
</div>

View file

@ -0,0 +1,41 @@
<% user_id = @article.user.id %>
<% author_username = @article.username %>
<div id="unsuspend-modal-content" class="hidden">
<p>
<%= t("views.moderations.actions.suspend.unsuspend_modal_text", username: author_username) %>
</p>
<div class="crayons-field mt-4">
<label
for="unsuspend-reason-userid-<%= user_id %>"
class="crayons-field__label">
Note:
</label>
<textarea
id="unsuspend-reason-userid-<%= user_id %>"
rows="4"
cols="70"
placeholder="Reason for unsuspension"
class="crayons-textfield"
aria-describedby="unsuspension-reason-error"
required></textarea>
<p
id="unsuspension-reason-error"
data-testid="unsuspension-reason-error"
class="unsuspend-reason-error crayons-field__description"
style="color: red;"
aria-live="assertive">
</p>
</div>
<div class="mt-4">
<button
id="submit-user-unsuspend-btn"
class="c-btn c-btn--primary"
data-btn-action="unsuspend"
data-reason-selector="#unsuspend-reason-userid-<%= user_id %>"
data-user-id="<%= user_id %>"
data-username="<%= author_username %>">
<%= t("views.moderations.actions.suspend.unsuspend_modal_button", username: author_username) %>
</button>
</div>
</div>

View file

@ -8,6 +8,9 @@
<%= javascript_packs_with_chunks_tag "actionsPanel", defer: true %>
<% user_suspended = @moderatable.user.suspended? %>
<% user_username = @moderatable.username %>
<div class="container mod-container">
<header class="top-header">
<h1><%= t("views.moderations.actions.heading") %></h1>
@ -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") %>
</button>
@ -183,22 +186,32 @@
</a>
</div>
<%# Hiding this Admin Actions panel until Flag User, Unpublish All Posts and Suspend User are moved into it %>
<% if current_user.any_admin? %>
<button aria-haspopup="true" aria-expanded="false" aria-controls="admin-action-options" class="other-things-btn admin-action hidden" type="button" data-other-things-type="admin-action" aria-label="<%= t("views.moderations.actions.admin.aria_label") %>">
<% if policy(@moderatable).can_perform_moderator_actions? %>
<button aria-haspopup="true" aria-expanded="false" aria-controls="admin-action-options" class="other-things-btn admin-action" type="button" data-other-things-type="admin-action" aria-label="<%= t("views.moderations.actions.admin.aria_label") %>">
<div class="label-wrapper">
<div class="icon circle centered-icon">
<%= inline_svg_tag("admin.svg", aria_hidden: true, title: t("views.moderations.actions.admin.icon")) %>
</div>
<header>
<h2><%= t("views.moderations.actions.admin.heading") %></h2>
<h3><%= t("views.moderations.actions.admin.subtitle") %></h3>
<h3><%= t("views.moderations.actions.admin.subtitle", user: user_username) %></h3>
</header>
</div>
<div class="toggle-chevron-container">
<%= inline_svg_tag("arrow-down-fill.svg", aria: true, title: t("views.moderations.actions.toggle")) %>
</div>
</button>
<div id="admin-action-options" class="admin-action-options dropdown-options mt-4 hidden">
<button
type="button"
id="<%= user_suspended ? "unsuspend" : "suspend" %>-user-btn"
class="js-suspend-unsuspend-flow-btn c-btn <%= user_suspended ? "" : "c-btn--destructive" %> align-center fs-s"
data-modal-title="<%= user_suspended ? "Unsuspend" : "Suspend" %> <%= user_username %>"
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) %>
</button>
</div>
<% end %>
</div>

View file

@ -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:

View file

@ -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:

View file

@ -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 <em>might</em> 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 <b>Flag to Admins</b> 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: "<b>Currently added tag:</b> %{tag}"
live_html: "<b>Current live tags:</b> %{tags}"
added_html: '<b>Currently added tag:</b> %{tag}'
live_html: '<b>Current live tags:</b> %{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: "<b>Currently removed tag:</b> %{tag}"
removed_html: '<b>Currently removed tag:</b> %{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.

View file

@ -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 <em>might</em> 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:
<br />
Use <b>Flag to Admins</b> 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: "<b>Currently added tag:</b> %{tag}"
live_html: "<b>Current live tags:</b> %{tags}"
added_html: '<b>Currently added tag:</b> %{tag}'
live_html: '<b>Current live tags:</b> %{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: "<b>Currently removed tag:</b> %{tag}"
removed_html: '<b>Currently removed tag:</b> %{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.

View file

@ -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(
() => {

View file

@ -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 }

View file

@ -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