diff --git a/EXTERNAL_MODERATION_PHASES.md b/EXTERNAL_MODERATION_PHASES.md
new file mode 100644
index 000000000..b29d4070c
--- /dev/null
+++ b/EXTERNAL_MODERATION_PHASES.md
@@ -0,0 +1,240 @@
+# External Moderation Phases Guide
+
+This document is in reference to external moderation phases emitted by Coral.
+You can configure external moderation phases on your installation of Coral by
+visiting `/admin/configure/moderation/phases`.
+
+Once you've configured a external moderation phase in Coral, you will start to
+receive moderation requests in the form of a
+[External Moderation Requests](#external-moderation-request) at the provided
+callback URL. These will be in the form of `POST` requests with a `JSON`
+payload.
+
+When a comment is created or edited, it will be processed by moderation phases in
+a predefined order. Any external moderation phase is run last, and only if all
+other moderation phases before it do not return a status. The current set of
+moderation phases is listed in order [here](https://github.com/coralproject/talk/blob/master/src/core/server/services/comments/pipeline/phases/index.ts).
+
+Once you have received a moderation request, you must respond within the
+provided timeout else the phase will be skipped and it will continue. It is
+strongly recommended to [verify the request signature](#request-signing).
+
+The external moderation phase must respond with one of the following:
+
+1. Do not moderate the comment, and return a 204 without a body.
+2. Perform a moderation action and return a 200 with a [External Moderation Response](#external-moderation-response)
+ as a `JSON` encoded body containing the operations you want to perform on the
+ comment.
+
+
+
+## Table of Contents
+
+- [Request Signing](#request-signing)
+- [Schema](#schema)
+ - [External Moderation Request](#external-moderation-request)
+ - [External Moderation Response](#external-moderation-response)
+
+
+
+## Request Signing
+
+Requests sent by Coral for external moderation phases use the same process as
+those used by webhooks. Refer to the [webhooks documentation](WEBHOOKS.md#webhook-signing)
+for instructions on how to verify signatures sent by Coral.
+
+## Schema
+
+### External Moderation Request
+
+```ts
+interface ExternalModerationRequest {
+ /**
+ * action refers to the specific operation being performed. If `NEW`, this
+ * is referring to a new comment being created. If `EDIT`, then this refers to
+ * an operation involving an edit operation on an existing Comment.
+ */
+ action: "NEW" | "EDIT";
+
+ /**
+ * comment refers to the actual Comment data for the Comment being
+ * created/edited.
+ */
+ comment: {
+ /**
+ * body refers to the actual body text of the Comment being created/edited.
+ */
+ body: string;
+
+ /**
+ * parentID is the identifier for the parent comment (if this Comment is a
+ * reply, null otherwise).
+ */
+ parentID: string | null;
+ };
+
+ /**
+ * author refers to the User that is creating/editing the Comment.
+ */
+ author: {
+ /**
+ * id is the identifier for this User.
+ */
+ id: string;
+
+ /**
+ * role refers to the role of this User.
+ */
+ role: "COMMENTER" | "STAFF" | "MODERATOR" | "ADMIN";
+ };
+
+ /**
+ * story refers to the Story being commented on.
+ */
+ story: {
+ /**
+ * id is the identifier for this Story.
+ */
+ id: string;
+
+ /**
+ * url is the URL for this Story.
+ */
+ url: string;
+ };
+
+ /**
+ * site refers to the Site that the story being commented on belongs to.
+ */
+ site: {
+ /**
+ * id is the identifier for this Site.
+ */
+ id: string;
+ };
+
+ /**
+ * tenantID is the identifer of the Tenant that this Comment is being
+ * created/edited on.
+ */
+ tenantID: string;
+
+ /**
+ * tenantDomain is the domain that is associated with this Tenant that this
+ * Comment is being created/edited on.
+ */
+ tenantDomain: string;
+}
+```
+
+#### Example
+
+New comment on a story:
+
+```json
+{
+ "action": "NEW",
+ "comment": {
+ "body": "Here's a comment!",
+ "parentID": null
+ },
+ "author": {
+ "id": "baf4e943-3594-4fcc-b2ba-3e8de7a76352",
+ "role": "COMMENTER"
+ },
+ "story": {
+ "id": "245b3856-b0a0-4d2f-a6bb-58c71f18d6a6",
+ "url": "http://localhost:1313/posts/a-story-url/"
+ },
+ "site": {
+ "id": "a4bede88-2d2c-4424-bc18-4322a9e285a6"
+ },
+ "tenantID": "19ba5794-7eeb-4d46-a81b-c00c61672501",
+ "tenantDomain": "localhost"
+}
+```
+
+New reply on a comment on a story:
+
+```json
+{
+ "action": "NEW",
+ "comment": {
+ "body": "Here's a reply!",
+ "parentID": "d79b787f-f406-49a0-a179-72e3652e54be"
+ },
+ "author": {
+ "id": "baf4e943-3594-4fcc-b2ba-3e8de7a76352",
+ "role": "COMMENTER"
+ },
+ "story": {
+ "id": "245b3856-b0a0-4d2f-a6bb-58c71f18d6a6",
+ "url": "http://localhost:1313/posts/a-story-url/"
+ },
+ "site": {
+ "id": "a4bede88-2d2c-4424-bc18-4322a9e285a6"
+ },
+ "tenantID": "19ba5794-7eeb-4d46-a81b-c00c61672501",
+ "tenantDomain": "localhost"
+}
+```
+
+### External Moderation Response
+
+```ts
+interface ExternalModerationResponse {
+ /**
+ * actions is an optional list of any flags to be added to this Comment.
+ */
+ actions?: Array<{
+ actionType: "FLAG";
+ reason: "COMMENT_DETECTED_TOXIC" | "COMMENT_DETECTED_SPAM";
+ }>;
+
+ /**
+ * tags are any listed tags that should be added to the comment.
+ */
+ tags?: Array<"FEATURED" | "STAFF">;
+
+ /**
+ * status when provided decides and terminates the moderation process by
+ * setting the status of the comment.
+ */
+ status?: "NONE" | "APPROVED" | "REJECTED" | "PREMOD" | "SYSTEM_WITHHELD";
+}
+```
+
+#### Examples
+
+Add a flag to a comment and do not set a status:
+
+```json
+{
+ "actions": [{ "actionType": "FLAG", "reason": "COMMENT_DETECTED_TOXIC" }]
+}
+```
+
+Reject a comment:
+
+```json
+{
+ "status": "REJECTED"
+}
+```
+
+Feature a comment and do not set a status:
+
+```json
+{
+ "tags": ["FEATURED"]
+}
+```
+
+Approve a comment and mark it as featured:
+
+```json
+{
+ "status": "APPROVED",
+ "tags": ["FEATURED"]
+}
+```
diff --git a/LICENSE b/LICENSE
index 2db3b1d5b..50eddcc87 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2019 Vox Media, Inc
+Copyright 2020 Vox Media, Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/package.json b/package.json
index 4965462c5..d43f9f9fe 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"build:server": "gulp server",
"migration:create": "ts-node --transpile-only ./scripts/migration/create.ts",
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./CLIENT_EVENTS.md",
- "doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.md",
+ "doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.md EXTERNAL_MODERATION_PHASES.md",
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
"generate:css-types": "tcm src/core/client/",
@@ -413,7 +413,7 @@
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,CLIENT_EVENTS.md}": [
"npm run docs:events -- --verify"
],
- "{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS}.md": [
+ "{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS,EXTERNAL_MODERATION_PHASES}.md": [
"npm run doctoc"
]
},
diff --git a/src/core/client/admin/helpers/getExternalModerationPhaseLink.ts b/src/core/client/admin/helpers/getExternalModerationPhaseLink.ts
new file mode 100644
index 000000000..a251235cd
--- /dev/null
+++ b/src/core/client/admin/helpers/getExternalModerationPhaseLink.ts
@@ -0,0 +1,5 @@
+import { urls } from "coral-framework/helpers";
+
+export default function getExternalModerationPhaseLink(phaseID: string) {
+ return `${urls.admin.configureExternalModerationPhase}/${phaseID}`;
+}
diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx
index 2896c3793..a3d198b12 100644
--- a/src/core/client/admin/routeConfig.tsx
+++ b/src/core/client/admin/routeConfig.tsx
@@ -9,18 +9,22 @@ import { createAuthCheckRoute } from "./routes/AuthCheck";
import CommunityRoute from "./routes/Community";
import ConfigureRoute from "./routes/Configure";
import {
+ AddExternalModerationPhaseRoute,
AddWebhookEndpointRoute,
AdvancedConfigRoute,
AuthConfigRoute,
+ ConfigureExternalModerationPhaseRoute,
ConfigureWebhookEndpointRoute,
EmailConfigRoute,
GeneralConfigRoute,
ModerationConfigRoute,
+ ModerationPhasesConfigRoute,
OrganizationConfigRoute,
SlackConfigRoute,
WebhookEndpointsConfigRoute,
WordListConfigRoute,
} from "./routes/Configure/sections";
+import ModerationPhasesLayout from "./routes/Configure/sections/ModerationPhases/ModerationPhasesLayout";
import { Sites } from "./routes/Configure/sections/Sites";
import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute";
import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute";
@@ -120,13 +124,28 @@ export default makeRouteConfig(
path="organization"
{...OrganizationConfigRoute.routeConfig}
/>
-
+
+
+
+
+
+
diff --git a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx
index c24cb225a..bb06ac8ed 100644
--- a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx
+++ b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx
@@ -14,7 +14,12 @@ const ConfigureLinks: FunctionComponent<{}> = () => {
Organization
- Moderation
+
+ Moderation
+
+
+
+ Moderation Phases
Banned and Suspect Words
diff --git a/src/core/client/admin/routes/Configure/Link.tsx b/src/core/client/admin/routes/Configure/Link.tsx
index e60901468..e8ea9be0d 100644
--- a/src/core/client/admin/routes/Configure/Link.tsx
+++ b/src/core/client/admin/routes/Configure/Link.tsx
@@ -7,6 +7,7 @@ interface Props {
className?: string;
children: React.ReactNode;
to: string | LocationDescriptor;
+ exact?: boolean;
}
const Link: FunctionComponent = (props) => (
@@ -15,6 +16,7 @@ const Link: FunctionComponent = (props) => (
to={props.to}
className={styles.link}
activeClassName={styles.linkActive}
+ exact={props.exact}
>
{props.children}
diff --git a/src/core/client/admin/routes/Configure/sections/Auth/RegenerateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/RegenerateSSOKeyMutation.ts
deleted file mode 100644
index b729ddb38..000000000
--- a/src/core/client/admin/routes/Configure/sections/Auth/RegenerateSSOKeyMutation.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { graphql } from "react-relay";
-import { Environment } from "relay-runtime";
-
-import {
- commitMutationPromiseNormalized,
- createMutation,
-} from "coral-framework/lib/relay";
-
-import { RegenerateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RegenerateSSOKeyMutation.graphql";
-
-let clientMutationId = 0;
-
-const RegenerateSSOKeyMutation = createMutation(
- "regenerateSSOKey",
- (environment: Environment) =>
- commitMutationPromiseNormalized(environment, {
- mutation: graphql`
- mutation RegenerateSSOKeyMutation($input: RegenerateSSOKeyInput!) {
- regenerateSSOKey(input: $input) {
- settings {
- auth {
- integrations {
- sso {
- key
- keyGeneratedAt
- }
- }
- }
- }
- clientMutationId
- }
- }
- `,
- variables: {
- input: {
- clientMutationId: (clientMutationId++).toString(),
- },
- },
- })
-);
-
-export default RegenerateSSOKeyMutation;
diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx
index 3908cf187..405268505 100644
--- a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx
+++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx
@@ -8,7 +8,7 @@ import { FormFieldDescription } from "coral-ui/components/v2";
import Header from "../../Header";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RegistrationField from "./RegistrationField";
-import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery";
+import SSOSigningSecretRotationQuery from "./SSOSigningSecretRotation/SSOSigningSecretRotationQuery";
import TargetFilterField from "./TargetFilterField";
// eslint-disable-next-line no-unused-expressions
@@ -60,7 +60,9 @@ const SSOConfig: FunctionComponent = ({ disabled }) => (
for additional information on single sign on.
-
+
diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.css
similarity index 100%
rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css
rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.css
diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.tsx
similarity index 89%
rename from src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx
rename to src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.tsx
index 528875980..6dd0d9869 100644
--- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx
+++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOSigningSecretRotation/DateField.tsx
@@ -3,11 +3,11 @@ import React, { FunctionComponent } from "react";
import { Flex, Label } from "coral-ui/components/v2";
-import { SSOKeyStatus } from "./StatusField";
+import { SSOSigningSecretStatus } from "./StatusField";
import styles from "./DateField.css";
-export interface SSOKeyDates {
+export interface SSOSigningSecretDates {
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
@@ -15,13 +15,13 @@ export interface SSOKeyDates {
}
interface Props {
- status: SSOKeyStatus;
- dates: SSOKeyDates;
+ status: SSOSigningSecretStatus;
+ dates: SSOSigningSecretDates;
}
const DateField: FunctionComponent = ({ status, dates }) => {
switch (status) {
- case SSOKeyStatus.ACTIVE:
+ case SSOSigningSecretStatus.ACTIVE:
return (
<>