[CORL-1249] Add "warn" status (#3100)

* schema and backend for warning users

* allow warning and warning acknowledgement

* include warning actions in user history drawer

* fix fixture

* add copy to warn modal

* style warning on stream side

* localize strings

* update warning form

* fix fixtures

* copy updates

* clean up error messaging

* userefetch for users

* ensure user status is refreshed on warn error

* add details about warning to user status details

* update copy for unacknowledged warning

* fix merge conflict

* update copy

* remove unused code

* clean up warning css

* sort imports

* fix copy in comments

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Tessa Thornton
2020-08-14 11:44:35 -04:00
committed by GitHub
parent d001fcd456
commit abfcdcc933
45 changed files with 1651 additions and 87 deletions
@@ -6,14 +6,16 @@ import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
import UsernameChangeAction, {
UsernameChangeActionProps,
} from "./UsernameChangeAction";
import WarningAction, { WarningActionProps } from "./WarningAction";
export interface HistoryActionProps {
kind: "username" | "suspension" | "ban" | "premod";
kind: "username" | "suspension" | "ban" | "premod" | "warning";
action:
| UsernameChangeActionProps
| SuspensionActionProps
| BanActionProps
| PremodActionProps;
| PremodActionProps
| WarningActionProps;
}
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
@@ -31,6 +33,8 @@ const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
return <BanAction {...(action as BanActionProps)} />;
case "premod":
return <PremodAction {...(action as PremodActionProps)} />;
case "warning":
return <WarningAction {...(action as WarningActionProps)} />;
default:
return null;
}
@@ -133,6 +133,30 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
});
});
user.status.warning.history.forEach((record, i) => {
let action: "created" | "acknowledged" | "removed";
if (record.active) {
action = "created";
} else {
if (record.acknowledgedAt) {
action = "acknowledged";
} else {
action = "removed";
}
}
history.push({
kind: "warning",
date: new Date(record.createdAt),
takenBy: record.createdBy.username,
action: {
action,
acknowledgedAt: record.acknowledgedAt
? new Date(record.acknowledgedAt)
: null,
},
});
});
// Sort the history so that it's in the right order.
return history.sort((a, b) => b.date.getTime() - a.date.getTime());
}, [user]);
@@ -193,6 +217,16 @@ const enhanced = withFragmentContainer<any>({
}
}
}
warning {
history {
active
createdBy {
username
}
acknowledgedAt
createdAt
}
}
ban {
history {
active
@@ -1,4 +1,5 @@
import { Localized } from "@fluent/react/compat";
import { last } from "lodash";
import React, { FunctionComponent, useMemo } from "react";
import { graphql } from "react-relay";
@@ -22,13 +23,23 @@ interface Props {
const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
const activeBan = useMemo(() => {
return user.status.ban.history.find((item) => item.active);
if (user.status.ban.active) {
return last(user.status.ban.history);
}
return null;
}, [user]);
const activeSuspension = useMemo(() => {
return user.status.suspension.history.find((item) => item.active);
}, [user]);
const activeWarning = useMemo(() => {
if (user.status.warning.active) {
return last(user.status.warning.history);
}
return null;
}, [user]);
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
@@ -37,7 +48,11 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
minute: "2-digit",
});
if (!user.status.ban.active && !user.status.suspension.active) {
if (
!user.status.ban.active &&
!user.status.suspension.active &&
!user.status.warning.active
) {
return null;
}
@@ -49,6 +64,37 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Box p={2}>
{activeWarning && (
<div>
<Localized
id="userDetails-warned-on"
$timestamp={formatter(activeWarning.createdAt)}
strong={<strong />}
>
<p className={styles.root}>
<strong>Warned on </strong>{" "}
{formatter(activeWarning.createdAt)}
</p>
</Localized>
{activeWarning.createdBy && (
<Localized
id="userDetails-warned-by"
strong={<strong />}
$username={activeWarning.createdBy.username}
>
<p className={styles.root}>
<strong>by </strong>
{activeWarning.createdBy.username}
</p>
</Localized>
)}
<Localized id="userDetails-warned-explanation">
<p className={styles.root}>
User has not acknowledged the warning.
</p>
</Localized>
</div>
)}
{activeBan && (
<div>
<Localized
@@ -138,6 +184,16 @@ const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusDetailsContainer_user on User {
status {
warning {
active
history {
active
createdBy {
username
}
createdAt
}
}
ban {
active
history {
@@ -0,0 +1,43 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
export interface WarningActionProps {
action: "created" | "removed" | "acknowledged";
acknowledgedAt: Date | null;
}
import { useDateTimeFormatter } from "coral-framework/hooks";
const WarningAction: FunctionComponent<WarningActionProps> = ({
action,
acknowledgedAt,
}) => {
const formatter = useDateTimeFormatter({
hour: "numeric",
minute: "2-digit",
});
if (action === "created") {
return (
<Localized id="moderate-user-drawer-account-history-warning-set">
<span>User warned</span>
</Localized>
);
} else if (action === "removed") {
return (
<Localized id="moderate-user-drawer-account-history-warning-removed">
<span>Warning removed</span>
</Localized>
);
} else if (action === "acknowledged") {
return (
<Localized id="moderate-user-drawer-account-history-warning-acknowledged">
<span>
Warning acknowledged at{" "}
{acknowledgedAt ? formatter(acknowledgedAt) : ""}
</span>
</Localized>
);
}
return null;
};
export default WarningAction;
@@ -0,0 +1,84 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
import { RemoveUserWarningMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserWarningMutation.graphql";
let clientMutationId = 0;
const RemoveUserWarningMutation = createMutation(
"removeUserWarning",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RemoveUserWarningMutation($input: RemoveUserWarningInput!) {
removeUserWarning(input: $input) {
user {
id
status {
current
warning {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
removeUserWarning: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.filter((s) => s !== GQLUSER_STATUS.WARNED),
warning: {
active: false,
history: [
{
active: false,
createdBy: {
id: viewer.id,
username: viewer.username,
},
createdAt: now.toISOString(),
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default RemoveUserWarningMutation;
@@ -10,6 +10,7 @@ interface Props {
banned: boolean;
suspended: boolean;
premod: boolean;
warned: boolean;
}
const render = (className: string, content: React.ReactNode) => (
@@ -45,6 +46,14 @@ const UserStatus: FunctionComponent<Props> = (props) => {
</Localized>
);
}
if (props.warned) {
return render(
styles.warning,
<Localized id="userStatus-warned">
<div>Warned</div>
</Localized>
);
}
return render(
styles.success,
<Localized id="userStatus-active">
@@ -32,9 +32,12 @@ interface Props {
onRemoveSuspension: () => void;
onPremod: () => void;
onRemovePremod: () => void;
onWarn: () => void;
onRemoveWarning: () => void;
banned: boolean;
suspended: boolean;
premod: boolean;
warned: boolean;
children: React.ReactNode;
fullWidth?: boolean;
bordered?: boolean;
@@ -47,6 +50,9 @@ const UserStatusChange: FunctionComponent<Props> = ({
onRemoveSuspension,
onPremod,
onRemovePremod,
onWarn,
onRemoveWarning,
warned,
banned,
suspended,
premod,
@@ -143,6 +149,37 @@ const UserStatusChange: FunctionComponent<Props> = ({
</DropdownButton>
</Localized>
)}
{warned ? (
<Localized id="community-userStatus-removeWarning">
<DropdownButton
className={styles.dropdownButton}
disabled={!onRemoveWarning}
onClick={() => {
if (onRemoveWarning) {
onRemoveWarning();
toggleVisibility();
}
}}
>
Remove warning
</DropdownButton>
</Localized>
) : (
<Localized id="community-userStatus-warn">
<DropdownButton
className={styles.dropdownButton}
disabled={!onWarn}
onClick={() => {
if (onWarn) {
onWarn();
toggleVisibility();
}
}}
>
Warn
</DropdownButton>
</Localized>
)}
</Dropdown>
</ClickOutside>
)}
@@ -15,10 +15,13 @@ import PremodUserMutation from "./PremodUserMutation";
import RemoveUserBanMutation from "./RemoveUserBanMutation";
import RemoveUserPremodMudtaion from "./RemoveUserPremodMutation";
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
import RemoveUserWarningMutation from "./RemoveUserWarningMutation";
import SuspendModal from "./SuspendModal";
import SuspendUserMutation from "./SuspendUserMutation";
import UserStatusChange from "./UserStatusChange";
import UserStatusContainer from "./UserStatusContainer";
import WarnModal from "./WarnModal";
import WarnUserMutation from "./WarnUserMutation";
interface Props {
settings: UserStatusChangeContainer_settings;
@@ -41,10 +44,37 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
const premodUser = useMutation(PremodUserMutation);
const removeUserPremod = useMutation(RemoveUserPremodMudtaion);
const warnUser = useMutation(WarnUserMutation);
const removeUserWarning = useMutation(RemoveUserWarningMutation);
const [showPremod, setShowPremod] = useState<boolean>(false);
const [showBanned, setShowBanned] = useState<boolean>(false);
const [showSuspend, setShowSuspend] = useState<boolean>(false);
const [showWarn, setShowWarn] = useState<boolean>(false);
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
const [showWarnSuccess, setShowWarnSuccess] = useState<boolean>(false);
const handleWarn = useCallback(() => {
if (user.status.warning.active) {
return;
}
setShowWarn(true);
}, [user, setShowWarn]);
const handleRemoveWarning = useCallback(() => {
if (!user.status.warning.active) {
return;
}
void removeUserWarning({ userID: user.id });
}, [user, removeUserWarning]);
const hideWarn = useCallback(() => {
setShowWarn(false);
setShowWarnSuccess(false);
}, [setShowWarn]);
const handleWarnConfirm = useCallback(
(message: string) => {
void warnUser({ userID: user.id, message });
setShowWarnSuccess(true);
},
[warnUser, user, setShowWarnSuccess]
);
const handleBan = useCallback(() => {
if (user.status.ban.active) {
return;
@@ -91,12 +121,12 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
return;
}
void removeUserPremod({ userID: user.id });
}, [user, premodUser]);
}, [user, removeUserPremod]);
const handleSuspendModalClose = useCallback(() => {
setShowSuspend(false);
setShowSuspendSuccess(false);
}, [setShowBanned, setShowSuspendSuccess]);
}, [setShowSuspendSuccess]);
const handleBanModalClose = useCallback(() => {
setShowBanned(false);
@@ -119,7 +149,7 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
void banUser({ userID: user.id, message, rejectExistingComments });
setShowBanned(false);
},
[user, setShowBanned]
[user, setShowBanned, banUser]
);
if (user.role !== GQLUSER_ROLE.COMMENTER) {
@@ -140,6 +170,9 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
banned={user.status.ban.active}
suspended={user.status.suspension.active}
premod={user.status.premod.active}
warned={user.status.warning.active}
onWarn={handleWarn}
onRemoveWarning={handleRemoveWarning}
fullWidth={fullWidth}
bordered={bordered}
>
@@ -159,6 +192,14 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
onClose={hidePremod}
onConfirm={handlePremodConfirm}
/>
<WarnModal
username={user.username}
organizationName={settings.organization.name}
open={showWarn}
onClose={hideWarn}
onConfirm={handleWarnConfirm}
success={showWarnSuccess}
/>
{!scoped && (
<BanModal
username={user.username}
@@ -187,6 +228,9 @@ const enhanced = withFragmentContainer<Props>({
premod {
active
}
warning {
active
}
}
...UserStatusContainer_user
}
@@ -18,6 +18,7 @@ const UserStatusContainer: FunctionComponent<Props> = (props) => {
banned={props.user.status.current.includes(GQLUSER_STATUS.BANNED)}
suspended={props.user.status.current.includes(GQLUSER_STATUS.SUSPENDED)}
premod={props.user.status.current.includes(GQLUSER_STATUS.PREMOD)}
warned={props.user.status.current.includes(GQLUSER_STATUS.WARNED)}
/>
);
};
@@ -0,0 +1,10 @@
.label {
margin-bottom: 0;
}
.required {
color: var(--palette-text-100);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-primary-regular);
font-size: var(--font-size-2);
}
@@ -0,0 +1,86 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, RefObject, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { required } from "coral-framework/lib/validation";
import {
Button,
Flex,
HelperText,
HorizontalGutter,
Label,
Textarea,
} from "coral-ui/components/v2";
import styles from "./WarnForm.css";
interface Props {
onCancel: () => void;
onSubmit: (message: string) => void;
lastFocusableRef: RefObject<any>;
}
const WarnForm: FunctionComponent<Props> = ({
onCancel,
onSubmit,
lastFocusableRef,
}) => {
const onFormSubmit = useCallback(
({ message }) => {
onSubmit(message);
},
[onSubmit]
);
return (
<>
<Form onSubmit={onFormSubmit}>
{({ handleSubmit, invalid, form }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={3}>
<HorizontalGutter spacing={1}>
<Flex alignItems="baseline" spacing={1}>
<Localized id="community-warnModal-message-label">
<Label className={styles.label}>Message</Label>
</Localized>
<Localized id="community-warnModal-message-required">
<span className={styles.required}>Required</span>
</Localized>
</Flex>
<Localized id="community-warnModal-message-description">
<HelperText>
Explain to this user how they should change their behavior
on your site.
</HelperText>
</Localized>
</HorizontalGutter>
<Field component="textarea" name="message" validate={required}>
{({ input }) => (
<Textarea id="warnModal-message" {...input} fullwidth />
)}
</Field>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-warnModal-cancel">
<Button variant="flat" onClick={onCancel}>
Cancel
</Button>
</Localized>
<Localized id="community-warnModal-warnUser">
<Button
ref={lastFocusableRef}
type="submit"
disabled={invalid}
>
Warn User
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</>
);
};
export default WarnForm;
@@ -0,0 +1,103 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2";
import ModalBodyText from "../ModalBodyText";
import ModalHeader from "../ModalHeader";
import ModalHeaderUsername from "../ModalHeaderUsername";
import ChangeStatusModal from "./ChangeStatusModal";
import WarnForm from "./WarnForm";
interface Props {
username: string | null;
open: boolean;
onClose: () => void;
onConfirm: (message: string) => void;
organizationName: string;
success: boolean;
}
const WarnModal: FunctionComponent<Props> = ({
open,
onClose,
onConfirm,
username,
organizationName,
success,
}) => {
const onFormSubmit = useCallback(
(message: string) => {
onConfirm(message);
},
[onConfirm]
);
return (
<ChangeStatusModal
open={open}
onClose={onClose}
aria-labelledby="warnModal-title"
>
{({ lastFocusableRef }) => (
<>
{success && (
<HorizontalGutter spacing={3}>
<Localized
id="community-warnModal-success"
$username={username}
strong={<ModalHeaderUsername />}
>
<ModalHeader>
A warning has been sent to{" "}
<ModalHeaderUsername>{username}</ModalHeaderUsername>
</ModalHeader>
</Localized>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-suspendModal-success-close">
<Button ref={lastFocusableRef} onClick={onClose}>
Ok
</Button>
</Localized>
</Flex>
</HorizontalGutter>
)}
{!success && (
<HorizontalGutter spacing={3}>
<Localized
id="community-warnModal-areYouSure"
strong={<ModalHeaderUsername />}
$username={username || <NotAvailable />}
>
<ModalHeader id="warnModal-title">
Warn{" "}
<ModalHeaderUsername>
{username || <NotAvailable />}
</ModalHeaderUsername>
?
</ModalHeader>
</Localized>
<Localized id="community-warnModal-consequence">
<ModalBodyText>
A warning can improve a commenters conduct without a
suspension or ban. The user must acknowledge the warning
before they can continue commenting.
</ModalBodyText>
</Localized>
<WarnForm
onCancel={onClose}
onSubmit={onFormSubmit}
lastFocusableRef={lastFocusableRef}
/>
</HorizontalGutter>
)}
</>
)}
</ChangeStatusModal>
);
};
export default WarnModal;
@@ -0,0 +1,84 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
import { WarnUserMutation as MutationTypes } from "coral-admin/__generated__/WarnUserMutation.graphql";
let clientMutationId = 0;
const WarnUserMutation = createMutation(
"warnUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation WarnUserMutation($input: WarnUserInput!) {
warnUser(input: $input) {
user {
id
status {
current
warning {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
warnUser: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.concat(GQLUSER_STATUS.WARNED),
warning: {
active: true,
history: [
{
active: true,
createdBy: {
id: viewer.id,
username: viewer.username,
},
createdAt: now.toISOString(),
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default WarnUserMutation;
+4
View File
@@ -400,6 +400,10 @@ export const baseUser = createFixture<GQLUser>({
active: false,
history: [],
},
warning: {
active: false,
history: [],
},
},
});
@@ -190,6 +190,9 @@ export const CommentContainer: FunctionComponent<Props> = ({
const isViewerSuspended = !!viewer?.status.current.includes(
GQLUSER_STATUS.SUSPENDED
);
const isViewerWarned = !!viewer?.status.current.includes(
GQLUSER_STATUS.WARNED
);
const isViewerScheduledForDeletion = !!viewer?.scheduledDeletionDate;
const isViewerComment =
@@ -204,7 +207,12 @@ export const CommentContainer: FunctionComponent<Props> = ({
const [editable, setEditable] = useState(() => {
// Can't edit a comment that the viewer didn't write! If the user is banned
// or suspended too they can't edit.
if (!isViewerComment || isViewerBanned || isViewerSuspended) {
if (
!isViewerComment ||
isViewerBanned ||
isViewerSuspended ||
isViewerWarned
) {
return false;
}
@@ -452,7 +460,9 @@ export const CommentContainer: FunctionComponent<Props> = ({
comment={comment}
settings={settings}
viewer={viewer}
readOnly={isViewerBanned || isViewerSuspended}
readOnly={
isViewerBanned || isViewerSuspended || isViewerWarned
}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactButton
@@ -466,6 +476,7 @@ export const CommentContainer: FunctionComponent<Props> = ({
{!disableReplies &&
!isViewerBanned &&
!isViewerSuspended &&
!isViewerWarned &&
!isViewerScheduledForDeletion && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
@@ -492,6 +503,7 @@ export const CommentContainer: FunctionComponent<Props> = ({
<ButtonsBar>
{!isViewerBanned &&
!isViewerSuspended &&
!isViewerWarned &&
!hideReportButton && (
<ReportButton
onClick={toggleShowReportFlow}
@@ -0,0 +1,30 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
createFetch,
fetchQuery,
FetchVariables,
} from "coral-framework/lib/relay";
import { RefreshUserFetchQuery as QueryTypes } from "coral-stream/__generated__/RefreshUserFetchQuery.graphql";
const RefreshUserFetch = createFetch(
"refreshUser",
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
return fetchQuery<QueryTypes>(
environment,
graphql`
query RefreshUserFetchQuery {
viewer {
...StreamContainer_viewer
}
}
`,
variables,
{ force: true }
);
}
);
export default RefreshUserFetch;
@@ -23,6 +23,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
showAuthPopup: noop as any,
createComment: noop as any,
refreshSettings: noop as any,
refreshUser: noop as any,
tab: "",
onChangeTab: noop as any,
story: {
@@ -31,9 +31,11 @@ import {
import {
getSubmitStatus,
shouldTriggerSettingsRefresh,
shouldTriggerUserRefresh,
SubmitStatus,
} from "../../helpers";
import RefreshSettingsFetch from "../../RefreshSettingsFetch";
import RefreshUserFetch from "../../RefreshUserFetch";
import { RTE_RESET_VALUE } from "../../RTE/RTE";
import CommentForm from "../CommentForm";
import {
@@ -48,6 +50,7 @@ import PostCommentFormFake from "./PostCommentFormFake";
interface Props {
createComment: CreateCommentMutation;
refreshSettings: FetchProp<typeof RefreshSettingsFetch>;
refreshUser: FetchProp<typeof RefreshUserFetch>;
sessionStorage: PromisifiedStorage;
settings: PostCommentFormContainer_settings;
viewer: PostCommentFormContainer_viewer | null;
@@ -146,8 +149,12 @@ export class PostCommentFormContainer extends Component<Props, State> {
if (shouldTriggerSettingsRefresh(error.code)) {
await this.props.refreshSettings({ storyID: this.props.story.id });
}
if (shouldTriggerUserRefresh(error.code)) {
await this.props.refreshUser();
}
return error.invalidArgs;
}
/**
* Comment was caught in one of the moderation filters on the server.
* We give the user another change to submit the comment, and we
@@ -278,60 +285,62 @@ const enhanced = withContext(({ sessionStorage }) => ({
withShowAuthPopupMutation(
withCreateCommentMutation(
withFetch(RefreshSettingsFetch)(
withFragmentContainer<Props>({
settings: graphql`
fragment PostCommentFormContainer_settings on Settings {
charCount {
enabled
min
max
}
disableCommenting {
enabled
message
}
closeCommenting {
message
}
media {
twitter {
withFetch(RefreshUserFetch)(
withFragmentContainer<Props>({
settings: graphql`
fragment PostCommentFormContainer_settings on Settings {
charCount {
enabled
min
max
}
youtube {
disableCommenting {
enabled
message
}
giphy {
enabled
closeCommenting {
message
}
media {
twitter {
enabled
}
youtube {
enabled
}
giphy {
enabled
}
}
rte {
...RTEContainer_config
}
}
rte {
...RTEContainer_config
}
}
`,
story: graphql`
fragment PostCommentFormContainer_story on Story {
id
isClosed
...MessageBoxContainer_story
site {
`,
story: graphql`
fragment PostCommentFormContainer_story on Story {
id
}
settings {
messageBox {
enabled
isClosed
...MessageBoxContainer_story
site {
id
}
settings {
messageBox {
enabled
}
mode
}
mode
}
}
`,
viewer: graphql`
fragment PostCommentFormContainer_viewer on User {
id
scheduledDeletionDate
}
`,
})(PostCommentFormContainer)
`,
viewer: graphql`
fragment PostCommentFormContainer_viewer on User {
id
scheduledDeletionDate
}
`,
})(PostCommentFormContainer)
)
)
)
)
@@ -1,6 +1,11 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, useCallback, useEffect } from "react";
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
} from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
@@ -49,6 +54,7 @@ import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
import UnansweredCommentsTab from "./UnansweredCommentsTab";
import useCommentCountEvent from "./useCommentCountEvent";
import WarningContainer from "./Warning";
import styles from "./StreamContainer.css";
@@ -139,6 +145,13 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
);
const warned = useMemo(() => {
return Boolean(
props.viewer &&
props.viewer.status.current.includes(GQLUSER_STATUS.WARNED)
);
}, [props.viewer]);
const allCommentsCount = props.story.commentCounts.totalPublished;
const featuredCommentsCount = props.story.commentCounts.tags.FEATURED;
const unansweredCommentsCount = props.story.commentCounts.tags.UNANSWERED;
@@ -194,7 +207,7 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
<StreamDeletionRequestCalloutContainer viewer={props.viewer} />
)}
<CommunityGuidelinesContainer settings={props.settings} />
{!banned && !suspended && (
{!banned && !suspended && !warned && (
<PostCommentFormContainer
settings={props.settings}
story={props.story}
@@ -205,6 +218,9 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
/>
)}
{banned && <BannedInfo />}
{warned && (
<WarningContainer viewer={props.viewer} settings={props.settings} />
)}
{suspended && (
<SuspendedInfoContainer
viewer={props.viewer}
@@ -423,6 +439,7 @@ const enhanced = withFragmentContainer<Props>({
...SuspendedInfoContainer_viewer
...StreamDeletionRequestCalloutContainer_viewer
...ModerateStreamContainer_viewer
...WarningContainer_viewer
status {
current
}
@@ -439,6 +456,7 @@ const enhanced = withFragmentContainer<Props>({
...SuspendedInfoContainer_settings
...AnnouncementContainer_settings
...ModerateStreamContainer_settings
...WarningContainer_settings
}
`,
})(StreamContainer);
@@ -0,0 +1,63 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
let clientMutationId = 0;
import { AcknowledgeWarningMutation as MutationTypes } from "coral-stream/__generated__/AcknowledgeWarningMutation.graphql";
const AcknowledgeWarningMutation = createMutation(
"acknowledgeWarning",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation AcknowledgeWarningMutation($input: AcknowledgeWarningInput!) {
acknowledgeWarning(input: $input) {
user {
id
status {
current
warning {
active
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
acknowledgeWarning: {
user: {
id: viewer.id,
status: {
current: lookup<GQLUser>(
environment,
viewer.id
)!.status.current.filter((s) => s !== GQLUSER_STATUS.WARNED),
warning: {
active: false,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default AcknowledgeWarningMutation;
@@ -0,0 +1,12 @@
.root {
}
.icon {
}
.message {
border-left: 2px solid var(--palette-grey-500);
padding: var(--spacing-1) 0 var(--spacing-1) var(--spacing-3);
line-height: 1.45;
white-space: pre-wrap;
}
@@ -0,0 +1,60 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { HorizontalGutter, Icon } from "coral-ui/components/v2";
import { Button, CallOut } from "coral-ui/components/v3";
import styles from "./Warning.css";
interface Props {
message: string;
onAcknowledge: () => void;
organizationName: string;
}
const Warning: FunctionComponent<Props> = ({
message,
onAcknowledge,
organizationName,
}) => {
return (
<CallOut
color="error"
iconColor="none"
icon={<Icon size="sm">report</Icon>}
borderPosition="top"
title={
<Localized id="warning-heading">
Your account has been issued a warning
</Localized>
}
>
<HorizontalGutter spacing={3}>
<Localized id="warning-explanation">
<p>
In accordance with our community guidelines your account has been
issued a warning.
</p>
</Localized>
<blockquote className={styles.message}>{message}</blockquote>
<Localized id="warning-instructions">
<p>
To continue participating in discussions please press the
"Acknowledge" button below.
</p>
</Localized>
<Localized id="warning-acknowledge">
<Button
paddingSize="extraSmall"
color="secondary"
onClick={onAcknowledge}
>
Acknowledge
</Button>
</Localized>
</HorizontalGutter>
</CallOut>
);
};
export default Warning;
@@ -0,0 +1,54 @@
import React, { FunctionComponent, useCallback } from "react";
import { graphql } from "react-relay";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import { WarningContainer_settings } from "coral-stream/__generated__/WarningContainer_settings.graphql";
import { WarningContainer_viewer } from "coral-stream/__generated__/WarningContainer_viewer.graphql";
import AcknowledgeWarningMutation from "./AcknowledgeWarningMutation";
import Warning from "./Warning";
interface Props {
viewer: WarningContainer_viewer | null;
settings: WarningContainer_settings;
}
const WarningContainer: FunctionComponent<Props> = ({ viewer, settings }) => {
const acknowledgeWarning = useMutation(AcknowledgeWarningMutation);
const onAcknowledge = useCallback(() => acknowledgeWarning(), [
acknowledgeWarning,
]);
if (!viewer || !viewer.status.warning.active) {
return null;
}
return (
<Warning
message={viewer.status.warning.message || ""}
onAcknowledge={onAcknowledge}
organizationName={settings.organization.name}
/>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment WarningContainer_viewer on User {
status {
warning {
active
message
}
}
}
`,
settings: graphql`
fragment WarningContainer_settings on Settings {
organization {
name
}
}
`,
})(WarningContainer);
export default enhanced;
@@ -0,0 +1 @@
export { default } from "./WarningContainer";
@@ -1,6 +1,7 @@
export { default as getHTMLCharacterLength } from "./getHTMLCharacterLength";
export { default as getCommentBodyValidators } from "./getCommentBodyValidators";
export { default as shouldTriggerSettingsRefresh } from "./shouldTriggerSettingsRefresh";
export { default as shouldTriggerUserRefresh } from "./shouldTriggerUserRefresh";
export { default as getSubmitStatus, SubmitStatus } from "./getSubmitStatus";
export { default as incrementStoryCommentCounts } from "./incrementStoryCommentCounts";
export { default as isInReview } from "./isInReview";
@@ -0,0 +1,15 @@
import { ERROR_CODES } from "coral-common/errors";
const triggers = [ERROR_CODES.USER_WARNED];
/**
* shouldTriggerSettingsRefresh will indicate whether the settings
* needs to refresh based on a recently received error code. Some
* error codes signify that the settings on the client currently
* mismatches with the newest settings on the server, and thus
* e.g. validations fail.
*
* @param code the error code to check for
*/
export default function shouldTriggerUserRefresh(code: ERROR_CODES) {
return triggers.includes(code);
}
+4
View File
@@ -162,6 +162,10 @@ export const baseUser = createFixture<GQLUser>({
until: null,
history: [],
},
warning: {
active: false,
history: [],
},
},
ignoredUsers: [],
comments: {
@@ -46,6 +46,10 @@ export function createUserStatus(banned = false): GQLUserStatus {
active: false,
history: [],
},
warning: {
active: false,
history: [],
},
};
}
+4
View File
@@ -44,6 +44,10 @@ export function createUserStatus(banned = false) {
active: false,
history: [],
},
warning: {
active: false,
history: [],
},
};
}
+6
View File
@@ -237,6 +237,12 @@ export enum ERROR_CODES {
*/
USER_SUSPENDED = "USER_SUSPENDED",
/**
* USER_WARNED is returned when the user attempts to perform an action that
* is not permitted if they are warned and have not acknowledged the warning.
*/
USER_WARNED = "USER_WARNED",
/**
* USER_BANNED is returned when the user attempts to perform an action that
* is not permitted if they are banned.
+14
View File
@@ -664,6 +664,20 @@ export class UserSuspended extends CoralError {
}
}
export class UserWarned extends CoralError {
constructor(
userID: string,
message?: string,
resource?: string,
operation?: string
) {
super({
code: ERROR_CODES.USER_WARNED,
context: { pvt: { resource, operation, userID }, pub: { message } },
});
}
}
export class UserCannotBeIgnoredError extends CoralError {
constructor(userID: string) {
super({
+1
View File
@@ -41,6 +41,7 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
USER_ALREADY_BANNED: "error-userAlreadyBanned",
USER_BANNED: "error-userBanned",
USER_SUSPENDED: "error-userSuspended",
USER_WARNED: "error-userWarned",
INTEGRATION_DISABLED: "error-integrationDisabled",
PASSWORD_RESET_TOKEN_EXPIRED: "error-passwordResetTokenExpired",
EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired",
+16
View File
@@ -5,11 +5,13 @@ import {
UserBanned,
UserForbiddenError,
UserSuspended,
UserWarned,
} from "coral-server/errors";
import GraphContext from "coral-server/graph/context";
import {
consolidateUserStatus,
consolidateUserSuspensionStatus,
consolidateUserWarningStatus,
User,
} from "coral-server/models/user";
import { canModerateUnscoped } from "coral-server/models/user/helpers";
@@ -59,6 +61,10 @@ function calculateAuthConditions(
conditions.push(GQLUSER_AUTH_CONDITIONS.PENDING_DELETION);
}
if (status.warning && status.warning.active) {
conditions.push(GQLUSER_AUTH_CONDITIONS.WARNED);
}
return conditions.sort();
}
@@ -92,6 +98,16 @@ const auth: DirectiveResolverFn<
throw new UserBanned(user.id, resource, info.operation.operation);
}
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.WARNED)) {
const warning = consolidateUserWarningStatus(user.status.warning);
throw new UserWarned(
user.id,
warning.message,
resource,
info.operation.operation
);
}
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.SUSPENDED)) {
const status = consolidateUserSuspensionStatus(
user.status.suspension,
+18
View File
@@ -3,6 +3,7 @@ import GraphContext from "coral-server/graph/context";
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
import { User } from "coral-server/models/user";
import {
acknowledgeWarning,
addModeratorNote,
ban,
cancelAccountDeletion,
@@ -15,6 +16,7 @@ import {
removeIgnore,
removePremod,
removeSuspension,
removeWarning,
requestAccountDeletion,
requestCommentsDownload,
requestUserCommentsDownload,
@@ -32,6 +34,7 @@ import {
updateRole,
updateUsername,
updateUsernameByID,
warn,
} from "coral-server/services/users";
import { invite } from "coral-server/services/users/auth/invite";
import { deleteUser } from "coral-server/services/users/delete";
@@ -51,6 +54,7 @@ import {
GQLRemoveUserBanInput,
GQLRemoveUserIgnoreInput,
GQLRemoveUserSuspensionInput,
GQLRemoveUserWarningInput,
GQLRequestAccountDeletionInput,
GQLRequestCommentsDownloadInput,
GQLRequestUserCommentsDownloadInput,
@@ -68,6 +72,7 @@ import {
GQLUpdateUsernameInput,
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
GQLWarnUserInput,
} from "coral-server/graph/schema/__generated__/types";
import { WithoutMutationID } from "./util";
@@ -258,6 +263,19 @@ export const Users = (ctx: GraphContext) => ({
rejectExistingComments,
ctx.now
),
warn: async (input: GQLWarnUserInput) =>
warn(
ctx.mongo,
ctx.tenant,
ctx.user!,
input.userID,
input.message,
ctx.now
),
removeWarning: async (input: GQLRemoveUserWarningInput) =>
removeWarning(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
acknowledgeWarning: async () =>
acknowledgeWarning(ctx.mongo, ctx.tenant, ctx.user!.id, ctx.now),
premodUser: async (input: GQLPremodUserInput) =>
premod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
suspend: async (input: GQLSuspendUserInput) =>
@@ -199,6 +199,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.removeBan(input),
clientMutationId: input.clientMutationId,
}),
warnUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.warn(input),
clientMutationId: input.clientMutationId,
}),
removeUserWarning: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeWarning(input),
clientMutationId: input.clientMutationId,
}),
acknowledgeWarning: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.acknowledgeWarning(),
clientMutationId: input.clientMutationId,
}),
suspendUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.suspend(input),
clientMutationId: input.clientMutationId,
@@ -9,6 +9,7 @@ import { BanStatusInput } from "./BanStatus";
import { PremodStatusInput } from "./PremodStatus";
import { SuspensionStatusInput } from "./SuspensionStatus";
import { UsernameStatusInput } from "./UsernameStatus";
import { WarningStatusInput } from "./WarningStatus";
export type UserStatusInput = user.UserStatus & {
userID: string;
@@ -36,6 +37,11 @@ export const UserStatus: Required<GQLUserStatusTypeResolver<
statuses.push(GQLUSER_STATUS.PREMOD);
}
// If they have a warning, then mark it.
if (consolidatedStatus.warning.active) {
statuses.push(GQLUSER_STATUS.WARNED);
}
// If no other statuses were applied, then apply the active status.
if (statuses.length === 0) {
statuses.push(GQLUSER_STATUS.ACTIVE);
@@ -59,4 +65,8 @@ export const UserStatus: Required<GQLUserStatusTypeResolver<
...user.consolidateUserPremodStatus(premod),
userID,
}),
warning: ({ warning, userID }): WarningStatusInput => ({
...user.consolidateUserWarningStatus(warning),
userID,
}),
};
@@ -0,0 +1,16 @@
import * as user from "coral-server/models/user";
import { GQLWarningStatusTypeResolver } from "coral-server/graph/schema/__generated__/types";
export type WarningStatusInput = user.ConsolidatedWarningStatus & {
userID: string;
};
export const WarningStatus: Required<GQLWarningStatusTypeResolver<
WarningStatusInput
>> = {
active: ({ active }) => active,
message: ({ message }) => message,
history: ({ history, userID }) =>
history.map((status) => ({ ...status, userID })),
};
@@ -0,0 +1,15 @@
import * as user from "coral-server/models/user";
import { GQLWarningStatusHistoryTypeResolver } from "coral-server/graph/schema/__generated__/types";
export const WarningStatusHistory: Required<GQLWarningStatusHistoryTypeResolver<
user.WarningStatusHistory
>> = {
active: ({ active }) => active,
acknowledgedAt: ({ acknowledgedAt }) => acknowledgedAt,
createdBy: ({ createdBy }, input, ctx) => {
return ctx.loaders.Users.user.load(createdBy);
},
createdAt: ({ createdAt }) => createdAt,
message: ({ message }) => message,
};
+4
View File
@@ -60,6 +60,8 @@ import { UserModerationScopes } from "./UserModerationScopes";
import { UsernameHistory } from "./UsernameHistory";
import { UsernameStatus } from "./UsernameStatus";
import { UserStatus } from "./UserStatus";
import { WarningStatus } from "./WarningStatus";
import { WarningStatusHistory } from "./WarningStatusHistory";
import { WebhookEndpoint } from "./WebhookEndpoint";
import { YouTubeMediaConfiguration } from "./YouTubeMediaConfiguration";
@@ -123,6 +125,8 @@ const Resolvers: GQLResolver = {
UsernameHistory,
UsernameStatus,
UserStatus,
WarningStatus,
WarningStatusHistory,
WebhookEndpoint,
YouTubeMediaConfiguration,
};
+228 -31
View File
@@ -34,6 +34,11 @@ enum USER_AUTH_CONDITIONS {
remain after being deleted.
"""
PENDING_DELETION
"""
WARNED
"""
WARNED
}
"""
@@ -1921,7 +1926,7 @@ type BanStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2001,7 +2006,7 @@ type SuspensionStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2011,7 +2016,7 @@ type SuspensionStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2020,6 +2025,50 @@ type SuspensionStatus {
history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
type WarningStatusHistory {
"""
active when true, indicates that the given user has been warned but has not acknowledged the warning.
"""
active: Boolean!
"""
createdBy is the user that warned the commenter
"""
createdBy: User!
"""
createdAt is the time the user was warned
"""
createdAt: Time!
"""
acknowledgedAt is the time the commenter acknowledged the warning. if `null` then the warning
has not been acknowledged
"""
acknowledgedAt: Time
"""
message is the custom message sent to the commenter
"""
message: String!
}
type WarningStatus {
"""
active when true, indicates that the given user has been warned but has not acknowledged it.
"""
active: Boolean!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
message: String
history: [WarningStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
type PremodStatusHistory {
"""
active when true, indicates that the given user is premodded.
@@ -2057,7 +2106,7 @@ type UsernameHistory {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2072,7 +2121,7 @@ type UsernameHistory {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
}
@@ -2094,7 +2143,7 @@ type UserStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2117,6 +2166,11 @@ type UserStatus {
premod stores the user premod status as well as the history of changes.
"""
premod: PremodStatus! @auth(roles: [ADMIN, MODERATOR])
"""
warning stores the user warning status as well as the history of warnings
"""
warning: WarningStatus!
}
"""
@@ -2143,6 +2197,11 @@ enum USER_STATUS {
PREMOD is used when a User is currently set to require pre-moderation.
"""
PREMOD
"""
WARNED is used when a user has been warned about behaviour and has not acknowledged
"""
WARNED
}
type ModeratorNote {
@@ -2281,7 +2340,14 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
permit: [
MISSING_NAME
MISSING_EMAIL
SUSPENDED
BANNED
PENDING_DELETION
WARNED
]
)
"""
@@ -2299,7 +2365,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2309,7 +2375,14 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
permit: [
MISSING_NAME
MISSING_EMAIL
SUSPENDED
BANNED
PENDING_DELETION
WARNED
]
)
"""
@@ -2319,7 +2392,14 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
permit: [
MISSING_NAME
MISSING_EMAIL
SUSPENDED
BANNED
PENDING_DELETION
WARNED
]
)
"""
@@ -2345,7 +2425,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2369,7 +2449,10 @@ type User {
sorted by their last comment date.
"""
ongoingDiscussions(limit: Int = 5 @constraint(max: 5)): [Story!]!
@auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
recentCommentHistory returns recent commenting history by the User.
@@ -2397,7 +2480,7 @@ type User {
@auth(
roles: [ADMIN]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2407,14 +2490,17 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
notifications stores the notification settings for the given User.
"""
notifications: UserNotificationSettings!
@auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
createdAt is the time that the User was created at.
@@ -2429,7 +2515,7 @@ type User {
@auth(
userIDField: "id"
roles: [ADMIN, MODERATOR]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2440,7 +2526,7 @@ type User {
@auth(
userIDField: "id"
roles: [ADMIN, MODERATOR]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2450,7 +2536,7 @@ type User {
@auth(
userIDField: "id"
roles: [ADMIN, MODERATOR]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2461,7 +2547,7 @@ type User {
@auth(
userIDField: "id"
roles: [ADMIN, MODERATOR]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -2474,6 +2560,10 @@ type User {
mediaSettings are the user's preferences around media stream behaviour.
"""
mediaSettings: UserMediaSettings
@auth(
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
}
"""
@@ -2896,7 +2986,7 @@ type Comment {
@auth(
roles: [MODERATOR, ADMIN]
userIDField: "author_id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
)
"""
@@ -6452,15 +6542,89 @@ input UpdateUserRoleInput {
}
type UpdateUserRolePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
user is the possibly modified User.
"""
user: User!
}
##################
# warnUser
##################
input WarnUserInput {
"""
userID is the ID of the User that should have their account banned.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
message is displayed to the user in the stream
"""
message: String!
}
type WarnUserPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
user is the possibly modified User.
"""
user: User!
}
input RemoveUserWarningInput {
"""
userID is the ID of the User that should be warned.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveUserWarningPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
user is the possibly modified User.
"""
user: User!
}
input AcknowledgeWarningInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type AcknowledgeWarningPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
user is the possibly modified User.
"""
user: User!
}
##################
@@ -7279,14 +7443,21 @@ type Mutation {
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
permit: [
MISSING_NAME
MISSING_EMAIL
SUSPENDED
BANNED
PENDING_DELETION
WARNED
]
)
"""
updateUsername will update the users username.
"""
updateUsername(input: UpdateUsernameInput!): UpdateUsernamePayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
@rate(seconds: 10)
"""
@@ -7295,7 +7466,14 @@ type Mutation {
"""
setEmail(input: SetEmailInput!): SetEmailPayload!
@auth(
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
permit: [
MISSING_NAME
MISSING_EMAIL
SUSPENDED
BANNED
PENDING_DELETION
WARNED
]
)
"""
@@ -7309,7 +7487,7 @@ type Mutation {
they already have one associated with them.
"""
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
@rate(seconds: 10)
"""
@@ -7319,7 +7497,7 @@ type Mutation {
requestAccountDeletion(
input: RequestAccountDeletionInput!
): RequestAccountDeletionPayload!
@auth(permit: [SUSPENDED, BANNED])
@auth(permit: [SUSPENDED, BANNED, WARNED])
@rate(seconds: 10)
"""
@@ -7335,7 +7513,7 @@ type Mutation {
cancelAccountDeletion(
input: CancelAccountDeletionInput!
): CancelAccountDeletionPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
"""
createToken allows an administrator to create a Token based on the current
@@ -7363,7 +7541,7 @@ type Mutation {
updateEmail will update the current users email address.
"""
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
@rate(seconds: 10)
"""
@@ -7373,7 +7551,7 @@ type Mutation {
updateNotificationSettings(
input: UpdateNotificationSettingsInput!
): UpdateNotificationSettingsPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
"""
updateUserMediaSettings can be used to update the media preferences for the
@@ -7430,6 +7608,25 @@ type Mutation {
suspendUser(input: SuspendUserInput!): SuspendUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
warnUser will warn a user and prevent them from commenting until they acknowledge
"""
warnUser(input: WarnUserInput!): WarnUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeUserWarning will remove a user warning
"""
removeUserWarning(input: RemoveUserWarningInput!): RemoveUserWarningPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
acknowledgWarning will remove a warning
"""
acknowledgeWarning(
input: AcknowledgeWarningInput!
): AcknowledgeWarningPayload @auth(permit: [WARNED])
"""
removeUserSuspension will remove an active suspension from a User if they have
one.
@@ -7442,14 +7639,14 @@ type Mutation {
ignoreUser will mark the given User as ignored by the current logged in User.
"""
ignoreUser(input: IgnoreUserInput!): IgnoreUserPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
"""
removeUserIgnore will remove the given User from the ignored user list from
the current logged in User.
"""
removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
"""
requestCommentsDownload allows a user to request to download their comments.
@@ -7457,7 +7654,7 @@ type Mutation {
requestCommentsDownload(
input: RequestCommentsDownloadInput!
): RequestCommentsDownloadPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
"""
requestUserCommentsDownload allows a user to request to download their comments.
+3
View File
@@ -46,6 +46,9 @@ error-userAlreadySuspended = The user already has an active suspension until {$u
error-userAlreadyBanned = The user is already banned.
error-userBanned = Your account is currently banned.
error-userSuspended = Your account is currently suspended until {$until}.
error-userWarned =
Your account has been issued a warning: {$message}.
To continue participating in discussions, please press the "Acknowledge" button below.
error-integrationDisabled = Specified integration is disabled.
error-passwordResetTokenExpired = Password reset link expired.
error-emailConfirmTokenExpired = Email confirmation link expired.
+255
View File
@@ -41,6 +41,7 @@ import {
GQLUSER_ROLE,
GQLUsernameStatus,
GQLUserNotificationSettings,
GQLWarningStatus,
} from "coral-server/graph/schema/__generated__/types";
import {
@@ -300,6 +301,36 @@ export interface PremodStatus {
history: PremodStatusHistory[];
}
export interface WarningStatusHistory {
/**
* active when true, indicates that the given user has not acknowledged the warning.
*/
active: boolean;
/**
* createdBy is the user that created this warning
*/
createdBy: string;
/**
* createdAt is the time the username was created
*/
createdAt: Date;
/**
* acknowledgedAt is the time the commneter acknowledged it
*/
acknowledgedAt?: Date;
/**
* message is the message sent to the commenter
*/
message?: string;
}
export interface WarningStatus {
active: boolean;
history: WarningStatusHistory[];
}
/**
* UserStatus stores the user status information regarding moderation state.
*/
@@ -325,6 +356,12 @@ export interface UserStatus {
* premod status.
*/
premod: PremodStatus;
/**
* warning stores whether a user has an unacknowledge warning and a history of
* warnings
*/
warning?: WarningStatus;
}
/**
@@ -545,6 +582,7 @@ async function findOrCreateUserInput(
suspension: { history: [] },
ban: { active: false, history: [] },
premod: { active: false, history: [] },
warning: { active: false, history: [] },
},
notifications: {
onReply: false,
@@ -1851,6 +1889,201 @@ export async function removeActiveUserSuspensions(
return result.value;
}
export async function warnUser(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
message?: string,
now = new Date()
) {
// Create the new warning.
const warningHistory: WarningStatusHistory = {
active: true,
createdBy,
createdAt: now,
message,
};
// Try to update the user if the user isn't already warned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"status.warning.active": {
$ne: true,
},
},
{
$set: {
"status.warning.active": true,
},
$push: {
"status.warning.history": warningHistory,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ban operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// Check to see if the user is already warned.
const warning = consolidateUserWarningStatus(user.status.warning);
if (warning && warning.active) {
throw new Error("User already warned");
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* removeUserWarning will remove a warning from a User allowing them to interact with
* the site again.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user having their warning removed
* @param modifiedBy the ID of the user removing the warning
* @param now the current date
*/
export async function removeUserWarning(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
now = new Date()
) {
// Create the new history entry.
const update: WarningStatusHistory = {
active: false,
createdBy,
createdAt: now,
};
// Try to update the user if the user isn't already warned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
$or: [
{
"status.warning.active": {
$ne: false,
},
},
{
"status.warning.history": {
$size: 0,
},
},
],
},
{
$set: {
"status.warning.active": false,
},
$push: {
"status.warning.history": update,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the warn operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// The user wasn't warned already, so nothing needs to be done
return user;
}
return result.value;
}
/**
* acknowledgeWarning will remove a warning from a User allowing them to interact with
* the site again.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user having their warning removed
* @param now the current date
*/
export async function acknowledgeOwnWarning(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
// Create the new update.
const update: WarningStatusHistory = {
active: false,
acknowledgedAt: now,
createdAt: now,
createdBy: id,
};
// Try to update the user if the user isn't already warned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
$or: [
{
"status.warning.active": {
$ne: false,
},
},
{
"status.warning.history": {
$size: 0,
},
},
],
},
{
$set: {
"status.warning.active": false,
},
$push: {
"status.warning.history": update,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the warn operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// The user wasn't warned already, so nothing needs to be done!
return user;
}
return result.value;
}
export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
Pick<BanStatus, "history">;
@@ -1860,6 +2093,9 @@ export type ConsolidatedUsernameStatus = Omit<GQLUsernameStatus, "history"> &
export type ConsolidatedPremodStatus = Omit<GQLPremodStatus, "history"> &
Pick<PremodStatus, "history">;
export type ConsolidatedWarningStatus = Omit<GQLWarningStatus, "history"> &
Pick<PremodStatus, "history">;
export function consolidateUsernameStatus(
username: User["status"]["username"]
) {
@@ -1874,6 +2110,23 @@ export function consolidateUserPremodStatus(premod: User["status"]["premod"]) {
return premod;
}
export function consolidateUserWarningStatus(
warning: User["status"]["warning"]
) {
if (!warning) {
return {
active: false,
history: [],
};
}
const activeWarning = warning.history[warning.history.length - 1];
return {
active: warning.active,
history: warning.history,
message: activeWarning ? activeWarning.message : undefined,
};
}
export type ConsolidatedSuspensionStatus = Omit<
GQLSuspensionStatus,
"history"
@@ -1909,6 +2162,7 @@ export interface ConsolidatedUserStatus {
suspension: ConsolidatedSuspensionStatus;
ban: ConsolidatedBanStatus;
premod: ConsolidatedPremodStatus;
warning: ConsolidatedWarningStatus;
}
export function consolidateUserStatus(
@@ -1920,6 +2174,7 @@ export function consolidateUserStatus(
suspension: consolidateUserSuspensionStatus(status.suspension, now),
ban: consolidateUserBanStatus(status.ban),
premod: consolidateUserPremodStatus(status.premod),
warning: consolidateUserWarningStatus(status.warning),
};
}
+86
View File
@@ -38,11 +38,13 @@ import {
Tenant,
} from "coral-server/models/tenant";
import {
acknowledgeOwnWarning,
banUser,
clearDeletionDate,
consolidateUserBanStatus,
consolidateUserPremodStatus,
consolidateUserSuspensionStatus,
consolidateUserWarningStatus,
createModeratorNote,
createUser,
createUserToken,
@@ -58,6 +60,7 @@ import {
removeUserBan,
removeUserIgnore,
removeUserPremod,
removeUserWarning,
retrieveUser,
retrieveUserWithEmail,
scheduleDeletionDate,
@@ -78,6 +81,7 @@ import {
User,
UserModerationScopes,
verifyUserPassword,
warnUser,
} from "coral-server/models/user";
import {
getLocalProfile,
@@ -1008,6 +1012,88 @@ export async function removePremod(
// For each of the suspensions, remove it.
return removeUserPremod(mongo, tenant.id, userID, moderator.id, now);
}
/**
* warn will warn a specific user.
*
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be warned on
* @param moderator the User that is warning the User
* @param userID the ID of the User being warned
* @param now the current time that the warning took effect
*/
export async function warn(
mongo: Db,
tenant: Tenant,
moderator: User,
userID: string,
message: string,
now = new Date()
) {
// Get the user being warned to check to see if the user already has an
// existing warning.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently warned.
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
if (warningStatus.active) {
throw new Error("User already warned");
}
// Ban the user.
return warnUser(mongo, tenant.id, userID, moderator.id, message, now);
}
export async function removeWarning(
mongo: Db,
tenant: Tenant,
moderator: User,
userID: string,
now = new Date()
) {
// Get the user being suspended to check to see if the user already has an
// existing warning.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently warned.
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
if (!warningStatus.active) {
// The user is not warned currently, just return the user because we
// don't have to do anything.
return targetUser;
}
// remove warning.
return removeUserWarning(mongo, tenant.id, userID, moderator.id, now);
}
export async function acknowledgeWarning(
mongo: Db,
tenant: Tenant,
userID: string,
now = new Date()
) {
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
if (!warningStatus.active) {
// The user is not warned currently, just return the user because we
// don't have to do anything.
return targetUser;
}
// remove warning
return acknowledgeOwnWarning(mongo, tenant.id, userID, now);
}
/**
* suspend will suspend a give user from interacting with Coral.
*
+17
View File
@@ -25,6 +25,7 @@ userStatus-active = Active
userStatus-banned = Banned
userStatus-suspended = Suspended
userStatus-premod = Always pre-moderate
userStatus-warned = Warned
## Navigation
navigation-moderate = Moderate
@@ -1150,6 +1151,18 @@ community-invite-invitationsSent = Your invitations have been sent!
community-invite-close = Close
community-invite-invite = Invite
community-warnModal-success =
A warning has been sent to <strong>{ $username }</strong>.
community-warnModal-success-close = Ok
community-warnModal-areYouSure = Warn <strong>{ $username }</strong>?
community-warnModal-consequence = A warning can improve a commenter's conduct without a suspension or ban. The user must acknowledge the warning before they can continue commenting.
community-warnModal-message-label = Message
community-warnModal-message-required = Required
community-warnModal-message-description = Explain to this user how they should change their behavior on your site.
community-warnModal-cancel = Cancel
community-warnModal-warnUser = Warn user
community-userStatus-warn = Warn
## Stories
stories-emptyMessage = There are currently no published stories.
stories-noMatchMessage = We could not find any stories matching your criteria.
@@ -1211,6 +1224,10 @@ userDetails-suspended-by = <strong>Suspended by</strong> { $username }
userDetails-suspension-start = <strong>Start:</strong> { $timestamp }
userDetails-suspension-end = <strong>End:</strong> { $timestamp }
userDetails-warned-on = <strong>Warned on</strong> { $timestamp }
userDetails-warned-by = <strong>by</strong> { $username }
userDetails-warned-explanation = User has not acknowledged the warning.
configure-general-reactions-title = Reactions
configure-general-reactions-explanation =
Allow your community to engage with one another and express themselves
+7
View File
@@ -712,6 +712,13 @@ suspendInfo-description-inAccordanceWith =
suspendInfo-until-pleaseRejoinThe =
Please rejoin the conversation on { $until }
warning-heading = Your account has been issued a warning
warning-explanation =
In accordance with our community guidelines your account has been issued a warning.
warning-instructions =
To continue participating in discussions, please press the "Acknowledge" button below.
warning-acknowledge = Acknowledge
profile-changeEmail-unverified = (Unverified)
profile-changeEmail-current = (current)
profile-changeEmail-edit = Edit