diff --git a/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx index 3479c0fa6..68c0dc6ea 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx @@ -1,14 +1,19 @@ import React, { FunctionComponent } from "react"; import BanAction, { BanActionProps } from "./BanAction"; +import PremodAction, { PremodActionProps } from "./PremodAction"; import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction"; import UsernameChangeAction, { UsernameChangeActionProps, } from "./UsernameChangeAction"; export interface HistoryActionProps { - kind: "username" | "suspension" | "ban"; - action: UsernameChangeActionProps | SuspensionActionProps | BanActionProps; + kind: "username" | "suspension" | "ban" | "premod"; + action: + | UsernameChangeActionProps + | SuspensionActionProps + | BanActionProps + | PremodActionProps; } const AccountHistoryAction: FunctionComponent = ({ @@ -22,6 +27,8 @@ const AccountHistoryAction: FunctionComponent = ({ return ; case "ban": return ; + case "premod": + return ; default: return null; } diff --git a/src/core/client/admin/components/UserHistoryDrawer/PremodAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/PremodAction.tsx new file mode 100644 index 000000000..046fa800e --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/PremodAction.tsx @@ -0,0 +1,19 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +export interface PremodActionProps { + action: "created" | "removed"; +} + +const PremodAction: FunctionComponent = ({ action }) => + action === "created" ? ( + + Set always premoderate + + ) : ( + + Removed always premoderate + + ); + +export default PremodAction; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx index 93d7d1ba6..a3520dcf8 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx @@ -107,6 +107,21 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { }); }); + // FIXME: (wyattjoh) once migration has been performed, remove check + if (user.status.premod) { + // Merge in all the premod history items. + user.status.premod.history.forEach(record => { + history.push({ + kind: "premod", + action: { + action: record.active ? "created" : "removed", + }, + date: new Date(record.createdAt), + takenBy: record.createdBy ? record.createdBy.username : system, + }); + }); + } + user.status.username.history.forEach((record, i) => { history.push({ kind: "username", @@ -190,6 +205,15 @@ const enhanced = withFragmentContainer({ createdAt } } + premod { + history { + active + createdBy { + username + } + createdAt + } + } suspension { history { active diff --git a/src/core/client/admin/components/UserStatus/PremodModal.css b/src/core/client/admin/components/UserStatus/PremodModal.css new file mode 100644 index 000000000..a85052914 --- /dev/null +++ b/src/core/client/admin/components/UserStatus/PremodModal.css @@ -0,0 +1,3 @@ +.card { + max-width: 500px; +} \ No newline at end of file diff --git a/src/core/client/admin/components/UserStatus/PremodModal.tsx b/src/core/client/admin/components/UserStatus/PremodModal.tsx new file mode 100644 index 000000000..830754cc5 --- /dev/null +++ b/src/core/client/admin/components/UserStatus/PremodModal.tsx @@ -0,0 +1,79 @@ +import { + Button, + Card, + CardCloseButton, + Flex, + HorizontalGutter, + Modal, + Typography, +} from "coral-ui/components"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import NotAvailable from "coral-admin/components/NotAvailable"; + +import styles from "./PremodModal.css"; + +interface Props { + username: string | null; + open: boolean; + onClose: () => void; + onConfirm: () => void; +} + +const PremodModal: FunctionComponent = ({ + open, + onClose, + onConfirm, + username, +}) => { + return ( + + {({ firstFocusableRef, lastFocusableRef }) => ( + + + + + } + $username={username || } + > + + Are you sure you want to always premoderate{" "} + {username || }? + + + + + Note: Always premoderating this user will place all of their + comments in the Pre-Moderate queue. + + + + + + + + + + + + + + + )} + + ); +}; + +export default PremodModal; diff --git a/src/core/client/admin/components/UserStatus/PremodUserMutation.ts b/src/core/client/admin/components/UserStatus/PremodUserMutation.ts new file mode 100644 index 000000000..42b392dec --- /dev/null +++ b/src/core/client/admin/components/UserStatus/PremodUserMutation.ts @@ -0,0 +1,82 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { PremodUserMutation as MutationTypes } from "coral-admin/__generated__/PremodUserMutation.graphql"; +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; + +const PremodUserMutation = createMutation( + "premodUser", + (environment: Environment, input: MutationInput) => { + const viewer = getViewer(environment)!; + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation PremodUserMutation($input: PremodUserInput!) { + premodUser(input: $input) { + user { + id + status { + current + premod { + active + history { + active + createdAt + createdBy { + id + username + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + premodUser: { + user: { + id: input.userID, + status: { + current: lookup( + environment, + input.userID + )!.status.current.concat(GQLUSER_STATUS.PREMOD), + premod: { + active: true, + history: [ + { + active: true, + createdAt: new Date(), + createdBy: { + id: viewer.id, + username: viewer.username, + }, + }, + ], + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + } +); + +export default PremodUserMutation; diff --git a/src/core/client/admin/components/UserStatus/RemoveUserPremodMutation.ts b/src/core/client/admin/components/UserStatus/RemoveUserPremodMutation.ts new file mode 100644 index 000000000..02d1bbb5a --- /dev/null +++ b/src/core/client/admin/components/UserStatus/RemoveUserPremodMutation.ts @@ -0,0 +1,82 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { RemoveUserPremodMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserPremodMutation.graphql"; +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; + +const RemoveUserPremodMutation = createMutation( + "removeUserPremod", + (environment: Environment, input: MutationInput) => { + const viewer = getViewer(environment)!; + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RemoveUserPremodMutation($input: RemovePremodUserInput!) { + removeUserPremod(input: $input) { + user { + id + status { + current + premod { + active + history { + active + createdAt + createdBy { + id + username + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + optimisticResponse: { + removeUserPremod: { + user: { + id: input.userID, + status: { + current: lookup( + environment, + input.userID + )!.status.current.filter(s => s !== GQLUSER_STATUS.PREMOD), + premod: { + active: false, + history: [ + { + active: false, + createdAt: new Date(), + createdBy: { + id: viewer.id, + username: viewer.username, + }, + }, + ], + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + } +); + +export default RemoveUserPremodMutation; diff --git a/src/core/client/admin/components/UserStatus/UserStatus.tsx b/src/core/client/admin/components/UserStatus/UserStatus.tsx index ad8f7c914..d80d68ff7 100644 --- a/src/core/client/admin/components/UserStatus/UserStatus.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatus.tsx @@ -7,6 +7,7 @@ import { PropTypesOf } from "coral-ui/types"; interface Props { banned: boolean; suspended: boolean; + premod: boolean; } const render = ( @@ -39,6 +40,15 @@ const UserStatus: FunctionComponent = props => { ); } + if (props.premod) { + return render( + "warning", + // tslint:disable-next-line:jsx-wrap-multiline + +
Always Premoderated
+
+ ); + } return render( "success", // tslint:disable-next-line:jsx-wrap-multiline diff --git a/src/core/client/admin/components/UserStatus/UserStatusChange.tsx b/src/core/client/admin/components/UserStatus/UserStatusChange.tsx index 41ebc98c0..bbbecd6bd 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusChange.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusChange.tsx @@ -18,8 +18,11 @@ interface Props { onRemoveBan: () => void; onSuspend: () => void; onRemoveSuspension: () => void; + onPremod: () => void; + onRemovePremod: () => void; banned: boolean; suspended: boolean; + premod: boolean; children: React.ReactNode; fullWidth?: boolean; } @@ -29,8 +32,11 @@ const UserStatusChange: FunctionComponent = ({ onRemoveBan, onSuspend, onRemoveSuspension, + onPremod, + onRemovePremod, banned, suspended, + premod, children, fullWidth = true, }) => ( @@ -42,21 +48,8 @@ const UserStatusChange: FunctionComponent = ({ body={({ toggleVisibility }) => ( - {!banned && ( - - { - onBan(); - toggleVisibility(); - }} - > - Ban User - - - )} - {banned && ( - + {banned ? ( + { @@ -64,25 +57,24 @@ const UserStatusChange: FunctionComponent = ({ toggleVisibility(); }} > - Remove Ban + Remove ban - )} - {!suspended && ( - + ) : ( + { - onSuspend(); + onBan(); toggleVisibility(); }} > - Suspend User + Ban )} - {suspended && ( - + {suspended ? ( + { @@ -90,7 +82,44 @@ const UserStatusChange: FunctionComponent = ({ toggleVisibility(); }} > - Remove Suspension + Remove suspension + + + ) : ( + + { + onSuspend(); + toggleVisibility(); + }} + > + Suspend + + + )} + {premod ? ( + + { + onRemovePremod(); + toggleVisibility(); + }} + > + Remove always pre-moderate + + + ) : ( + + { + onPremod(); + toggleVisibility(); + }} + > + Always pre-moderate )} diff --git a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx index 0b586aea5..1dea6dd91 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusChangeContainer.tsx @@ -12,7 +12,10 @@ import { GQLUSER_ROLE } from "coral-framework/schema"; import ButtonPadding from "../ButtonPadding"; import BanModal from "./BanModal"; import BanUserMutation from "./BanUserMutation"; +import PremodModal from "./PremodModal"; +import PremodUserMutation from "./PremodUserMutation"; import RemoveUserBanMutation from "./RemoveUserBanMutation"; +import RemoveUserPremodMudtaion from "./RemoveUserPremodMutation"; import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation"; import SuspendModal from "./SuspendModal"; import SuspendUserMutation from "./SuspendUserMutation"; @@ -31,6 +34,9 @@ const UserStatusChangeContainer: FunctionComponent = props => { const suspendUser = useMutation(SuspendUserMutation); const removeUserBan = useMutation(RemoveUserBanMutation); const removeUserSuspension = useMutation(RemoveUserSuspensionMutation); + const premodUser = useMutation(PremodUserMutation); + const removeUserPremod = useMutation(RemoveUserPremodMudtaion); + const [showPremod, setShowPremod] = useState(false); const [showBanned, setShowBanned] = useState(false); const [showSuspend, setShowSuspend] = useState(false); const [showSuspendSuccess, setShowSuspendSuccess] = useState(false); @@ -59,6 +65,31 @@ const UserStatusChangeContainer: FunctionComponent = props => { removeUserSuspension({ userID: user.id }); }, [user, removeUserSuspension]); + const handlePremod = useCallback(() => { + // FIXME: (wyattjoh) once migration has been performed, remove check + if (user.status.premod && user.status.premod.active) { + return; + } + setShowPremod(true); + }, [user, setShowPremod]); + + const handlePremodConfirm = useCallback(() => { + premodUser({ userID: user.id }); + setShowPremod(false); + }, [premodUser, user, setShowPremod]); + + const hidePremod = useCallback(() => { + setShowPremod(false); + }, [setShowPremod]); + + const handleRemovePremod = useCallback(() => { + // FIXME: (wyattjoh) once migration has been performed, remove check + if (!user.status.premod || !user.status.premod.active) { + return; + } + removeUserPremod({ userID: user.id }); + }, [user, premodUser]); + const handleSuspendModalClose = useCallback(() => { setShowSuspend(false); setShowSuspendSuccess(false); @@ -103,8 +134,12 @@ const UserStatusChangeContainer: FunctionComponent = props => { onRemoveBan={handleRemoveBan} onSuspend={handleSuspend} onRemoveSuspension={handleRemoveSuspension} + onPremod={handlePremod} + onRemovePremod={handleRemovePremod} banned={user.status.ban.active} suspended={user.status.suspension.active} + // FIXME: (wyattjoh) once migration has been performed, remove check + premod={Boolean(user.status.premod && user.status.premod.active)} fullWidth={fullWidth} > @@ -117,6 +152,12 @@ const UserStatusChangeContainer: FunctionComponent = props => { organizationName={settings.organization.name} onConfirm={handleSuspendConfirm} /> + ({ suspension { active } + premod { + active + } } ...UserStatusContainer_user } diff --git a/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx b/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx index 07c2d7d6e..09d003453 100644 --- a/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx +++ b/src/core/client/admin/components/UserStatus/UserStatusContainer.tsx @@ -16,6 +16,7 @@ const UserStatusContainer: FunctionComponent = props => { ); }; diff --git a/src/core/client/admin/routes/Community/UserTableFilter.tsx b/src/core/client/admin/routes/Community/UserTableFilter.tsx index bce393bec..1a87c293b 100644 --- a/src/core/client/admin/routes/Community/UserTableFilter.tsx +++ b/src/core/client/admin/routes/Community/UserTableFilter.tsx @@ -178,6 +178,11 @@ const UserTableFilter: FunctionComponent = props => ( + + + diff --git a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap index 6dea16331..179558b57 100644 --- a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap +++ b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap @@ -166,6 +166,11 @@ exports[`renders community 1`] = ` > Banned + Banned + { TestRenderer.act(() => { within(popup) - .getByText("Suspend User", { selector: "button" }) + .getByText("Suspend", { selector: "button" }) .props.onClick(); }); @@ -515,7 +515,7 @@ it("remove user suspension", async () => { TestRenderer.act(() => { within(popup) - .getByText("Remove Suspension", { selector: "button" }) + .getByText("Remove suspension", { selector: "button" }) .props.onClick(); }); expect(resolvers.Mutation!.removeUserSuspension!.called).toBe(true); @@ -566,7 +566,7 @@ it("suspend user with custom timeout", async () => { TestRenderer.act(() => { within(popup) - .getByText("Suspend User", { selector: "button" }) + .getByText("Suspend", { selector: "button" }) .props.onClick(); }); @@ -632,7 +632,7 @@ it("suspend user with custom message", async () => { TestRenderer.act(() => { within(popup) - .getByText("Suspend User", { selector: "button" }) + .getByText("Suspend", { selector: "button" }) .props.onClick(); }); @@ -704,7 +704,7 @@ it("ban user", async () => { TestRenderer.act(() => { within(popup) - .getByText("Ban User", { selector: "button" }) + .getByText("Ban", { selector: "button" }) .props.onClick(); }); @@ -767,7 +767,7 @@ it("ban user with custom message", async () => { TestRenderer.act(() => { within(popup) - .getByText("Ban User", { selector: "button" }) + .getByText("Ban", { selector: "button" }) .props.onClick(); }); @@ -853,7 +853,7 @@ it("remove user ban", async () => { TestRenderer.act(() => { within(popup) - .getByText("Remove Ban", { selector: "button" }) + .getByText("Remove ban", { selector: "button" }) .props.onClick(); }); diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 8d70943d3..8df673d5f 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -321,6 +321,10 @@ export const baseUser = createFixture({ active: false, history: [], }, + premod: { + active: false, + history: [], + }, }, }); diff --git a/src/core/client/stream/test/helpers/fixture.ts b/src/core/client/stream/test/helpers/fixture.ts index 18a42a9ae..0874ef9c5 100644 --- a/src/core/client/stream/test/helpers/fixture.ts +++ b/src/core/client/stream/test/helpers/fixture.ts @@ -40,6 +40,10 @@ export function createUserStatus(banned: boolean = false): GQLUserStatus { username: { history: [], }, + premod: { + active: false, + history: [], + }, }; } diff --git a/src/core/client/test/helpers/fixture.ts b/src/core/client/test/helpers/fixture.ts index fc7c9f1bd..39988f486 100644 --- a/src/core/client/test/helpers/fixture.ts +++ b/src/core/client/test/helpers/fixture.ts @@ -39,6 +39,10 @@ export function createUserStatus(banned: boolean = false) { until: null, history: [], }, + premod: { + active: false, + history: [], + }, }; } @@ -143,6 +147,7 @@ export function createComment(author?: GQLUser) { COMMENT_DETECTED_LINKS: 0, COMMENT_DETECTED_BANNED_WORD: 0, COMMENT_DETECTED_SUSPECT_WORD: 0, + COMMENT_DETECTED_PREMOD_USER: 0, }, }, }, diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts index 0d557912c..4405b25c6 100644 --- a/src/core/common/errors.ts +++ b/src/core/common/errors.ts @@ -314,4 +314,6 @@ export enum ERROR_CODES { * all non-admin users. */ RAW_QUERY_NOT_AUTHORIZED = "RAW_QUERY_NOT_AUTHORIZED", + + USER_ALREADY_PREMOD = "USER_ALREADY_PREMOD", } diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index bc1791420..b60eddf52 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -593,6 +593,14 @@ export class UserAlreadySuspendedError extends CoralError { } } +export class UserAlreadyPremoderated extends CoralError { + constructor() { + super({ + code: ERROR_CODES.USER_ALREADY_PREMOD, + }); + } +} + export class UserAlreadyBannedError extends CoralError { constructor() { super({ diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index 5bb374a4c..b990bbbf2 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -54,4 +54,5 @@ export const ERROR_TRANSLATIONS: Record = { USERNAME_UPDATED_WITHIN_WINDOW: "error-usernameAlreadyUpdated", PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound", RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized", + USER_ALREADY_PREMOD: "error-userAlreadyPremod", }; diff --git a/src/core/server/graph/tenant/loaders/Users.ts b/src/core/server/graph/tenant/loaders/Users.ts index 6d0d3ea65..0189ea50c 100644 --- a/src/core/server/graph/tenant/loaders/Users.ts +++ b/src/core/server/graph/tenant/loaders/Users.ts @@ -40,6 +40,7 @@ const statusFilter = ( case GQLUSER_STATUS.ACTIVE: return { "status.ban.active": false, + "status.premod.active": false, "status.suspension.history": { $not: { $elemMatch: { @@ -55,6 +56,8 @@ const statusFilter = ( }; case GQLUSER_STATUS.BANNED: return { "status.ban.active": true }; + case GQLUSER_STATUS.PREMOD: + return { "status.premod.active": true }; case GQLUSER_STATUS.SUSPENDED: return { "status.suspension.history": { diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 596ad24c2..9020bf2fc 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -8,8 +8,10 @@ import { createToken, deactivateToken, ignore, + premod, removeBan, removeIgnore, + removePremod, removeSuspension, requestAccountDeletion, requestCommentsDownload, @@ -38,6 +40,8 @@ import { GQLDeleteUserAccountInput, GQLIgnoreUserInput, GQLInviteUsersInput, + GQLPremodUserInput, + GQLRemovePremodUserInput, GQLRemoveUserBanInput, GQLRemoveUserIgnoreInput, GQLRemoveUserSuspensionInput, @@ -209,6 +213,8 @@ export const Users = (ctx: TenantContext) => ({ input.message, ctx.now ), + premodUser: async (input: GQLPremodUserInput) => + premod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now), suspend: async (input: GQLSuspendUserInput) => suspend( ctx.mongo, @@ -224,6 +230,8 @@ export const Users = (ctx: TenantContext) => ({ removeBan(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now), removeSuspension: async (input: GQLRemoveUserSuspensionInput) => removeSuspension(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now), + removeUserPremod: async (input: GQLRemovePremodUserInput) => + removePremod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now), ignore: async (input: GQLIgnoreUserInput) => ignore(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now), removeIgnore: async (input: GQLRemoveUserIgnoreInput) => diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 7b5362ad7..d5f23edd2 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -181,6 +181,14 @@ export const Mutation: Required> = { user: await ctx.mutators.Users.suspend(input), clientMutationId: input.clientMutationId, }), + premodUser: async (source, { input }, ctx) => ({ + user: await ctx.mutators.Users.premodUser(input), + clientMutationId: input.clientMutationId, + }), + removeUserPremod: async (source, { input }, ctx) => ({ + user: await ctx.mutators.Users.removeUserPremod(input), + clientMutationId: input.clientMutationId, + }), removeUserSuspension: async (source, { input }, ctx) => ({ user: await ctx.mutators.Users.removeSuspension(input), clientMutationId: input.clientMutationId, diff --git a/src/core/server/graph/tenant/resolvers/PremodStatus.ts b/src/core/server/graph/tenant/resolvers/PremodStatus.ts new file mode 100644 index 000000000..c2522a38b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/PremodStatus.ts @@ -0,0 +1,14 @@ +import { GQLPremodStatusTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import * as user from "coral-server/models/user"; + +export type PremodStatusInput = user.ConsolidatedPremodStatus & { + userID: string; +}; + +export const PremodStatus: Required< + GQLPremodStatusTypeResolver +> = { + active: ({ active }) => active, + history: ({ history, userID }) => + history.map(status => ({ ...status, userID })), +}; diff --git a/src/core/server/graph/tenant/resolvers/PremodStatusHistory.ts b/src/core/server/graph/tenant/resolvers/PremodStatusHistory.ts new file mode 100644 index 000000000..44d8557a9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/PremodStatusHistory.ts @@ -0,0 +1,16 @@ +import { GQLPremodStatusHistoryTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import * as user from "coral-server/models/user"; + +export const PremodStatusHistory: Required< + GQLPremodStatusHistoryTypeResolver +> = { + active: ({ active }) => active, + createdBy: ({ createdBy }, input, ctx) => { + if (createdBy) { + return ctx.loaders.Users.user.load(createdBy); + } + + return null; + }, + createdAt: ({ createdAt }) => createdAt, +}; diff --git a/src/core/server/graph/tenant/resolvers/UserStatus.ts b/src/core/server/graph/tenant/resolvers/UserStatus.ts index f72a76abd..568d24a32 100644 --- a/src/core/server/graph/tenant/resolvers/UserStatus.ts +++ b/src/core/server/graph/tenant/resolvers/UserStatus.ts @@ -5,6 +5,7 @@ import { import * as user from "coral-server/models/user"; import { BanStatusInput } from "./BanStatus"; +import { PremodStatusInput } from "./PremodStatus"; import { SuspensionStatusInput } from "./SuspensionStatus"; import { UsernameStatusInput } from "./UsernameStatus"; @@ -29,6 +30,12 @@ export const UserStatus: Required< statuses.push(GQLUSER_STATUS.SUSPENDED); } + // If they are set to mandatory premod, then mark it. + // FIXME: (wyattjoh) once migration has been performed, remove check + if (consolidatedStatus.premod && consolidatedStatus.premod.active) { + statuses.push(GQLUSER_STATUS.PREMOD); + } + // If no other statuses were applied, then apply the active status. if (statuses.length === 0) { statuses.push(GQLUSER_STATUS.ACTIVE); @@ -48,4 +55,17 @@ export const UserStatus: Required< ...user.consolidateUserSuspensionStatus(suspension), userID, }), + // FIXME: (wyattjoh) once migration has been performed, return PremodStatusInput only + premod: ({ premod, userID }): PremodStatusInput | null => { + const status = user.consolidateUserPremodStatus(premod); + // FIXME: (wyattjoh) once migration has been performed, remove check + if (!status) { + return null; + } + + return { + ...status, + userID, + }; + }, }; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 8404e5beb..80f2e922e 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -29,6 +29,8 @@ import { ModerationQueue } from "./ModerationQueue"; import { ModerationQueues } from "./ModerationQueues"; import { Mutation } from "./Mutation"; import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; +import { PremodStatus } from "./PremodStatus"; +import { PremodStatusHistory } from "./PremodStatusHistory"; import { Profile } from "./Profile"; import { Query } from "./Query"; import { RecentCommentHistory } from "./RecentCommentHistory"; @@ -72,6 +74,8 @@ const Resolvers: GQLResolver = { ModerationQueues, Mutation, OIDCAuthIntegration, + PremodStatus, + PremodStatusHistory, Profile, Query, RecentCommentHistory, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 674369f16..8f6c089f0 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -141,6 +141,11 @@ enum COMMENT_FLAG_DETECTED_REASON { recent history of rejected comments. """ COMMENT_DETECTED_RECENT_HISTORY + + """ + COMMENT_DETECTED_PREMOD_USER is used when a Comment author has been tagged as requiring premoderation + """ + COMMENT_DETECTED_PREMOD_USER } """ @@ -157,6 +162,7 @@ enum COMMENT_FLAG_REASON { COMMENT_DETECTED_BANNED_WORD COMMENT_DETECTED_SUSPECT_WORD COMMENT_DETECTED_RECENT_HISTORY + COMMENT_DETECTED_PREMOD_USER } """ @@ -191,6 +197,7 @@ type FlagReasonActionCounts { COMMENT_DETECTED_BANNED_WORD: Int! COMMENT_DETECTED_SUSPECT_WORD: Int! COMMENT_DETECTED_RECENT_HISTORY: Int! + COMMENT_DETECTED_PREMOD_USER: Int! } type Flag { @@ -1440,6 +1447,34 @@ type SuspensionStatus { history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR]) } +type PremodStatusHistory { + """ + active when true, indicates that the given user is premodded. + """ + active: Boolean! + """ + createdBy is the user that flagged the commenter as pre-mod + """ + createdBy: User! + + """ + createdAt is the time the user was set to pre-mod + """ + createdAt: Time! +} + +type PremodStatus { + """ + active when true, indicates that the given user is set to pre-mod. + """ + active: Boolean! + + """ + history is the list of all suspension events against a specific User. + """ + history: [PremodStatusHistory!]! +} + type UsernameHistory { """ username is the username that was assigned @@ -1503,6 +1538,13 @@ type UserStatus { changes. """ suspension: SuspensionStatus! + + """ + premod stores the user premod status as well as the history of changes. + + FIXME: (wyattjoh) once migration has been performed, make non-nullable + """ + premod: PremodStatus @auth(roles: [ADMIN, MODERATOR]) } """ @@ -1524,6 +1566,11 @@ enum USER_STATUS { SUSPENDED is used when a User is currently suspended. """ SUSPENDED + + """ + PREMOD is used when a User is currently set to require pre-moderation. + """ + PREMOD } enum DIGEST_FREQUENCY { @@ -4771,6 +4818,62 @@ type RemoveUserSuspensionPayload { clientMutationId: String! } +################## +# premodUser +################## + +input PremodUserInput { + """ + userID is the ID of the User that should be premodded. + """ + userID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type PremodUserPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# removePremod +################## + +input RemovePremodUserInput { + """ + userID is the ID of the User that should be premodded. + """ + userID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type RemovePremodUserPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## # ignoreUser ################## @@ -5196,6 +5299,18 @@ type Mutation { requestUserCommentsDownload( input: RequestUserCommentsDownloadInput! ): RequestUserCommentsDownloadPayload! @auth(roles: [ADMIN]) + + """ + premodUser sets a user to mandatory premod + """ + premodUser(input: PremodUserInput!): PremodUserPayload! + @auth(roles: [ADMIN, MODERATOR]) + + """ + removeUserPremod removes a user from mandatory premod + """ + removeUserPremod(input: RemovePremodUserInput!): RemovePremodUserPayload! + @auth(roles: [ADMIN, MODERATOR]) } ################## @@ -5367,7 +5482,7 @@ type Subscription { commentReleased returns when a Comment on a premoderated stream is approved """ commentReleased(storyID: ID!): CommentReleasedPayload! - + """ commentReplyCreated returns when a Comment is posted in the ancestor chain of comments. diff --git a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap index fbc9d044e..7dd9099da 100644 --- a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap +++ b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap @@ -19,6 +19,7 @@ Object { "reasons": Object { "COMMENT_DETECTED_BANNED_WORD": 1, "COMMENT_DETECTED_LINKS": 0, + "COMMENT_DETECTED_PREMOD_USER": 0, "COMMENT_DETECTED_RECENT_HISTORY": 0, "COMMENT_DETECTED_SPAM": 0, "COMMENT_DETECTED_SUSPECT_WORD": 0, diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 89c1599a6..ea25400e4 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -14,6 +14,7 @@ import { PasswordResetTokenExpired, TokenNotFoundError, UserAlreadyBannedError, + UserAlreadyPremoderated, UserAlreadySuspendedError, UsernameAlreadySetError, UserNotFoundError, @@ -21,6 +22,7 @@ import { import { GQLBanStatus, GQLDIGEST_FREQUENCY, + GQLPremodStatus, GQLSuspensionStatus, GQLTimeRange, GQLUSER_ROLE, @@ -236,6 +238,42 @@ export interface UsernameStatus { history: UsernameHistory[]; } +/** + * PremodStatusHistory is the history of premod status changes + * against a specific User. + */ +export interface PremodStatusHistory { + /** + * active when true, indicates that the given user is premodded. + */ + active: boolean; + /** + * createdBy is the ID for the User that premodded the User. If `null`, the + * premod was created by the system. + */ + createdBy?: string; + + /** + * createdAt is the time that the given premod status was set. + */ + createdAt: Date; +} + +/** + * PremodStatus is the status of whether a user is set to mandatory premod + */ +export interface PremodStatus { + /** + * active when true, indicates that the given user is set to mandatory premod. + */ + active: boolean; + + /** + * history is a list of previous enable/disable of premod status + */ + history: PremodStatusHistory[]; +} + /** * UserStatus stores the user status information regarding moderation state. */ @@ -255,6 +293,14 @@ export interface UserStatus { * username stores the history of username changes for this user. */ username: UsernameStatus; + + /** + * premod stores whether a user is set to mandatory premod and history of + * premod status. + * + * FIXME: (wyattjoh) set defaults during migration + */ + premod?: PremodStatus; } /** @@ -503,6 +549,7 @@ async function findOrCreateUserInput( }, suspension: { history: [] }, ban: { active: false, history: [] }, + premod: { active: false, history: [] }, }, notifications: { onReply: false, @@ -1358,6 +1405,130 @@ async function retrieveConnection( return resolveConnection(query, input, user => user.createdAt); } +/** + * premodUser will set a user to mandatory premod. + * + * @param mongo the mongo database handle + * @param tenantID the Tenant's ID where the User exists + * @param id the ID of the user being banned + * @param createdBy the ID of the user premodding + * @param now the current date + */ +export async function premodUser( + mongo: Db, + tenantID: string, + id: string, + createdBy: string, + now = new Date() +) { + // Create the new ban. + const premodStatusHistory: PremodStatusHistory = { + active: true, + createdBy, + createdAt: now, + }; + + // Try to update the user if the user isn't already banned. + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "status.premod.active": { + $ne: true, + }, + }, + { + $set: { + "status.premod.active": true, + }, + $push: { + "status.premod.history": premodStatusHistory, + }, + }, + { + // 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 banned. + const premod = consolidateUserPremodStatus(user.status.premod); + // FIXME: (wyattjoh) once migration has been performed, remove check + if (premod && premod.active) { + throw new UserAlreadyPremoderated(); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * removeUserPremod will lift a user premod requirement + * @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 ban lifted + * @param modifiedBy the ID of the user lifting the premod + * @param now the current date + */ +export async function removeUserPremod( + mongo: Db, + tenantID: string, + id: string, + createdBy: string, + now = new Date() +) { + // Create the new ban. + const premod: PremodStatusHistory = { + active: false, + createdBy, + createdAt: now, + }; + + // Try to update the user if the user isn't already banned. + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "status.premod.active": true, + }, + { + $set: { + "status.premod.active": false, + }, + $push: { + "status.premod.history": premod, + }, + }, + { + // 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); + } + + // The user wasn't banned already, so nothing needs to be done! + return user; + } + + return result.value; +} + /** * banUser will ban a specific user from interacting with the site. * @@ -1651,18 +1822,23 @@ export type ConsolidatedBanStatus = Omit & export type ConsolidatedUsernameStatus = Omit & Pick; +export type ConsolidatedPremodStatus = Omit & + Pick; + export function consolidateUsernameStatus( username: User["status"]["username"] -): ConsolidatedUsernameStatus { +) { return username; } -export function consolidateUserBanStatus( - ban: User["status"]["ban"] -): ConsolidatedBanStatus { +export function consolidateUserBanStatus(ban: User["status"]["ban"]) { return ban; } +export function consolidateUserPremodStatus(premod: User["status"]["premod"]) { + return premod; +} + export type ConsolidatedSuspensionStatus = Omit< GQLSuspensionStatus, "history" @@ -1697,6 +1873,8 @@ export function consolidateUserSuspensionStatus( export interface ConsolidatedUserStatus { suspension: ConsolidatedSuspensionStatus; ban: ConsolidatedBanStatus; + // FIXME: (wyattjoh) once migration has been performed, make required + premod?: ConsolidatedPremodStatus; } export function consolidateUserStatus( @@ -1707,6 +1885,7 @@ export function consolidateUserStatus( return { suspension: consolidateUserSuspensionStatus(status.suspension, now), ban: consolidateUserBanStatus(status.ban), + premod: consolidateUserPremodStatus(status.premod), }; } diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index 564a71889..96e8f958e 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -5,6 +5,7 @@ import { commentLength } from "./commentLength"; import { detectLinks } from "./detectLinks"; import { linkify } from "./linkify"; import { preModerate } from "./preModerate"; +import { premodUser } from "./preModerateUser"; import { purify } from "./purify"; import { recentCommentHistory } from "./recentCommentHistory"; import { spam } from "./spam"; @@ -29,4 +30,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [ spam, detectLinks, preModerate, + premodUser, ]; diff --git a/src/core/server/services/comments/pipeline/phases/preModerateUser.ts b/src/core/server/services/comments/pipeline/phases/preModerateUser.ts new file mode 100644 index 000000000..c375d5d86 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/preModerateUser.ts @@ -0,0 +1,17 @@ +import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; +import { + IntermediateModerationPhase, + IntermediatePhaseResult, +} from "coral-server/services/comments/pipeline"; + +// If a given user is set to always premod, set to premod. +export const premodUser: IntermediateModerationPhase = ({ + author, +}): IntermediatePhaseResult | void => { + // FIXME: (wyattjoh) once migration has been performed, remove check + if (author.status.premod && author.status.premod.active) { + return { + status: GQLCOMMENT_STATUS.PREMOD, + }; + } +}; diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 7b08fff13..7e6497cd2 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -17,6 +17,7 @@ import { PasswordIncorrect, TokenNotFoundError, UserAlreadyBannedError, + UserAlreadyPremoderated, UserAlreadySuspendedError, UserCannotBeIgnoredError, UsernameAlreadySetError, @@ -33,6 +34,7 @@ import { banUser, clearDeletionDate, consolidateUserBanStatus, + consolidateUserPremodStatus, consolidateUserSuspensionStatus, createUser, createUserToken, @@ -41,9 +43,11 @@ import { FindOrCreateUserInput, ignoreUser, NotificationSettingsInput, + premodUser, removeActiveUserSuspensions, removeUserBan, removeUserIgnore, + removeUserPremod, retrieveUser, retrieveUserWithEmail, scheduleDeletionDate, @@ -791,6 +795,66 @@ export async function ban( return user; } +/** + * premod will premod a specific user. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be banned on + * @param moderator the User that is banning the User + * @param userID the ID of the User being banned + * @param now the current time that the ban took effect + */ +export async function premod( + mongo: Db, + tenant: Tenant, + moderator: User, + userID: string, + 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); + } + + // Check to see if the User is currently banned. + const premodStatus = consolidateUserPremodStatus(targetUser.status.premod); + // FIXME: (wyattjoh) once migration has been performed, remove check + if (premodStatus && premodStatus.active) { + throw new UserAlreadyPremoderated(); + } + + // Ban the user. + return premodUser(mongo, tenant.id, userID, moderator.id, now); +} + +export async function removePremod( + 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 suspension. + const targetUser = await retrieveUser(mongo, tenant.id, userID); + if (!targetUser) { + throw new UserNotFoundError(userID); + } + + // Check to see if the User is currently suspended. + const premodStatus = consolidateUserPremodStatus(targetUser.status.premod); + // FIXME: (wyattjoh) once migration has been performed, remove check + if (!premodStatus || !premodStatus.active) { + // The user is not premodded currently, just return the user because we + // don't have to do anything. + return targetUser; + } + + // For each of the suspensions, remove it. + return removeUserPremod(mongo, tenant.id, userID, moderator.id, now); +} /** * suspend will suspend a give user from interacting with Coral. * diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 2e359b542..043e9ade4 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -22,6 +22,7 @@ role-plural-commenter = Commenters userStatus-active = Active userStatus-banned = Banned userStatus-suspended = Suspended +userStatus-premod = Always pre-moderate ## Navigation navigation-moderate = Moderate @@ -486,6 +487,9 @@ moderate-user-drawer-username-change = Username change moderate-user-drawer-username-change-new = New: moderate-user-drawer-username-change-old = Old: +moderate-user-drawer-account-history-premod-set = Always pre-moderate +moderate-user-drawer-account-history-premod-removed = Removed pre-moderate + moderate-user-drawer-suspension = Suspension, { $value } { $unit -> [second] { $value -> @@ -614,12 +618,18 @@ community-userStatus-popover = .description = A dropdown to change the user status community-userStatus-banUser = Ban User +community-userStatus-ban = Ban community-userStatus-removeBan = Remove Ban +community-userStatus-removeUserBan = Remove ban community-userStatus-suspendUser = Suspend User +community-userStatus-suspend = Suspend community-userStatus-removeSuspension = Remove Suspension +community-userStatus-removeUserSuspension = Remove suspension community-userStatus-unknown = Unknown community-userStatus-changeButton = .aria-label = Change user status +community-userStatus-premodUser = Always pre-moderate +community-userStatus-removePremod = Remove pre-moderate community-banModal-areYouSure = Are you sure you want to ban { $username }? community-banModal-consequence = @@ -652,6 +662,13 @@ community-suspendModal-success = community-suspendModal-success-close = Close community-suspendModal-selectDuration = Select suspension length +community-premodModal-areYouSure = + Are you sure you want to always pre-moderate { $username }? +community-premodModal-consequence = + All their comments will go to the Pending queue until you remove this status. +community-premodModal-cancel = Cancel +community-premodModal-premodUser = Yes, always pre-moderate + community-invite-inviteMember = Invite members to your organization community-invite-emailAddressLabel = Email address: community-invite-inviteMore = Invite more