Allow Moderator Role to Unpublish Single Post (#17795)
* relocate btn; build functionality for admin only * remove commented code * add explanatory comment * refactor * build unpublish and feature-post policies * mod role in policies fails specs 😓 * fix failing specs * add end-to-end tests * fix hardcoded name in modal 😓
This commit is contained in:
parent
3fb085b3d9
commit
1679fbf47d
14 changed files with 273 additions and 70 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { toggleFlagUserModal } from '../packs/flagUserModal';
|
||||
import { toggleUnpublishPostModal } from '../packs/unpublishPostModal';
|
||||
import { request } from '@utilities/http';
|
||||
|
||||
export function addCloseListener() {
|
||||
|
|
@ -149,33 +150,6 @@ export async function updateExperienceLevel(
|
|||
}
|
||||
}
|
||||
|
||||
const adminUnpublishArticle = async (id, username, slug) => {
|
||||
try {
|
||||
const response = await request(`/articles/${id}/admin_unpublish`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ id, username, slug }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
if (outcome.message == 'success') {
|
||||
window.top.location.assign(`${window.location.origin}${outcome.path}`);
|
||||
} else {
|
||||
top.addSnackbarItem({
|
||||
message: `Error: ${outcome.message}`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
top.addSnackbarItem({
|
||||
message: `Error: ${error}`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const adminFeatureArticle = async (id, featured) => {
|
||||
try {
|
||||
const response = await request(`/articles/${id}/admin_featured_toggle`, {
|
||||
|
|
@ -415,17 +389,7 @@ export function addBottomActionsListeners() {
|
|||
|
||||
const unpublishArticleBtn = document.getElementById('unpublish-article-btn');
|
||||
if (unpublishArticleBtn) {
|
||||
unpublishArticleBtn.addEventListener('click', () => {
|
||||
const {
|
||||
articleId: id,
|
||||
articleAuthor: username,
|
||||
articleSlug: slug,
|
||||
} = unpublishArticleBtn.dataset;
|
||||
|
||||
if (confirm('You are unpublishing this post; are you sure?')) {
|
||||
adminUnpublishArticle(id, username, slug);
|
||||
}
|
||||
});
|
||||
unpublishArticleBtn.addEventListener('click', toggleUnpublishPostModal);
|
||||
}
|
||||
|
||||
document
|
||||
|
|
|
|||
|
|
@ -6,13 +6,23 @@ getCsrfToken().then(() => {
|
|||
|
||||
if (articleContainer) {
|
||||
const user = userData();
|
||||
const { authorId: articleAuthorId, path } = articleContainer.dataset;
|
||||
const {
|
||||
articleId,
|
||||
articleSlug,
|
||||
authorId: articleAuthorId,
|
||||
authorName,
|
||||
authorUsername,
|
||||
path,
|
||||
} = articleContainer.dataset;
|
||||
|
||||
const initializeModerationsTools = async () => {
|
||||
const { initializeActionsPanel } = await import(
|
||||
'../actionsPanel/initializeActionsPanelToggle'
|
||||
);
|
||||
const { initializeFlagUserModal } = await import('./flagUserModal');
|
||||
const { initializeUnpublishPostModal } = await import(
|
||||
'./unpublishPostModal.jsx'
|
||||
);
|
||||
|
||||
// If the user can moderate an article give them access to this panel.
|
||||
// Note: this assumes we're within the article context (which is the case
|
||||
|
|
@ -31,10 +41,17 @@ getCsrfToken().then(() => {
|
|||
if (user?.id !== articleAuthorId && !isModerationPage()) {
|
||||
initializeActionsPanel(user, path);
|
||||
initializeFlagUserModal(articleAuthorId);
|
||||
initializeUnpublishPostModal(
|
||||
articleId,
|
||||
authorName,
|
||||
authorUsername,
|
||||
articleSlug,
|
||||
);
|
||||
// "/mod" page
|
||||
} else if (isModerationPage()) {
|
||||
initializeActionsPanel(user, path);
|
||||
initializeFlagUserModal(articleAuthorId);
|
||||
initializeUnpublishPostModal(articleId, authorUsername, articleSlug);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
156
app/javascript/packs/unpublishPostModal.jsx
Normal file
156
app/javascript/packs/unpublishPostModal.jsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { h, render } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { request } from '../utilities/http';
|
||||
import { ButtonNew as Button } from '@crayons';
|
||||
import RemoveIcon from '@images/x.svg';
|
||||
|
||||
async function confirmAdminUnpublishPost(id, username, slug) {
|
||||
try {
|
||||
const response = await request(`/articles/${id}/admin_unpublish`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ id, username, slug }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
const outcome = await response.json();
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
if (outcome.message == 'success') {
|
||||
window.top.location.assign(`${window.location.origin}${outcome.path}`);
|
||||
} else {
|
||||
top.addSnackbarItem({
|
||||
message: `Error: ${outcome.message}`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
top.addSnackbarItem({
|
||||
message: `Error: ${error}`,
|
||||
addCloseButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
toggleUnpublishPostModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides the flag user modal.
|
||||
*/
|
||||
export function toggleUnpublishPostModal() {
|
||||
const modalContainer = top.document.getElementsByClassName(
|
||||
'unpublish-post-modal-container',
|
||||
)[0];
|
||||
modalContainer.classList.toggle('hidden');
|
||||
|
||||
if (!modalContainer.classList.contains('hidden')) {
|
||||
top.window.scrollTo(0, 0);
|
||||
top.document.body.style.height = '100vh';
|
||||
top.document.body.style.overflowY = 'hidden';
|
||||
} else {
|
||||
top.document.body.style.height = 'inherit';
|
||||
top.document.body.style.overflowY = 'inherit';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Unpublish Post modal for the given article ID, author username and article slug.
|
||||
*
|
||||
* @param {number} articleId
|
||||
* @param {string} authorUsername
|
||||
* @param {string} articleSlug
|
||||
*/
|
||||
export function initializeUnpublishPostModal(
|
||||
articleId,
|
||||
authorName,
|
||||
authorUsername,
|
||||
articleSlug,
|
||||
) {
|
||||
// Check whether context is ModCenter or Friday-Night-Mode
|
||||
const modContainer = document.getElementById('mod-container');
|
||||
|
||||
if (!modContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
render(
|
||||
<UnpublishPostModal
|
||||
articleId={articleId}
|
||||
authorName={authorName}
|
||||
authorUsername={authorUsername}
|
||||
articleSlug={articleSlug}
|
||||
/>,
|
||||
document.getElementsByClassName('unpublish-post-modal-container')[0],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal for unpublishing a post. This can be used in the moderation center
|
||||
* or on an article page.
|
||||
*
|
||||
* @param {number} props.articleId ID of the article to be unpublished.
|
||||
* @param {string} props.authorUsername Username of the article's author.
|
||||
* @param {string} props.articleSlug Slug of the article to be unpublished.
|
||||
*/
|
||||
export function UnpublishPostModal({
|
||||
articleId,
|
||||
authorName,
|
||||
authorUsername,
|
||||
articleSlug,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="unpublish-post-modal"
|
||||
class="crayons-modal crayons-modal--small absolute unpublish-post-modal"
|
||||
>
|
||||
<div class="crayons-modal__box">
|
||||
<header class="crayons-modal__box__header unpublish-post-modal-header">
|
||||
<h2 class="crayons-modal__box__header__title">Unpublish post</h2>
|
||||
<Button
|
||||
icon={RemoveIcon}
|
||||
className="inline-flex"
|
||||
onClick={toggleUnpublishPostModal}
|
||||
/>
|
||||
</header>
|
||||
<div class="crayons-modal__box__body">
|
||||
<div class="grid gap-4">
|
||||
<p>
|
||||
Once unpublished, this post will become invisible to the public
|
||||
and only accessible to {authorName}.
|
||||
</p>
|
||||
<p>They can still re-publish the post if they are not suspended.</p>
|
||||
<div>
|
||||
<Button
|
||||
destructive
|
||||
variant="primary"
|
||||
className="mr-2"
|
||||
id="confirm-unpublish-post-action"
|
||||
onClick={(_event) => {
|
||||
confirmAdminUnpublishPost(
|
||||
articleId,
|
||||
authorUsername,
|
||||
articleSlug,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Unpublish post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="presentation"
|
||||
class="crayons-modal__overlay"
|
||||
onClick={toggleUnpublishPostModal}
|
||||
onKeyUp={toggleUnpublishPostModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UnpublishPostModal.displayName = 'UnpublishPostModal';
|
||||
UnpublishPostModal.propTypes = {
|
||||
articleId: PropTypes.number.isRequired,
|
||||
authorUsername: PropTypes.string.isRequired,
|
||||
articleSlug: PropTypes.string.isRequired,
|
||||
};
|
||||
|
|
@ -402,6 +402,7 @@ class User < ApplicationRecord
|
|||
:comment_suspended?,
|
||||
:creator?,
|
||||
:has_trusted_role?,
|
||||
:moderator?,
|
||||
:podcast_admin_for?,
|
||||
:restricted_liquid_tag_for?,
|
||||
:single_resource_admin_for?,
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ class ApplicationPolicy
|
|||
|
||||
delegate :support_admin?, to: :user
|
||||
|
||||
delegate :super_admin?, :any_admin?, :suspended?, to: :user, prefix: true
|
||||
delegate :moderator?, :super_admin?, :any_admin?, :suspended?, to: :user, prefix: true
|
||||
|
||||
alias minimal_admin? user_any_admin?
|
||||
deprecate minimal_admin?: "Deprecating #{self}#minimal_admin?, use #{self}#user_any_admin?"
|
||||
|
|
|
|||
|
|
@ -144,9 +144,13 @@ class ArticlePolicy < ApplicationPolicy
|
|||
user_author? || user_super_admin?
|
||||
end
|
||||
|
||||
def admin_unpublish?
|
||||
# this method performs the same checks that determine
|
||||
# if the record can be featured
|
||||
def revoke_publication?
|
||||
require_user!
|
||||
user_any_admin?
|
||||
return false unless @record.published?
|
||||
|
||||
user_any_admin? || user_moderator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
|
|
@ -170,7 +174,13 @@ class ArticlePolicy < ApplicationPolicy
|
|||
user.trusted?
|
||||
end
|
||||
|
||||
alias admin_featured_toggle? admin_unpublish?
|
||||
alias admin_featured_toggle? revoke_publication?
|
||||
|
||||
alias toggle_featured_status? revoke_publication?
|
||||
|
||||
# Due to the associated controller method "admin_unpublish", we
|
||||
# alias "admin_ubpublish" to the "revoke_publication" method.
|
||||
alias admin_unpublish? revoke_publication?
|
||||
|
||||
alias new? create?
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ module Authorizer
|
|||
has_role?(:trusted)
|
||||
end
|
||||
|
||||
def moderator?
|
||||
has_role?(:moderator)
|
||||
end
|
||||
|
||||
def podcast_admin_for?(podcast)
|
||||
has_role?(:podcast_admin, podcast)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -81,12 +81,15 @@
|
|||
<article class="crayons-card crayons-article mb-4"
|
||||
id="article-show-container"
|
||||
data-article-id="<%= @article.id %>"
|
||||
data-article-slug="<%= @article.slug %>"
|
||||
data-author-id="<%= @article.user_id %>"
|
||||
data-author-name="<%= @article.cached_user_name %>"
|
||||
data-author-username="<%= @article.username %>"
|
||||
data-co-author-ids="<%= @article.co_author_ids.join(",") %>"
|
||||
data-path="<%= @article.path %>"
|
||||
data-published="<%= @article.published? %>"
|
||||
data-pin-path="<%= stories_feed_pinned_article_path %>"
|
||||
data-pinned-article-id="<%= @pinned_article_id %>"
|
||||
data-published="<%= @article.published? %>"
|
||||
<%= @article.pinned? ? "data-pinned" : " " %>>
|
||||
<header class="crayons-article__header" id="main-title">
|
||||
<% if @article.video.present? %>
|
||||
|
|
@ -216,6 +219,7 @@
|
|||
|
||||
<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>
|
||||
|
||||
<div class="fullscreen-code js-fullscreen-code"></div>
|
||||
<%= javascript_packs_with_chunks_tag "followButtons", defer: true %>
|
||||
|
|
|
|||
|
|
@ -48,8 +48,14 @@
|
|||
<span></span>
|
||||
<%= crayons_icon_tag(:checkmark, class: "vomit-checkmark", title: t("views.moderations.actions.checkmark")) %>
|
||||
</button>
|
||||
<% if current_user.any_admin? && @moderatable.published %>
|
||||
<% @recent_featured_count = Article.where(featured: true).where("published_at > ?", 1.day.ago).size %>
|
||||
<% if policy(@moderatable).revoke_publication? %>
|
||||
<button
|
||||
class="c-btn c-btn--primary c-btn--destructive w-100 my-4"
|
||||
id="unpublish-article-btn">
|
||||
<%= t("views.moderations.actions.unpublish") %>
|
||||
</button>
|
||||
<% end %>
|
||||
<% if policy(@moderatable).toggle_featured_status? %>
|
||||
<button
|
||||
class="c-btn c-btn--primary w-100"
|
||||
id="feature-article-btn"
|
||||
|
|
@ -60,7 +66,8 @@
|
|||
<%= @moderatable.featured ? t("views.moderations.actions.unfeature") : t("views.moderations.actions.feature") %>
|
||||
</button>
|
||||
<div class="additional-subtext-section py-4 pt-2">
|
||||
<%= t("views.moderations.actions.featured_past_day", count: @recent_featured_count) %>
|
||||
<% recent_featured_count = Article.where(featured: true).where("published_at > ?", 1.day.ago).size %>
|
||||
<%= t("views.moderations.actions.featured_past_day", count: recent_featured_count) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<a href="<%= URL.url %>/community-moderation#using-the-quick-reactions-to-moderate-content" target="_blank" rel="noopener">
|
||||
|
|
@ -175,8 +182,9 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<% if current_user.any_admin? && @moderatable.published %>
|
||||
<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") %>">
|
||||
<%# 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") %>">
|
||||
<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")) %>
|
||||
|
|
@ -190,19 +198,6 @@
|
|||
<%= 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 hidden">
|
||||
<div class="article-admin-action">
|
||||
<button
|
||||
class="c-btn c-btn--primary c-btn--destructive w-100"
|
||||
id="unpublish-article-btn"
|
||||
data-article-id="<%= @moderatable.id %>"
|
||||
data-article-author="<%= @moderatable.username %>"
|
||||
data-article-slug="<%= @moderatable.slug %>">
|
||||
<%= t("views.moderations.actions.unpublish") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div data-testid="flag-user-modal-container" class="flag-user-modal-container 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>
|
||||
</main>
|
||||
<% else %>
|
||||
<div class="container" style="margin-top: 90px;">
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ describe('Moderation Tools for Posts', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Unpublish Post button on a published post for an admin user', () => {
|
||||
cy.get('@adminUser').then((user) => {
|
||||
cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => {
|
||||
cy.findByRole('button', { name: 'Moderation' }).click();
|
||||
|
||||
cy.getIframeBody('[title="Moderation panel actions"]').within(() => {
|
||||
cy.findByRole('button', { name: 'Unpublish Post' }).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
context('as moderator user', () => {
|
||||
|
|
@ -111,6 +123,18 @@ describe('Moderation Tools for Posts', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Unpublish Post button on a published post for a moderator user', () => {
|
||||
cy.get('@moderatorUser').then((user) => {
|
||||
cy.loginAndVisit(user, '/admin_mcadmin/test-article-slug').then(() => {
|
||||
cy.findByRole('button', { name: 'Moderation' }).click();
|
||||
|
||||
cy.getIframeBody('[title="Moderation panel actions"]').within(() => {
|
||||
cy.findByRole('button', { name: 'Unpublish Post' }).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
context('as trusted user', () => {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ FactoryBot.define do
|
|||
after(:build) { |user| user.add_role(:admin) }
|
||||
end
|
||||
|
||||
trait :moderator do
|
||||
after(:build) { |user| user.add_role(:moderator) }
|
||||
end
|
||||
|
||||
trait :single_resource_admin do
|
||||
transient do
|
||||
resource { nil }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ RSpec.describe ArticlePolicy do
|
|||
let(:trusted) { create(:user, :trusted) }
|
||||
let(:other_users) { create(:user) }
|
||||
let(:author) { create(:user) }
|
||||
let(:moderator) { create(:user, :moderator) }
|
||||
|
||||
let(:resource) { build(:article, user: author, organization: organization) }
|
||||
let(:policy) { described_class.new(user, resource) }
|
||||
|
|
@ -153,13 +154,28 @@ RSpec.describe ArticlePolicy do
|
|||
it_behaves_like "disallowed roles", to: %i[admin org_admin other_users]
|
||||
end
|
||||
|
||||
%i[admin_unpublish? admin_featured_toggle?].each do |method_name|
|
||||
describe "admin_unpublish?" do
|
||||
%i[admin_unpublish? admin_featured_toggle? revoke_publication? toggle_featured_status?].each do |method_name|
|
||||
describe "##{method_name}" do
|
||||
let(:policy_method) { method_name }
|
||||
|
||||
it_behaves_like "it requires an authenticated user"
|
||||
it_behaves_like "permitted roles", to: %i[super_admin admin]
|
||||
it_behaves_like "disallowed roles", to: %i[org_admin author other_users]
|
||||
context "when published article" do
|
||||
# need "create" (as opposed to "build") for the article to be published
|
||||
let(:resource) { create(:article, user: author, organization: organization) }
|
||||
|
||||
it_behaves_like "it requires an authenticated user"
|
||||
it_behaves_like "permitted roles", to: %i[super_admin admin moderator]
|
||||
it_behaves_like "disallowed roles", to: %i[org_admin author other_users]
|
||||
end
|
||||
|
||||
context "when unpublished article" do
|
||||
let(:resource) do
|
||||
build(:article, user: author, organization: organization, published: false, published_at: nil)
|
||||
end
|
||||
|
||||
it_behaves_like "it requires an authenticated user"
|
||||
it_behaves_like "permitted roles", to: %i[]
|
||||
it_behaves_like "disallowed roles", to: %i[super_admin admin moderator org_admin author other_users]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ require "rails_helper"
|
|||
RSpec.describe Authorizer, type: :policy do
|
||||
subject(:authorizer) { described_class.for(user: user) }
|
||||
|
||||
let(:authorizer_mod_role) { described_class.for(user: mod_user) }
|
||||
let(:user) { create(:user) }
|
||||
let(:mod_user) { create(:user, :moderator) }
|
||||
|
||||
describe "#any_admin?" do
|
||||
# This test che
|
||||
it "queries the user's roles" do
|
||||
# I want to test `expect(authorizer.admin?)` but our rubocop
|
||||
# version squaks.
|
||||
|
|
@ -15,6 +16,13 @@ RSpec.describe Authorizer, type: :policy do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#moderator?" do
|
||||
it "queries the user's roles" do
|
||||
expect(authorizer.moderator?).to be_falsey
|
||||
expect(authorizer_mod_role.moderator?).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
describe "#administrative_access_to?" do
|
||||
subject(:method_call) { authorizer.administrative_access_to?(resource: resource) }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue