diff --git a/src/core/client/admin/components/BanModal.tsx b/src/core/client/admin/components/BanModal.tsx index 9ee6bdb214..f0751be0c1 100644 --- a/src/core/client/admin/components/BanModal.tsx +++ b/src/core/client/admin/components/BanModal.tsx @@ -1,4 +1,5 @@ import { Localized } from "@fluent/react/compat"; +import { FORM_ERROR } from "final-form"; import React, { FunctionComponent, useCallback, @@ -221,28 +222,39 @@ const BanModal: FunctionComponent = ({ const onFormSubmit = useCallback(async () => { switch (updateType) { case UpdateType.ALL_SITES: - await banUser({ - userID, // Should be defined because the modal shouldn't open if author is null - message: customizeMessage ? emailMessage : getDefaultMessage, - rejectExistingComments, - siteIDs: viewerIsScoped - ? viewer?.moderationScopes?.sites?.map(({ id }) => id) - : [], - }); + try { + await banUser({ + userID, // Should be defined because the modal shouldn't open if author is null + message: customizeMessage ? emailMessage : getDefaultMessage, + rejectExistingComments, + siteIDs: viewerIsScoped + ? viewer?.moderationScopes?.sites?.map(({ id }) => id) + : [], + }); + } catch (err) { + return { [FORM_ERROR]: err.message }; + } break; case UpdateType.SPECIFIC_SITES: - await updateUserBan({ - userID, - message: customizeMessage ? emailMessage : getDefaultMessage, - banSiteIDs, - unbanSiteIDs, - rejectExistingComments, - }); + try { + await updateUserBan({ + userID, + message: customizeMessage ? emailMessage : getDefaultMessage, + banSiteIDs, + unbanSiteIDs, + }); + } catch (err) { + return { [FORM_ERROR]: err.message }; + } break; case UpdateType.NO_SITES: - await removeUserBan({ - userID, - }); + try { + await removeUserBan({ + userID, + }); + } catch (err) { + return { [FORM_ERROR]: err.message }; + } } if (banDomain) { void createDomainBan({ @@ -327,59 +339,66 @@ const BanModal: FunctionComponent = ({ > {/* BAN FROM/REJECT COMMENTS */} - {/* ban from header */} - - - - - {/* sites options */} - {showAllSitesOption && ( - - - - setUpdateType(UpdateType.ALL_SITES) - } - disabled={userBanStatus?.active} - > - All sites - - - - )} - - - - setUpdateType(UpdateType.SPECIFIC_SITES) - } - > - Specific Sites - + {isMultisite && ( + <> + + - - {!viewerIsScoped && userHasAnyBan && ( - - - - setUpdateType(UpdateType.NO_SITES) - } - > - No Sites - - - - )} - + + {/* sites options */} + {showAllSitesOption && ( + + + + setUpdateType(UpdateType.ALL_SITES) + } + disabled={userBanStatus?.active} + > + All sites + + + + )} + + + + setUpdateType(UpdateType.SPECIFIC_SITES) + } + > + Specific Sites + + + + {!viewerIsScoped && userHasAnyBan && ( + + + + setUpdateType(UpdateType.NO_SITES) + } + > + No Sites + + + + )} + + + )} {/* reject comments option */} {updateType !== UpdateType.NO_SITES && ( { expect(resolvers.Mutation!.updateUserBan!.called).toBe(true); }); +it("displays limited options for single site tenants", async () => { + const resolvers = createResolversStub({ + Query: { + settings: () => settings, // base settings has multisite: false + }, + }); + + const { container } = await createTestRenderer({ + resolvers, + }); + + const userRow = within(container).getByRole("row", { + name: "Isabelle isabelle@test.com 07/06/18, 06:24 PM Commenter Active", + }); + userEvent.click( + within(userRow).getByRole("button", { name: "Change user status" }) + ); + + const dropdown = within(userRow).getByLabelText( + "A dropdown to change the user status" + ); + fireEvent.click(within(dropdown).getByRole("button", { name: "Manage Ban" })); + + const modal = screen.getByLabelText("Are you sure you want to ban Isabelle?"); + expect(modal).toBeInTheDocument(); + expect(screen.queryByText("All sites")).not.toBeInTheDocument(); + expect(screen.queryByText("Specific sites")).not.toBeInTheDocument(); +}); + it("site moderators can unban users on their sites but not sites out of their scope", async () => { const user = users.siteBannedCommenter; const resolvers = createResolversStub({ diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 8bf34e1830..b734e14b93 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -64,6 +64,10 @@ extend type Comment { # Remember last viewer action that could have caused a status change. lastViewerAction: COMMENT_VIEWER_ACTION + # If the comment was spam banned and rejected. Used for showing spam banned + # confirmation in moderation rejected tombstone container. + spamBanned: Boolean + # If true then Comment came in live. enteredLive: Boolean diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index be3ca56ce1..031b5a6e3e 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -399,6 +399,8 @@ export const CommentContainer: FunctionComponent = ({ ); } @@ -808,6 +810,7 @@ const enhanced = withShowAuthPopupMutation( ...ReportFlowContainer_viewer ...ReportButton_viewer ...CaretContainer_viewer + ...ModerationRejectedTombstoneContainer_viewer } `, story: graphql` @@ -829,6 +832,7 @@ const enhanced = withShowAuthPopupMutation( ...PermalinkButtonContainer_story ...ReplyCommentFormContainer_story ...UserTagsContainer_story + ...ModerationRejectedTombstoneContainer_story } `, comment: graphql` diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx index 70d6f9626c..deef3049ff 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/CaretContainer.tsx @@ -1,9 +1,9 @@ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback } from "react"; import { graphql } from "react-relay"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; import CLASSES from "coral-stream/classes"; import { ArrowsDownIcon, @@ -17,7 +17,10 @@ import { CaretContainer_settings } from "coral-stream/__generated__/CaretContain import { CaretContainer_story } from "coral-stream/__generated__/CaretContainer_story.graphql"; import { CaretContainer_viewer } from "coral-stream/__generated__/CaretContainer_viewer.graphql"; -import ModerationDropdownContainer from "./ModerationDropdownContainer"; +import { SetSpamBanned } from "../setSpamBanned"; +import ModerationDropdownContainer, { + ModerationDropdownView, +} from "./ModerationDropdownContainer"; import styles from "./CaretContainer.css"; @@ -26,10 +29,19 @@ interface Props { story: CaretContainer_story; viewer: CaretContainer_viewer; settings: CaretContainer_settings; + view?: ModerationDropdownView; + open?: boolean; } const CaretContainer: FunctionComponent = (props) => { const popoverID = `comments-moderationMenu-${props.comment.id}`; + const setSpamBanned = useMutation(SetSpamBanned); + const setSpamBannedOnClickOutside = useCallback(() => { + if (props.comment.spamBanned) { + void setSpamBanned({ commentID: props.comment.id, spamBanned: false }); + } + }, [props.comment.spamBanned, setSpamBanned, props.comment.id]); + return ( = (props) => { > ( - + { + setSpamBannedOnClickOutside(); + toggleVisibility(); + }} + > = (props) => { settings={props.settings} onDismiss={toggleVisibility} scheduleUpdate={scheduleUpdate} + view={props.view} /> )} @@ -81,6 +100,7 @@ const enhanced = withFragmentContainer({ comment: graphql` fragment CaretContainer_comment on Comment { id + spamBanned ...ModerationDropdownContainer_comment } `, diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx index ddbe42b5ab..2b3729462a 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanButton.tsx @@ -25,9 +25,9 @@ const ModerationActionBanButton: FunctionComponent = ({ showSpinner = false, }) => { const localizationId = allSiteBan - ? "comments-moderationDropdown-ban" + ? "comments-moderationDropdown-spam-ban" : "comments-moderationDropdown-siteBan"; - const defaultText = allSiteBan ? "Ban User" : "Site Ban"; + const defaultText = allSiteBan ? "Spam ban" : "Site ban"; const icon = allSiteBan ? SingleNeutralActionsBlockIcon : AppWindowDisableIcon; diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx index 591683ea59..65e3548ec2 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationActionBanContainer.tsx @@ -59,25 +59,25 @@ const ModerationActionBanContainer: FunctionComponent = ({ {settings?.multisite ? ( <> - - {!viewerScoped && ( + {viewerScoped && ( )} + ) : ( <> void; scheduleUpdate: () => void; + view?: ModerationDropdownView; } const ModerationDropdownContainer: FunctionComponent = ({ @@ -38,9 +43,13 @@ const ModerationDropdownContainer: FunctionComponent = ({ settings, onDismiss, scheduleUpdate, + view: viewProp, }) => { const emitShowEvent = useViewerEvent(ShowModerationPopoverEvent); - const [view, setView] = useState("MODERATE"); + const [view, setView] = useState( + viewProp ?? "MODERATE" + ); + const onBan = useCallback(() => { setView("BAN"); scheduleUpdate(); @@ -72,9 +81,11 @@ const ModerationDropdownContainer: FunctionComponent = ({ ) : ( )} @@ -110,11 +121,13 @@ const enhanced = withFragmentContainer({ settings: graphql` fragment ModerationDropdownContainer_settings on Settings { ...ModerationActionsContainer_settings + ...UserBanPopoverContainer_settings } `, viewer: graphql` fragment ModerationDropdownContainer_viewer on User { ...ModerationActionsContainer_viewer + ...UserBanPopoverContainer_viewer } `, })(ModerationDropdownContainer); diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css index c6d47a18f8..5baeae9985 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.css @@ -1,3 +1,8 @@ .icon { margin-left: var(--spacing-1); } + +.rejectedContainer { + margin-right: auto; + width: 100%; +} diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx index b0438904fa..57d0f8aec0 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/ModerationRejectedTombstoneContainer.tsx @@ -12,19 +12,26 @@ import { Button, Tombstone } from "coral-ui/components/v3"; import { ModerationRejectedTombstoneContainer_comment as CommentData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_comment.graphql"; import { ModerationRejectedTombstoneContainer_local } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_local.graphql"; import { ModerationRejectedTombstoneContainer_settings as SettingsData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_settings.graphql"; +import { ModerationRejectedTombstoneContainer_story as StoryData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_story.graphql"; +import { ModerationRejectedTombstoneContainer_viewer as ViewerData } from "coral-stream/__generated__/ModerationRejectedTombstoneContainer_viewer.graphql"; import computeCommentElementID from "../computeCommentElementID"; +import CaretContainer from "./CaretContainer"; import styles from "./ModerationRejectedTombstoneContainer.css"; interface Props { comment: CommentData; settings: SettingsData; + story: StoryData; + viewer: ViewerData; } const ModerationRejectedTombstoneContainer: FunctionComponent = ({ comment, settings, + story, + viewer, }) => { const [{ accessToken }] = useLocal(graphql` @@ -56,24 +63,42 @@ const ModerationRejectedTombstoneContainer: FunctionComponent = ({ fullWidth noBottomBorder > - -
You have rejected this comment.
-
-
- + {comment.spamBanned && ( + + + + )} + ); }; @@ -82,6 +107,8 @@ const enhanced = withFragmentContainer({ comment: graphql` fragment ModerationRejectedTombstoneContainer_comment on Comment { id + spamBanned + ...CaretContainer_comment } `, settings: graphql` @@ -96,6 +123,17 @@ const enhanced = withFragmentContainer({ } } } + ...CaretContainer_settings + } + `, + viewer: graphql` + fragment ModerationRejectedTombstoneContainer_viewer on User { + ...CaretContainer_viewer + } + `, + story: graphql` + fragment ModerationRejectedTombstoneContainer_story on Story { + ...CaretContainer_story } `, })(ModerationRejectedTombstoneContainer); diff --git a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts index 9c10644116..cdff51621b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ModerationDropdown/RejectCommentMutation.ts @@ -19,7 +19,10 @@ const RejectCommentMutation = createMutation( "rejectComment", async ( environment: Environment, - input: MutationInput & { storyID: string; noEmit?: boolean }, + input: MutationInput & { + storyID: string; + noEmit?: boolean; + }, { eventEmitter }: CoralContext ) => { let rejectCommentEvent: ReturnType | null = diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index c297cdc051..42d2ab7b0f 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -353,6 +353,7 @@ async function commit( pending: false, lastViewerAction: "CREATE", hasTraversalFocus: false, + spamBanned: false, author: { id: viewer.id, username: viewer.username || null, diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css index 673140bf86..46155c7594 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.css @@ -1,6 +1,7 @@ .root { width: 280px; max-width: 80vw; + text-align: left; } .title { @@ -10,8 +11,43 @@ line-height: 1; color: var(--palette-text-500); +} + +.header { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-2); + color: var(--palette-text-500); + margin-bottom: var(--spacing-1); + margin-top: var(--spacing-3); +} + +.orderedList { + margin: 0; + padding-left: var(--spacing-4); +} + +.callOut { + padding: var(--spacing-1); + font-weight: var(--font-weight-primary-semi-bold); + margin-top: var(--spacing-3); + font-size: var(--font-size-1); +} - margin-bottom: var(--spacing-3); +.icon { + display: inline-block; + margin-right: var(--spacing-1); + position: relative; + top: 2px; +} + +.confirmationInput { + box-sizing: border-box; + font-family: var(--font-family-primary); + font-size: var(--font-size-2); + line-height: 2.25; + padding-left: var(--spacing-2); + width: 100%; } .description { @@ -26,3 +62,19 @@ .actions { padding-top: var(--spacing-3); } + +.link { + display: inline; + vertical-align: baseline; + white-space: break-spaces; +} + +.container { + margin-top: var(--spacing-2); +} + +.error { + color: var(--palette-error-500); + margin-top: var(--spacing-2); + font-weight: var(--font-weight-primary-semi-bold); +} diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index 6c356c0c68..af1bf2245a 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -1,18 +1,38 @@ import { Localized } from "@fluent/react/compat"; import cn from "classnames"; -import React, { FunctionComponent, useCallback } from "react"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; import { graphql } from "react-relay"; +import { useModerationLink } from "coral-framework/hooks"; import { useCoralContext } from "coral-framework/lib/bootstrap"; import { getMessage } from "coral-framework/lib/i18n"; -import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { + useLocal, + useMutation, + withFragmentContainer, +} from "coral-framework/lib/relay"; import CLASSES from "coral-stream/classes"; -import { Box, Button, Flex } from "coral-ui/components/v2"; +import { + AlertCircleIcon, + AlertTriangleIcon, + SvgIcon, +} from "coral-ui/components/icons"; +import { Box, Button, CallOut, Divider, Flex } from "coral-ui/components/v2"; import { UserBanPopoverContainer_comment } from "coral-stream/__generated__/UserBanPopoverContainer_comment.graphql"; +import { UserBanPopoverContainer_local } from "coral-stream/__generated__/UserBanPopoverContainer_local.graphql"; +import { UserBanPopoverContainer_settings } from "coral-stream/__generated__/UserBanPopoverContainer_settings.graphql"; import { UserBanPopoverContainer_story } from "coral-stream/__generated__/UserBanPopoverContainer_story.graphql"; +import { UserBanPopoverContainer_viewer } from "coral-stream/__generated__/UserBanPopoverContainer_viewer.graphql"; +import { ModerationDropdownView } from "../ModerationDropdown/ModerationDropdownContainer"; import RejectCommentMutation from "../ModerationDropdown/RejectCommentMutation"; +import { SetSpamBanned } from "../setSpamBanned"; import BanUserMutation from "./BanUserMutation"; import styles from "./UserBanPopoverContainer.css"; @@ -20,45 +40,121 @@ import styles from "./UserBanPopoverContainer.css"; interface Props { onDismiss: () => void; comment: UserBanPopoverContainer_comment; + settings: UserBanPopoverContainer_settings; story: UserBanPopoverContainer_story; - siteBan: boolean; + viewer: UserBanPopoverContainer_viewer; + view: ModerationDropdownView; } const UserBanPopoverContainer: FunctionComponent = ({ comment, + settings, story, + viewer, onDismiss, - siteBan, + view, }) => { - const user = comment.author!; - const rejected = comment.status === "REJECTED"; + const [{ accessToken }] = useLocal(graphql` + fragment UserBanPopoverContainer_local on Local { + accessToken + } + `); + const { localeBundles, rootURL } = useCoralContext(); + const setSpamBanned = useMutation(SetSpamBanned); const reject = useMutation(RejectCommentMutation); const banUser = useMutation(BanUserMutation); - const { localeBundles } = useCoralContext(); - - const onBan = useCallback(() => { - void banUser({ - userID: user.id, - commentID: comment.id, - rejectExistingComments: false, - message: getMessage( - localeBundles, - "common-banEmailTemplate", - "Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments", - { username: user.username } - ), - siteIDs: siteBan ? [story.site.id] : [], - }); - - if (!rejected && comment.revision) { - void reject({ + + const siteBan = view === "SITE_BAN"; + const spamBanConfirmationText = "spam"; + + const user = comment.author!; + const viewerScoped = + viewer?.moderationScopes && viewer.moderationScopes.scoped; + const rejected = comment.status === "REJECTED"; + + const [spamBanConfirmationTextInput, setSpamBanConfirmationTextInput] = + useState(""); + const [banError, setBanError] = useState(null); + + const linkModerateComment = useModerationLink({ commentID: comment.id }); + const linkCommunitySection = rootURL + "/admin/community"; + + const adminLinkSuffix = + !!accessToken && + settings.auth.integrations.sso.enabled && + settings.auth.integrations.sso.targetFilter.admin && + `#accessToken=${accessToken}`; + + const gotoModerateCommentHref = useMemo(() => { + let ret = linkModerateComment; + if (adminLinkSuffix) { + ret += adminLinkSuffix; + } + + return ret; + }, [linkModerateComment, adminLinkSuffix]); + + const gotoCommunitySectionHref = useMemo(() => { + let ret = linkCommunitySection; + if (adminLinkSuffix) { + ret += adminLinkSuffix; + } + + return ret; + }, [linkCommunitySection, adminLinkSuffix]); + + const onCloseConfirm = useCallback(() => { + void setSpamBanned({ commentID: comment.id, spamBanned: false }); + }, [setSpamBanned, comment.id]); + + const onSpamBanConfirmationTextInputChange = useCallback( + (e: React.ChangeEvent) => { + setSpamBanConfirmationTextInput(e.target.value); + }, + [setSpamBanConfirmationTextInput] + ); + + const banButtonDisabled = useMemo(() => { + return siteBan + ? false + : !( + spamBanConfirmationTextInput.toLowerCase() === spamBanConfirmationText + ); + }, [siteBan, spamBanConfirmationText, spamBanConfirmationTextInput]); + + const onBan = useCallback(async () => { + try { + await banUser({ + userID: user.id, commentID: comment.id, - commentRevisionID: comment.revision.id, - storyID: story.id, - noEmit: true, + rejectExistingComments: !siteBan, + message: getMessage( + localeBundles, + "common-banEmailTemplate", + "Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments", + { username: user.username } + ), + siteIDs: siteBan || viewerScoped ? [story.site.id] : [], }); + if (!rejected && comment.revision) { + await reject({ + commentID: comment.id, + commentRevisionID: comment.revision.id, + storyID: story.id, + noEmit: true, + }); + } + } catch (e) { + if (e.message) { + setBanError(e.message); + } + } + if (siteBan) { + onDismiss(); + } else { + // this will trigger the spam ban confirmation view to show for this comment + void setSpamBanned({ commentID: comment.id, spamBanned: true }); } - onDismiss(); }, [ user.id, user.username, @@ -71,61 +167,206 @@ const UserBanPopoverContainer: FunctionComponent = ({ story.site.id, story.id, reject, + viewerScoped, + setBanError, + siteBan, + setSpamBanned, ]); - return ( - - {siteBan ? ( + if (view === "CONFIRM_BAN") { + return ( + -
- Ban {user.username} from this site? -
+
{user.username} is now banned
- ) : ( - + +
+ This account can no longer comment, use reactions, or report + comments +
+
+ +
All comments by this account have been rejected
+
+ + -
Ban {user.username}?
-
- )} - - - Once banned, this user will no longer be able to comment, use - reactions, or report comments. - - - - - - - - + + + + +
+ + + You can still review this account's history by searching in + Coral's + + {" "} + + + +
+
+
+ ); + } + + return ( + + { + <> + {siteBan ? ( + +
+ Ban {user.username} from this site? +
+
+ ) : ( + <> + +
Spam ban
+
+ +
Username
+
+
{user.username}
+ +
Spam ban will
+
+
+
    + +
  1. Ban this account from the comments
  2. +
    + +
  3. Reject all comments written by this account
  4. +
    +
+
+ + + + Only for use on obvious spam accounts + + + +
+ Type in "{spamBanConfirmationText}" to confirm +
+
+ + {banError && ( +
+ + {banError} +
+ )} + + )} + - Ban - - - + + + + + + + + {!siteBan && ( + <> + + + + For more context, go to + {" "} + + + + + + )} + + }
); }; @@ -153,6 +394,27 @@ const enhanced = withFragmentContainer({ } } `, + settings: graphql` + fragment UserBanPopoverContainer_settings on Settings { + auth { + integrations { + sso { + enabled + targetFilter { + admin + } + } + } + } + } + `, + viewer: graphql` + fragment UserBanPopoverContainer_viewer on User { + moderationScopes { + scoped + } + } + `, })(UserBanPopoverContainer); export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts b/src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts new file mode 100644 index 0000000000..bee80f1b49 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/setSpamBanned.ts @@ -0,0 +1,19 @@ +import { commitLocalUpdate, Environment } from "relay-runtime"; + +import { createMutation } from "coral-framework/lib/relay"; + +export interface Input { + commentID: string; + spamBanned: boolean; +} + +export async function commit(environment: Environment, input: Input) { + return commitLocalUpdate(environment, (store) => { + const comment = store.get(input.commentID); + if (comment) { + comment.setValue(input.spamBanned, "spamBanned"); + } + }); +} + +export const SetSpamBanned = createMutation("SetSpamBanned", commit); diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts index 380b24569f..e256e4de05 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts @@ -303,6 +303,7 @@ export const CreateCommentMutation = createMutation( pending: false, lastViewerAction: "CREATE", hasTraversalFocus: false, + spamBanned: false, author: { id: viewer.id, username: viewer.username || null, diff --git a/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx b/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx index 1d9aee7f4b..b5e67549c4 100644 --- a/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx +++ b/src/core/client/stream/test/comments/stream/emitCountEvents.spec.tsx @@ -44,7 +44,7 @@ it("emit commentCount events", (done) => { eventEmitter.on("commentCount", (args) => { expect(args).toMatchInlineSnapshot(` Object { - "number": 2, + "number": 3, "storyID": "story-1", "storyURL": "http://localhost/stories/story-1", "text": "Comments", diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index c8fc6eab10..612050765d 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -7,7 +7,9 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { ERROR_CODES } from "coral-common/errors"; import { pureMerge } from "coral-common/utils"; +import { InvalidRequestError } from "coral-framework/lib/errors"; import { GQLCOMMENT_STATUS, GQLResolver } from "coral-framework/schema"; import { createResolversStub, @@ -37,10 +39,12 @@ function createStory() { const story = createStory(); const firstComment = story.comments.edges[0].node; +const thirdComment = story.comments.edges[2].node; const viewer = moderators[0]; async function createTestRenderer( - params: CreateTestRendererParams = {} + params: CreateTestRendererParams = {}, + options: { muteNetworkErrors?: boolean } = {} ) { const { context } = createContext({ ...params, @@ -54,6 +58,7 @@ async function createTestRenderer( }), params.resolvers ), + muteNetworkErrors: options.muteNetworkErrors, initLocalState: (localRecord, source, environment) => { localRecord.setValue(story.id, "storyID"); @@ -234,7 +239,7 @@ it("reject comment", async () => { ); }); -it("ban user", async () => { +it("spam ban user", async () => { await act(async () => { await createTestRenderer({ resolvers: createResolversStub({ @@ -248,7 +253,7 @@ it("ban user", async () => { banUser: ({ variables }) => { expectAndFail(variables).toMatchObject({ userID: firstComment.author!.id, - rejectExistingComments: false, + rejectExistingComments: true, siteIDs: [], }); return { @@ -284,19 +289,77 @@ it("ban user", async () => { }); await waitFor(() => { expect( - within(comment).getByRole("button", { name: "Ban User" }) + within(comment).getByRole("button", { name: "Spam ban" }) ).not.toBeDisabled(); }); // this is not multisite, so there should be no Site Ban option expect( - within(comment).queryByRole("button", { name: "Site Ban" }) + within(comment).queryByRole("button", { name: "Site ban" }) ).not.toBeInTheDocument(); - fireEvent.click(within(comment).getByRole("button", { name: "Ban User" })); + fireEvent.click(within(comment).getByRole("button", { name: "Spam ban" })); + const banButtonDialog = await screen.findByRole("button", { name: "Ban" }); + + // Ban button should be disabled at first + expect(banButtonDialog).toBeDisabled(); + + // Spam ban option includes all details + expect( + screen.getByText("Ban this account from the comments") + ).toBeInTheDocument(); + expect( + screen.getByText("Reject all comments written by this account") + ).toBeInTheDocument(); + expect( + screen.getByText("Only for use on obvious spam accounts") + ).toBeInTheDocument(); + expect(screen.getByText("For more context, go to")).toBeInTheDocument(); + const moderationViewLink = screen.getByRole("link", { + name: "Moderation view", + }); + expect(moderationViewLink).toBeInTheDocument(); + expect(moderationViewLink).toHaveAttribute( + "href", + `/admin/moderate/comment/${firstComment.id}` + ); + + const input = screen.getByTestId("userSpamBanConfirmation"); + fireEvent.change(input, { target: { value: "spam" } }); + + // After "spam" is typed in, Ban button should be enabled + await waitFor(() => { + expect(banButtonDialog).toBeEnabled(); + }); + fireEvent.click(banButtonDialog); expect( await within(tabPane).findByText("You have rejected this comment.") ).toBeVisible(); + + // spam ban confirmation should be shown + expect(screen.getByText("Markus is now banned")).toBeInTheDocument(); + expect( + screen.getByText( + "This account can no longer comment, use reactions, or report comments" + ) + ).toBeInTheDocument(); + expect( + screen.getByText("All comments by this account have been rejected") + ).toBeInTheDocument(); + expect( + screen.getByText( + "You can still review this account's history by searching in Coral's" + ) + ).toBeInTheDocument(); + const communityLink = screen.getByRole("link", { name: "Community section" }); + expect(communityLink).toBeInTheDocument(); + expect(communityLink).toHaveAttribute("href", "/admin/community"); + const closeButton = screen.getByRole("button", { name: "Close" }); + fireEvent.click(closeButton); + + // spam ban comfirmation should no longer be shown after Close button clicked + expect(screen.queryByText("Markus is now banned")).toBeNull(); + expect(screen.queryByText("You have rejected this comment.")).toBeDefined(); }); it("cancel ban user", async () => { @@ -319,10 +382,10 @@ it("cancel ban user", async () => { }); await waitFor(() => { expect( - within(comment).getByRole("button", { name: "Ban User" }) + within(comment).getByRole("button", { name: "Spam ban" }) ).not.toBeDisabled(); }); - fireEvent.click(within(comment).getByRole("button", { name: "Ban User" })); + fireEvent.click(within(comment).getByRole("button", { name: "Spam ban" })); const cancelButtonDialog = await screen.findByRole("button", { name: "Cancel", }); @@ -384,16 +447,17 @@ it("site moderator can site ban commenter", async () => { await act(async () => { userEvent.click(caretButton); }); - // Site moderator has Site Ban option but not Ban User option + // Site moderator has Site Ban option const siteBanButton = await within(comment).findByRole("button", { - name: "Site Ban", + name: "Site ban", }); await waitFor(() => { expect(siteBanButton).not.toBeDisabled(); }); + // Site moderator also has Spam ban option expect( - within(comment).queryByRole("button", { name: "Ban User" }) - ).not.toBeInTheDocument(); + within(comment).queryByRole("button", { name: "Spam ban" }) + ).toBeInTheDocument(); fireEvent.click(siteBanButton); expect( await screen.findByText("Ban Markus from this site?") @@ -401,10 +465,92 @@ it("site moderator can site ban commenter", async () => { const banButtonDialog = screen.getByRole("button", { name: "Ban" }); fireEvent.click(banButtonDialog); - expect( - within(tabPane).getByText("You have rejected this comment.") - ).toBeVisible(); - const link = within(tabPane).getByRole("link", { + await waitFor(() => { + expect( + within(tabPane).getByText("You have rejected this comment.") + ).toBeVisible(); + }); + + const link = await within(tabPane).findByRole("link", { + name: "Go to moderate to review this decision share-external-link-1", + }); + expect(link).toHaveAttribute( + "href", + `/admin/moderate/comment/${firstComment.id}` + ); +}); + +it("site moderator can spam ban commenter", async () => { + await act(async () => { + await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + user: ({ variables }) => { + expectAndFail(variables.id).toBe(firstComment.author!.id); + return firstComment.author!; + }, + settings: () => settingsWithMultisite, + viewer: () => moderators[1], + }, + Mutation: { + banUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: firstComment.author!.id, + rejectExistingComments: true, + siteIDs: ["site-id"], + }); + return { + user: pureMerge(firstComment.author, { + status: { + ban: { + active: true, + }, + }, + }), + }; + }, + rejectComment: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: firstComment.id, + commentRevisionID: firstComment.revision!.id, + }); + return { + comment: pureMerge(firstComment, { + status: GQLCOMMENT_STATUS.REJECTED, + }), + }; + }, + }, + }), + }); + }); + const tabPane = await screen.findByTestId("current-tab-pane"); + + const comment = screen.getByTestId(`comment-${firstComment.id}`); + const caretButton = within(comment).getByLabelText("Moderate"); + await act(async () => { + userEvent.click(caretButton); + }); + // Site moderator has Spam ban option + const spamBanButton = await within(comment).findByRole("button", { + name: "Spam ban", + }); + fireEvent.click(spamBanButton); + + const input = screen.getByTestId("userSpamBanConfirmation"); + fireEvent.change(input, { target: { value: "spam" } }); + + const banButtonDialog = screen.getByRole("button", { name: "Ban" }); + fireEvent.click(banButtonDialog); + await waitFor(() => { + expect( + within(tabPane).getByText("You have rejected this comment.") + ).toBeVisible(); + }); + + expect(screen.getByText("Markus is now banned")).toBeInTheDocument(); + + const link = await within(tabPane).findByRole("link", { name: "Go to moderate to review this decision share-external-link-1", }); expect(link).toHaveAttribute( @@ -413,6 +559,69 @@ it("site moderator can site ban commenter", async () => { ); }); +it("site moderator cannot ban another moderator with site privileges", async () => { + const errorCode = ERROR_CODES.MODERATOR_CANNOT_BE_BANNED_ON_SITE; + await act(async () => { + await createTestRenderer( + { + resolvers: createResolversStub({ + Query: { + user: () => { + return moderators[2]; + }, + settings: () => settingsWithMultisite, + viewer: () => moderators[1], + }, + Mutation: { + banUser: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: thirdComment.author?.id, + rejectExistingComments: true, + siteIDs: ["site-id"], + }); + throw new InvalidRequestError({ + code: errorCode, + param: "input.body", + traceID: "traceID", + }); + }, + rejectComment: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + commentID: thirdComment.id, + commentRevisionID: thirdComment.revision?.id, + }); + return { + comment: pureMerge(thirdComment, { + status: GQLCOMMENT_STATUS.REJECTED, + }), + }; + }, + }, + }), + }, + { muteNetworkErrors: true } + ); + }); + + const comment = screen.getByTestId(`comment-${thirdComment.id}`); + const caretButton = within(comment).getByLabelText("Moderate"); + await act(async () => { + userEvent.click(caretButton); + }); + // Site moderator has Spam ban option + const spamBanButton = await within(comment).findByRole("button", { + name: "Spam ban", + }); + fireEvent.click(spamBanButton); + + const input = screen.getByTestId("userSpamBanConfirmation"); + fireEvent.change(input, { target: { value: "spam" } }); + + const banButtonDialog = screen.getByRole("button", { name: "Ban" }); + fireEvent.click(banButtonDialog); + expect(await screen.findByText(errorCode)).toBeInTheDocument(); +}); + it("can copy comment embed code", async () => { const jsdomPrompt = window.prompt; window.prompt = jest.fn(() => null); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index a354840b1b..a961bc8db4 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -442,6 +442,38 @@ export const baseComment = createFixture({ }, }); +export const moderators = createFixtures( + [ + { + id: "me-as-moderator", + username: "Moderator", + role: GQLUSER_ROLE.MODERATOR, + ignoreable: false, + }, + { + id: "site-moderator", + username: "Site Moderator", + role: GQLUSER_ROLE.MODERATOR, + ignoreable: false, + moderationScopes: { + scoped: true, + sites: [site[0]], + }, + }, + { + id: "site-moderator-2", + username: "Site Moderator 2", + role: GQLUSER_ROLE.MODERATOR, + ignoreable: false, + moderationScopes: { + scoped: true, + sites: [site[0]], + }, + }, + ], + baseUser +); + export const comments = denormalizeComments( createFixtures( [ @@ -467,7 +499,7 @@ export const comments = denormalizeComments( }, { id: "comment-4", - author: commenters[2], + author: moderators[2], body: "Comment Body 4", }, { @@ -735,28 +767,6 @@ export const commentWithDeepestReplies = denormalizeComment( }) ); -export const moderators = createFixtures( - [ - { - id: "me-as-moderator", - username: "Moderator", - role: GQLUSER_ROLE.MODERATOR, - ignoreable: false, - }, - { - id: "site-moderator", - username: "Site Moderator", - role: GQLUSER_ROLE.MODERATOR, - ignoreable: false, - moderationScopes: { - scoped: true, - sites: [site[0]], - }, - }, - ], - baseUser -); - export const commentFromModerator = denormalizeComment( createFixture( { @@ -797,6 +807,7 @@ export const stories = denormalizeStories( edges: [ { node: comments[0], cursor: comments[0].createdAt }, { node: comments[1], cursor: comments[1].createdAt }, + { node: comments[4], cursor: comments[4].createdAt }, ], }, }, diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index f66ef1abd1..0285afacad 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -47,7 +47,7 @@ export const ERROR_TRANSLATIONS: Record = { USER_ALREADY_BANNED: "error-userAlreadyBanned", USER_BANNED: "error-userBanned", USER_SITE_BANNED: "error-userSiteBanned", - MODERATOR_CANNOT_BE_BANNED_ON_SITE: "error-moderatorCannotBeBannedOnSite", + MODERATOR_CANNOT_BE_BANNED_ON_SITE: "error-cannotBanAccountWithModPrivileges", USER_SUSPENDED: "error-userSuspended", USER_WARNED: "error-userWarned", INTEGRATION_DISABLED: "error-integrationDisabled", diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index 6dfaa3e45a..5ec0fff6dc 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -16,6 +16,7 @@ error-tokenNotFound = Specified token does not exist. error-emailAlreadySet = Email address has already been set. error-emailNotSet = Email address has not been set yet. error-emailDomainProtected = Email domain cannot be moderated. +error-cannotBanAccountWithModPrivileges = Cannot ban accounts with moderator privileges error-duplicateUser = Specified user already exists with a different login method. error-duplicateEmail = Specified email address is already in use. diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 4cceca1f05..daaed68079 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -16,7 +16,6 @@ import { PasswordResetTokenExpired, SSOProfileNotSetError, TokenNotFoundError, - UserAlreadyBannedError, UserAlreadyPremoderated, UserAlreadySuspendedError, UsernameAlreadySetError, @@ -1992,12 +1991,6 @@ export async function siteBanUser( throw new UserNotFoundError(id); } - // Check to see if the user is already banned. - const ban = consolidateUserBanStatus(user.status.ban); - if (ban.active) { - throw new UserAlreadyBannedError(); - } - throw new Error("an unexpected error occurred"); } @@ -2037,9 +2030,6 @@ export async function banUser( { id, tenantID, - "status.ban.active": { - $ne: true, - }, }, { $set: { @@ -2063,12 +2053,6 @@ export async function banUser( throw new UserNotFoundError(id); } - // Check to see if the user is already banned. - const ban = consolidateUserBanStatus(user.status.ban); - if (ban.active) { - throw new UserAlreadyBannedError(); - } - throw new Error("an unexpected error occurred"); } diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index c2932db8cf..f0bcb62b3e 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -28,7 +28,6 @@ import { ModeratorCannotBeBannedOnSiteError, PasswordIncorrect, TokenNotFoundError, - UserAlreadyBannedError, UserAlreadyPremoderated, UserAlreadySuspendedError, UserBioTooLongError, @@ -1389,25 +1388,79 @@ export async function ban( siteIDs?: string[] | null, now = new Date() ) { + // Get the user being banned to check to see if the user already has an + // existing ban. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + // site moderators must provide at least one site ID to ban the user on // otherwise, they would be performing an organization wide ban. - if ( - // check if they are a site moderator + const bannerIsSiteMod = banner.role === GQLUSER_ROLE.MODERATOR && banner.moderationScopes && banner.moderationScopes.siteIDs && - banner.moderationScopes.siteIDs.length !== 0 && - // ensure they've provided at least one site ID - (!siteIDs || siteIDs.length === 0) + banner.moderationScopes.siteIDs.length !== 0; + + // used to determine whether to send another ban email notification or not + let alreadyBanned = false; + + // Check to see if the User is currently banned. + const banStatus = consolidateUserBanStatus(targetUser.status.ban); + if (banStatus.active) { + alreadyBanned = true; + } + + // check if user is already banned on all of the siteIDs provided + if ( + targetUser.status.ban.siteIDs && + targetUser.status.ban.siteIDs.length > 0 && + siteIDs && + siteIDs.length > 0 ) { - throw new Error("site moderators must provide at least one site ID to ban"); + const siteIDsAlreadyBanned = targetUser.status.ban.siteIDs.filter( + (siteID) => { + return siteIDs.includes(siteID); + } + ); + if (siteIDsAlreadyBanned.length === siteIDs.length) { + alreadyBanned = true; + } } - // Get the user being banned to check to see if the user already has an - // existing ban. - const targetUser = await retrieveUser(mongo, tenant.id, userID); - if (!targetUser) { - throw new UserNotFoundError(userID); + if (bannerIsSiteMod) { + // ensure they've provided at least one site ID + if (!siteIDs || siteIDs.length === 0) { + throw new Error( + "site moderators must provide at least one site ID to ban" + ); + } + // make sure org moderators aren't site banned + if ( + targetUser.role === GQLUSER_ROLE.MODERATOR && + !targetUser.moderationScopes?.scoped + ) { + throw new ModeratorCannotBeBannedOnSiteError(); + } + + // a site moderator cannot ban another site moderator with privileges for one of + // the site ids they are trying to ban against. + if ( + targetUser.role === GQLUSER_ROLE.MODERATOR && + targetUser.moderationScopes && + targetUser.moderationScopes.siteIDs && + targetUser.moderationScopes.siteIDs.length !== 0 + ) { + const siteIDInModScopes = targetUser.moderationScopes.siteIDs.some( + (site) => { + return siteIDs.includes(site); + } + ); + if (siteIDInModScopes) { + throw new ModeratorCannotBeBannedOnSiteError(); + } + } } let user: Readonly; @@ -1438,12 +1491,10 @@ export async function ban( } // Otherwise, perform a regular ban else { - // Check to see if the User is currently banned. - const banStatus = consolidateUserBanStatus(targetUser.status.ban); - if (banStatus.active) { - throw new UserAlreadyBannedError(); + // moderators can't be generally banned + if (targetUser.role === GQLUSER_ROLE.MODERATOR) { + throw new ModeratorCannotBeBannedOnSiteError(); } - // Ban the user. user = await banUser(mongo, tenant.id, userID, banner.id, message, now); @@ -1492,27 +1543,29 @@ export async function ban( } } - // If the user has an email address associated with their account, send them - // a ban notification email. - if (user?.email) { - // Send the ban user email. - await mailer.add({ - tenantID: tenant.id, - message: { - to: user.email, - }, - template: { - name: "account-notification/ban", - context: { - // TODO: (wyattjoh) possibly reevaluate the use of a required username. - username: user.username!, - organizationName: tenant.organization.name, - organizationURL: tenant.organization.url, - organizationContactEmail: tenant.organization.contactEmail, - customMessage: (message || "").replace(/\n/g, "
"), + if (!alreadyBanned) { + // If the user has an email address associated with their account, send them + // a ban notification email. + if (user?.email) { + // Send the ban user email. + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, }, - }, - }); + template: { + name: "account-notification/ban", + context: { + // TODO: (wyattjoh) possibly reevaluate the use of a required username. + username: user.username!, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + organizationContactEmail: tenant.organization.contactEmail, + customMessage: (message || "").replace(/\n/g, "
"), + }, + }, + }); + } } return user; diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 48ecfbc8dc..d9df263453 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -255,6 +255,14 @@ comments-userIgnorePopover-description = comments-userIgnorePopover-ignore = Ignore comments-userIgnorePopover-cancel = Cancel +comments-userSpamBanPopover-title = Spam ban +comments-userSpamBanPopover-header-username = Username +comments-userSpamBanPopover-header-description = Spam ban will +comments-userSpamBanPopover-callout = Only for use on obvious spam accounts +comments-userSpamBanPopover-description-list-banFromComments = Ban this account from the comments +comments-userSpamBanPopover-description-list-rejectAllComments = Reject all comments written by this account +comments-userSpamBanPopover-confirmation = Type in "{$text}" to confirm + comments-userBanPopover-title = Ban {$username}? comments-userSiteBanPopover-title = Ban {$username} from this site? comments-userBanPopover-description = @@ -263,6 +271,16 @@ comments-userBanPopover-description = This comment will also be rejected. comments-userBanPopover-cancel = Cancel comments-userBanPopover-ban = Ban +comments-userBanPopover-moderator-ban-error = Cannot ban accounts with moderator privileges +comments-userBanPopover-moreContext = For more context, go to +comments-userBanPopover-moderationView = Moderation view + +comments-userSiteBanPopover-confirm-title = {$username} is now banned +comments-userSiteBanPopover-confirm-spam-banned = This account can no longer comment, use reactions, or report comments +comments-userSiteBanPopover-confirm-comments-rejected = All comments by this account have been rejected +comments-userSiteBanPopover-confirm-closeButton = Close +comments-userSiteBanPopover-confirm-reviewAccountHistory = You can still review this account's history by searching in Coral's +comments-userSiteBanPopover-confirm-communitySection = Community section comments-moderationDropdown-popover = .description = A popover menu to moderate the comment @@ -272,8 +290,9 @@ comments-moderationDropdown-approve = Approve comments-moderationDropdown-approved = Approved comments-moderationDropdown-reject = Reject comments-moderationDropdown-rejected = Rejected -comments-moderationDropdown-ban = Ban User -comments-moderationDropdown-siteBan = Site Ban +comments-moderationDropdown-spam-ban = Spam ban +comments-moderationDropdown-ban = Ban user +comments-moderationDropdown-siteBan = Site ban comments-moderationDropdown-banned = Banned comments-moderationDropdown-goToModerate = comments-moderationDropdown-moderationView = Moderation view