[CORL-810] Custom Moderation Phases (#2901)

* feat: initial implementation

* feat: renamed fields from mutations

* fix: more renaming to streamline {Key,Secret}->SigningSecret

* feat: introduced WrappedInternalError

* feat: enhanced extern payload, more fetch options

- Added tenant.{id,domain} to extern payload
- Added site.id to the extern payload
- Added response size limit to fetch
- Added new SCRAPE_MAX_RESPONSE_SIZE env var for managing the size of
  responses for scraping

* fix: fixed bug with scrape invocation

* feat: added more queries + mutations

- Added Query.externalModerationPhase
- Added Mutation.createExternalModerationPhase
- Added Mutation.updateExternalModerationPhase
- Added Mutation.enableExternalModerationPhase
- Added Mutation.disableExternalModerationPhase
- Added Mutation.deleteExternalModerationPhase
- Added Mutation.rotateExternalModerationPhaseSigningSecret

* feat: added secret management

* fix: linting

* fix: merge conflict fix

* feat: added UI

* fix: linting

* fix: linting

* fix: updated snapshots

* fix: improved docs

* fix: improved docs

* fix: added locales

* review: improve naming

* review: some review changes

- Switched /moderation/phase to /moderation/phases
- Fixed scrolling
- Fixed redirection

* fix: added scroll timeout for webhooks
This commit is contained in:
Wyatt Johnson
2020-05-14 19:20:35 +00:00
committed by GitHub
parent ceb96dba75
commit ed92f4916d
154 changed files with 5445 additions and 1694 deletions
@@ -0,0 +1,5 @@
import { urls } from "coral-framework/helpers";
export default function getExternalModerationPhaseLink(phaseID: string) {
return `${urls.admin.configureExternalModerationPhase}/${phaseID}`;
}
+20 -1
View File
@@ -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}
/>
<Route path="moderation" {...ModerationConfigRoute.routeConfig} />
<Route
exact
path="moderation"
{...ModerationConfigRoute.routeConfig}
/>
<Route path="wordList" {...WordListConfigRoute.routeConfig} />
<Route path="auth" {...AuthConfigRoute.routeConfig} />
<Route path="advanced" {...AdvancedConfigRoute.routeConfig} />
<Route path="email" {...EmailConfigRoute.routeConfig} />
<Route path="slack" {...SlackConfigRoute.routeConfig} />
</Route>
<Route
path="configure/moderation/phases"
Component={ModerationPhasesLayout}
>
<Route path="/" {...ModerationPhasesConfigRoute.routeConfig} />
<Route path="add" Component={AddExternalModerationPhaseRoute} />
<Route
path=":phaseID"
{...ConfigureExternalModerationPhaseRoute.routeConfig}
/>
</Route>
<Route path="configure/webhooks" Component={WebhookEndpointsLayout}>
<Route path="/" {...WebhookEndpointsConfigRoute.routeConfig} />
<Route path="add" {...AddWebhookEndpointRoute.routeConfig} />
@@ -14,7 +14,12 @@ const ConfigureLinks: FunctionComponent<{}> = () => {
<Link to="/admin/configure/organization">Organization</Link>
</Localized>
<Localized id="configure-sideBarNavigation-moderation">
<Link to="/admin/configure/moderation">Moderation</Link>
<Link exact to="/admin/configure/moderation">
Moderation
</Link>
</Localized>
<Localized id="configure-sideBarNavigation-moderationPhases">
<Link to="/admin/configure/moderation/phases">Moderation Phases</Link>
</Localized>
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
<Link to="/admin/configure/wordList">Banned and Suspect Words</Link>
@@ -7,6 +7,7 @@ interface Props {
className?: string;
children: React.ReactNode;
to: string | LocationDescriptor;
exact?: boolean;
}
const Link: FunctionComponent<Props> = (props) => (
@@ -15,6 +16,7 @@ const Link: FunctionComponent<Props> = (props) => (
to={props.to}
className={styles.link}
activeClassName={styles.linkActive}
exact={props.exact}
>
{props.children}
</FoundLink>
@@ -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<MutationTypes>(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;
@@ -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<Props> = ({ disabled }) => (
for additional information on single sign on.
</FormFieldDescription>
</Localized>
<SSOKeyRotationQuery disabled={disabledInside}></SSOKeyRotationQuery>
<SSOSigningSecretRotationQuery
disabled={disabledInside}
></SSOSigningSecretRotationQuery>
<TargetFilterField
label={
<Localized id="configure-auth-sso-useLoginOn">
@@ -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<Props> = ({ status, dates }) => {
switch (status) {
case SSOKeyStatus.ACTIVE:
case SSOSigningSecretStatus.ACTIVE:
return (
<>
<div className={styles.label}>
@@ -37,7 +37,7 @@ const DateField: FunctionComponent<Props> = ({ status, dates }) => {
</Localized>
</>
);
case SSOKeyStatus.EXPIRING:
case SSOSigningSecretStatus.EXPIRING:
return (
<>
<div className={styles.label}>
@@ -63,7 +63,7 @@ const DateField: FunctionComponent<Props> = ({ status, dates }) => {
</Flex>
</>
);
case SSOKeyStatus.EXPIRED:
case SSOSigningSecretStatus.EXPIRED:
return (
<>
<div className={styles.label}>
@@ -7,23 +7,25 @@ import {
MutationInput,
} from "coral-framework/lib/relay";
import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql";
import { DeactivateSSOSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOSigningSecretMutation.graphql";
const clientMutationId = 0;
const RotateSSOKeyMutation = createMutation(
"rotateSSOKey",
const DeactivateSSOSigningSecretMutation = createMutation(
"deactivateSSOSigningSecret",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) {
rotateSSOKey(input: $input) {
mutation DeactivateSSOSigningSecretMutation(
$input: DeactivateSSOSigningSecretInput!
) {
deactivateSSOSigningSecret(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
signingSecrets {
kid
secret
createdAt
@@ -49,4 +51,4 @@ const RotateSSOKeyMutation = createMutation(
}
);
export default RotateSSOKeyMutation;
export default DeactivateSSOSigningSecretMutation;
@@ -7,23 +7,25 @@ import {
MutationInput,
} from "coral-framework/lib/relay";
import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql";
import { DeleteSSOSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOSigningSecretMutation.graphql";
const clientMutationId = 0;
const DeactivateSSOKeyMutation = createMutation(
"deactivateSSOKey",
const DeleteSSOSigningSecretMutation = createMutation(
"deleteSSOSigningSecret",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) {
deactivateSSOKey(input: $input) {
mutation DeleteSSOSigningSecretMutation(
$input: DeleteSSOSigningSecretInput!
) {
deleteSSOSigningSecret(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
signingSecrets {
kid
secret
createdAt
@@ -49,4 +51,4 @@ const DeactivateSSOKeyMutation = createMutation(
}
);
export default DeactivateSSOKeyMutation;
export default DeleteSSOSigningSecretMutation;
@@ -7,23 +7,25 @@ import {
MutationInput,
} from "coral-framework/lib/relay";
import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql";
import { RotateSSOSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOSigningSecretMutation.graphql";
const clientMutationId = 0;
const DeleteSSOKeyMutation = createMutation(
"deleteSSOKey",
const RotateSSOSigningSecretMutation = createMutation(
"rotateSSOSigningSecret",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) {
deleteSSOKey(input: $input) {
mutation RotateSSOSigningSecretMutation(
$input: RotateSSOSigningSecretInput!
) {
rotateSSOSigningSecret(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
signingSecrets {
kid
secret
createdAt
@@ -49,4 +51,4 @@ const DeleteSSOKeyMutation = createMutation(
}
);
export default DeleteSSOKeyMutation;
export default RotateSSOSigningSecretMutation;
@@ -15,16 +15,16 @@ import {
} from "coral-ui/components/v2";
import DateField from "./DateField";
import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation";
import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation";
import RotateSSOKeyMutation from "./RotateSSOKeyMutation";
import DeactivateSSOSigningSecretMutation from "./DeactivateSSOSigningSecretMutation";
import DeleteSSOSigningSecretMutation from "./DeleteSSOSigningSecretMutation";
import RotateSSOSigningSecretMutation from "./RotateSSOSigningSecretMutation";
import RotationDropDown from "./RotationDropdown";
import { RotateOptions } from "./RotationOption";
import StatusField, { SSOKeyStatus } from "./StatusField";
import StatusField, { SSOSigningSecretStatus } from "./StatusField";
import styles from "./SSOKeyCard.css";
import styles from "./SSOSigningSecretCard.css";
export interface SSOKeyDates {
export interface SSOSigningSecretDates {
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
@@ -34,22 +34,22 @@ export interface SSOKeyDates {
interface Props {
id: string;
secret: string;
status: SSOKeyStatus;
dates: SSOKeyDates;
status: SSOSigningSecretStatus;
dates: SSOSigningSecretDates;
disabled?: boolean;
}
function createActionButton(
status: SSOKeyStatus,
status: SSOSigningSecretStatus,
onRotateKey: (rotation: string) => void,
onDeactivateKey: () => void,
onDelete: () => void,
disabled?: boolean
) {
switch (status) {
case SSOKeyStatus.ACTIVE:
case SSOSigningSecretStatus.ACTIVE:
return <RotationDropDown onRotateKey={onRotateKey} disabled={disabled} />;
case SSOKeyStatus.EXPIRING:
case SSOSigningSecretStatus.EXPIRING:
return (
<Localized id="configure-auth-sso-rotate-deactivateNow">
<Button color="alert" onClick={onDeactivateKey} disabled={disabled}>
@@ -57,7 +57,7 @@ function createActionButton(
</Button>
</Localized>
);
case SSOKeyStatus.EXPIRED:
case SSOSigningSecretStatus.EXPIRED:
return (
<Localized id="configure-auth-sso-rotate-delete">
<Button color="alert" onClick={onDelete} disabled={disabled}>
@@ -70,48 +70,50 @@ function createActionButton(
}
}
const SSOKeyCard: FunctionComponent<Props> = ({
const SSOSigningSecretCard: FunctionComponent<Props> = ({
id,
secret,
status,
dates,
disabled,
}) => {
const rotateSSOKey = useMutation(RotateSSOKeyMutation);
const deactivateSSOKey = useMutation(DeactivateSSOKeyMutation);
const deleteSSOKey = useMutation(DeleteSSOKeyMutation);
const rotateSSOSigningSecret = useMutation(RotateSSOSigningSecretMutation);
const deactivateSSOSigningSecret = useMutation(
DeactivateSSOSigningSecretMutation
);
const deleteSSOSigningSecret = useMutation(DeleteSSOSigningSecretMutation);
const onRotate = useCallback(
(rotation: string) => {
switch (rotation) {
case RotateOptions.NOW:
rotateSSOKey({ inactiveIn: 0 });
rotateSSOSigningSecret({ inactiveIn: 0 });
break;
case RotateOptions.IN1DAY:
rotateSSOKey({ inactiveIn: 24 * 60 * 60 });
rotateSSOSigningSecret({ inactiveIn: 24 * 60 * 60 });
break;
case RotateOptions.IN1WEEK:
rotateSSOKey({ inactiveIn: 7 * 24 * 60 * 60 });
rotateSSOSigningSecret({ inactiveIn: 7 * 24 * 60 * 60 });
break;
case RotateOptions.IN30DAYS:
rotateSSOKey({ inactiveIn: 30 * 24 * 60 * 60 });
rotateSSOSigningSecret({ inactiveIn: 30 * 24 * 60 * 60 });
break;
default:
rotateSSOKey({ inactiveIn: 0 });
rotateSSOSigningSecret({ inactiveIn: 0 });
}
},
[rotateSSOKey]
[rotateSSOSigningSecret]
);
const onDeactivate = useCallback(() => {
deactivateSSOKey({
deactivateSSOSigningSecret({
kid: id,
});
}, [deactivateSSOKey, id]);
}, [deactivateSSOSigningSecret, id]);
const onDelete = useCallback(() => {
deleteSSOKey({
deleteSSOSigningSecret({
kid: id,
});
}, [deleteSSOKey, id]);
}, [deleteSSOSigningSecret, id]);
return (
<Card>
@@ -184,4 +186,4 @@ const SSOKeyCard: FunctionComponent<Props> = ({
);
};
export default SSOKeyCard;
export default SSOSigningSecretCard;
@@ -5,59 +5,52 @@ import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { Label } from "coral-ui/components/v2";
import { SSOKeyRotationContainer_settings } from "coral-admin/__generated__/SSOKeyRotationContainer_settings.graphql";
import { SSOSigningSecretRotationContainer_settings } from "coral-admin/__generated__/SSOSigningSecretRotationContainer_settings.graphql";
import SSOKeyCard, { SSOKeyDates } from "./SSOKeyCard";
import { SSOKeyStatus } from "./StatusField";
import SSOSigningSecretCard, {
SSOSigningSecretDates,
} from "./SSOSigningSecretCard";
import { SSOSigningSecretStatus } from "./StatusField";
interface Props {
settings: SSOKeyRotationContainer_settings;
settings: SSOSigningSecretRotationContainer_settings;
disabled?: boolean;
}
interface Key {
readonly kid: string;
readonly secret: string;
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
function getStatus(dates: SSOKeyDates) {
function getStatus(dates: SSOSigningSecretDates) {
if (
dates.inactiveAt &&
dates.rotatedAt &&
new Date(dates.inactiveAt) > new Date()
) {
return SSOKeyStatus.EXPIRING;
return SSOSigningSecretStatus.EXPIRING;
}
if (dates.inactiveAt && new Date(dates.inactiveAt) <= new Date()) {
return SSOKeyStatus.EXPIRED;
return SSOSigningSecretStatus.EXPIRED;
}
return SSOKeyStatus.ACTIVE;
return SSOSigningSecretStatus.ACTIVE;
}
const SSOKeyRotationContainer: FunctionComponent<Props> = ({
const SSOSigningSecretRotationContainer: FunctionComponent<Props> = ({
disabled,
settings,
}) => {
const {
auth: {
integrations: {
sso: { keys },
sso: { signingSecrets },
},
},
} = settings;
const sortedKeys = useMemo(
const sortedSigningSecrets = useMemo(
() =>
keys
signingSecrets
// Copy this map because we don't want to modify the underlying copy.
.map((key) => key)
.sort((a: Key, b: Key) => {
.sort((a, b) => {
// Both active, sort on createdAt date.
if (!a.inactiveAt && !b.inactiveAt) {
return (
@@ -84,7 +77,7 @@ const SSOKeyRotationContainer: FunctionComponent<Props> = ({
return bDate.getTime() - aDate.getTime();
}),
[keys]
[signingSecrets]
);
return (
@@ -92,8 +85,8 @@ const SSOKeyRotationContainer: FunctionComponent<Props> = ({
<Localized id="configure-auth-sso-rotate-keys">
<Label htmlFor="configure-auth-sso-rotate-keys">Keys</Label>
</Localized>
{sortedKeys.map((key) => (
<SSOKeyCard
{sortedSigningSecrets.map((key) => (
<SSOSigningSecretCard
key={key.kid}
id={key.kid}
secret={key.secret}
@@ -108,12 +101,12 @@ const SSOKeyRotationContainer: FunctionComponent<Props> = ({
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment SSOKeyRotationContainer_settings on Settings {
fragment SSOSigningSecretRotationContainer_settings on Settings {
auth {
integrations {
sso {
enabled
keys {
signingSecrets {
kid
secret
createdAt
@@ -126,6 +119,6 @@ const enhanced = withFragmentContainer<Props>({
}
}
`,
})(SSOKeyRotationContainer);
})(SSOSigningSecretRotationContainer);
export default enhanced;
@@ -4,21 +4,23 @@ import { graphql } from "react-relay";
import { QueryRenderData, QueryRenderer } from "coral-framework/lib/relay";
import { CallOut, Spinner } from "coral-ui/components/v2";
import { SSOKeyRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOKeyRotationQuery.graphql";
import { SSOSigningSecretRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOSigningSecretRotationQuery.graphql";
import SSOKeyRotationContainer from "./SSOKeyRotationContainer";
import SSOSigningSecretRotationContainer from "./SSOSigningSecretRotationContainer";
interface Props {
disabled?: boolean;
}
const SSOKeyRotationQuery: FunctionComponent<Props> = ({ disabled }) => {
const SSOSigningSecretRotationQuery: FunctionComponent<Props> = ({
disabled,
}) => {
return (
<QueryRenderer<QueryTypes>
query={graphql`
query SSOKeyRotationQuery {
query SSOSigningSecretRotationQuery {
settings {
...SSOKeyRotationContainer_settings
...SSOSigningSecretRotationContainer_settings
}
}
`}
@@ -38,7 +40,7 @@ const SSOKeyRotationQuery: FunctionComponent<Props> = ({ disabled }) => {
}
return (
<SSOKeyRotationContainer
<SSOSigningSecretRotationContainer
settings={props.settings}
disabled={disabled}
/>
@@ -48,4 +50,4 @@ const SSOKeyRotationQuery: FunctionComponent<Props> = ({ disabled }) => {
);
};
export default SSOKeyRotationQuery;
export default SSOSigningSecretRotationQuery;
@@ -6,19 +6,19 @@ import { Flex, Icon, Tooltip, TooltipButton } from "coral-ui/components/v2";
import styles from "./StatusField.css";
export enum SSOKeyStatus {
export enum SSOSigningSecretStatus {
EXPIRED,
EXPIRING,
ACTIVE,
}
interface Props {
status: SSOKeyStatus;
status: SSOSigningSecretStatus;
}
const StatusField: FunctionComponent<Props> = ({ status }) => {
switch (status) {
case SSOKeyStatus.ACTIVE:
case SSOSigningSecretStatus.ACTIVE:
return (
<Localized id="configure-auth-sso-rotate-statusActive">
<span
@@ -29,7 +29,7 @@ const StatusField: FunctionComponent<Props> = ({ status }) => {
</span>
</Localized>
);
case SSOKeyStatus.EXPIRING:
case SSOSigningSecretStatus.EXPIRING:
return (
<Flex alignItems="center" justifyContent="center">
<Flex
@@ -68,7 +68,7 @@ const StatusField: FunctionComponent<Props> = ({ status }) => {
/>
</Flex>
);
case SSOKeyStatus.EXPIRED:
case SSOSigningSecretStatus.EXPIRED:
return (
<Flex alignItems="center" justifyContent="center">
<Localized id="configure-auth-sso-rotate-statusExpired">
@@ -0,0 +1,46 @@
import { Localized } from "@fluent/react/compat";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import ConfigBox from "coral-admin/routes/Configure/ConfigBox";
import Header from "coral-admin/routes/Configure/Header";
import { urls } from "coral-framework/helpers";
import { HorizontalGutter } from "coral-ui/components/v2";
import { ConfigureExternalModerationPhaseForm } from "../ConfigureExternalModerationPhaseForm";
import ExperimentalExternalModerationPhaseCallOut from "../ExperimentalExternalModerationPhaseCallOut";
interface Props {
router: Router;
match: Match;
}
const AddExternalModerationPhaseContainer: FunctionComponent<Props> = ({
router,
}) => {
const onCancel = useCallback(() => {
router.push(urls.admin.moderationPhases);
}, [router]);
return (
<HorizontalGutter size="double">
<ExperimentalExternalModerationPhaseCallOut />
<ConfigBox
title={
<Localized id="configure-moderationPhases-addExternalModerationPhase">
<Header>Add external moderation phase</Header>
</Localized>
}
>
<ConfigureExternalModerationPhaseForm
phase={null}
onCancel={onCancel}
/>
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withRouter(AddExternalModerationPhaseContainer);
export default enhanced;
@@ -0,0 +1,9 @@
import React, { FunctionComponent } from "react";
import AddExternalModerationPhaseContainer from "./AddExternalModerationPhaseContainer";
const AddExternalModerationPhaseRoute: FunctionComponent = () => (
<AddExternalModerationPhaseContainer />
);
export default AddExternalModerationPhaseRoute;
@@ -0,0 +1,4 @@
export {
default,
default as AddExternalModerationPhaseRoute,
} from "./AddExternalModerationPhaseRoute";
@@ -0,0 +1,57 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import ConfigBox from "coral-admin/routes/Configure/ConfigBox";
import Header from "coral-admin/routes/Configure/Header";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components/v2";
import { ConfigureExternalModerationPhaseContainer_phase } from "coral-admin/__generated__/ConfigureExternalModerationPhaseContainer_phase.graphql";
import ExperimentalExternalModerationPhaseCallOut from "../ExperimentalExternalModerationPhaseCallOut";
import ExternalModerationPhaseDangerZone from "./ExternalModerationPhaseDangerZone";
import ExternalModerationPhaseDetails from "./ExternalModerationPhaseDetails";
import ExternalModerationPhaseStatus from "./ExternalModerationPhaseStatus";
interface Props {
phase: ConfigureExternalModerationPhaseContainer_phase;
}
const ConfigureExternalModerationPhaseContainer: FunctionComponent<Props> = ({
phase,
}) => {
return (
<HorizontalGutter
size="double"
data-testid="external-moderation-phases-container"
>
<ExperimentalExternalModerationPhaseCallOut />
<ConfigBox
title={
<Localized id="configure-moderationPhases-configureExternalModerationPhase">
<Header htmlFor="configure-moderationPhases-header.title">
Configure external moderation phase
</Header>
</Localized>
}
>
<ExternalModerationPhaseDetails phase={phase} />
<ExternalModerationPhaseStatus phase={phase} />
<ExternalModerationPhaseDangerZone phase={phase} />
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
phase: graphql`
fragment ConfigureExternalModerationPhaseContainer_phase on ExternalModerationPhase {
...ExternalModerationPhaseDetails_phase
...ExternalModerationPhaseDangerZone_phase
...ExternalModerationPhaseStatus_phase
}
`,
})(ConfigureExternalModerationPhaseContainer);
export default enhanced;
@@ -0,0 +1,60 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { CallOut, Delay, Spinner } from "coral-ui/components/v2";
import { ConfigureExternalModerationPhaseRouteQueryResponse } from "coral-admin/__generated__/ConfigureExternalModerationPhaseRouteQuery.graphql";
import ConfigureExternalModerationPhaseContainer from "./ConfigureExternalModerationPhaseContainer";
interface Props {
data: ConfigureExternalModerationPhaseRouteQueryResponse | null;
}
const ConfigureExternalModerationPhaseRoute: FunctionComponent<Props> = ({
data,
}) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
if (!data.externalModerationPhase) {
return (
<Localized id="configure-moderationPhases-phaseNotFound">
<CallOut color="error" fullWidth>
External moderation phase not found
</CallOut>
</Localized>
);
}
return (
<ConfigureExternalModerationPhaseContainer
phase={data.externalModerationPhase}
/>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query ConfigureExternalModerationPhaseRouteQuery($phaseID: ID!) {
externalModerationPhase(id: $phaseID) {
...ConfigureExternalModerationPhaseContainer_phase
}
}
`,
cacheConfig: { force: true },
prepareVariables: (params, match) => {
return {
phaseID: match.params.phaseID,
};
},
})(ConfigureExternalModerationPhaseRoute);
export default enhanced;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DeleteExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/DeleteExternalModerationPhaseMutation.graphql";
let clientMutationId = 0;
const DeleteExternalModerationPhaseMutation = createMutation(
"deleteExternalModerationPhase",
(environment: Environment, { id }: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteExternalModerationPhaseMutation(
$input: DeleteExternalModerationPhaseInput!
) {
deleteExternalModerationPhase(input: $input) {
phase {
id
}
}
}
`,
variables: {
input: {
id,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default DeleteExternalModerationPhaseMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DisableExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/DisableExternalModerationPhaseMutation.graphql";
let clientMutationId = 0;
const DisableExternalModerationPhaseMutation = createMutation(
"disableExternalModerationPhase",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DisableExternalModerationPhaseMutation(
$input: DisableExternalModerationPhaseInput!
) {
disableExternalModerationPhase(input: $input) {
phase {
...ConfigureExternalModerationPhaseContainer_phase
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default DisableExternalModerationPhaseMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { EnableExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/EnableExternalModerationPhaseMutation.graphql";
let clientMutationId = 0;
const EnableExternalModerationPhaseMutation = createMutation(
"enableExternalModerationPhase",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation EnableExternalModerationPhaseMutation(
$input: EnableExternalModerationPhaseInput!
) {
enableExternalModerationPhase(input: $input) {
phase {
...ConfigureExternalModerationPhaseContainer_phase
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default EnableExternalModerationPhaseMutation;
@@ -0,0 +1,185 @@
import { Localized } from "@fluent/react/compat";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql } from "react-relay";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { urls } from "coral-framework/helpers";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { getMessage } from "coral-framework/lib/i18n";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
FormField,
FormFieldDescription,
Label,
} from "coral-ui/components/v2";
import { ExternalModerationPhaseDangerZone_phase } from "coral-admin/__generated__/ExternalModerationPhaseDangerZone_phase.graphql";
import DeleteExternalModerationPhaseMutation from "./DeleteExternalModerationPhaseMutation";
import DisableExternalModerationPhaseMutation from "./DisableExternalModerationPhaseMutation";
import EnableExternalModerationPhaseMutation from "./EnableExternalModerationPhaseMutation";
import RotateSigningSecretModal from "./RotateSigningSecretModal";
interface Props {
phase: ExternalModerationPhaseDangerZone_phase;
router: Router;
match: Match;
}
const ExternalModerationPhaseDangerZone: FunctionComponent<Props> = ({
phase,
router,
}) => {
const { localeBundles } = useCoralContext();
const enableExternalModerationPhase = useMutation(
EnableExternalModerationPhaseMutation
);
const disableExternalModerationPhase = useMutation(
DisableExternalModerationPhaseMutation
);
const deleteExternalModerationPhase = useMutation(
DeleteExternalModerationPhaseMutation
);
const [rotateSecretOpen, setRotateSecretOpen] = useState<boolean>(false);
const onRotateSecret = useCallback(async () => {
setRotateSecretOpen(true);
}, []);
const onHideRotateSecret = useCallback(async () => {
setRotateSecretOpen(false);
}, [setRotateSecretOpen]);
const onEnable = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-moderationPhases-confirmEnable",
"Enabling the external moderation phase will start to send moderation queries to this URL. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await enableExternalModerationPhase({ id: phase.id });
}
}, [phase, enableExternalModerationPhase]);
const onDisable = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-moderationPhases-confirmDisable",
"Disabling this external moderation phase will stop any new moderation queries from being sent to this URL. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await disableExternalModerationPhase({ id: phase.id });
}
}, [phase, disableExternalModerationPhase]);
const onDelete = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-moderationPhases-confirmDelete",
"Deleting this external moderation phase will stop any new moderation queries from being sent to this URL and will remove all the associated settings. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await deleteExternalModerationPhase({ id: phase.id });
// Send the user back to the webhook endpoints listing.
router.push(urls.admin.moderationPhases);
}
}, [phase, disableExternalModerationPhase, router]);
return (
<>
<Localized id="configure-moderationPhases-dangerZone">
<Subheader>Danger Zone</Subheader>
</Localized>
<FormField>
<Localized id="configure-moderationPhases-rotateSigningSecret">
<Label>Rotate signing secret</Label>
</Localized>
<Localized id="configure-moderationPhases-rotateSigningSecretDescription">
<FormFieldDescription>
Rotating the signing secret will allow to you to safely replace a
signing secret used in production with a delay.
</FormFieldDescription>
</Localized>
<Localized id="configure-moderationPhases-rotateSigningSecretButton">
<Button color="alert" onClick={onRotateSecret}>
Rotate signing secret
</Button>
</Localized>
</FormField>
<RotateSigningSecretModal
phaseID={phase.id}
onHide={onHideRotateSecret}
open={rotateSecretOpen}
/>
{phase.enabled ? (
<FormField>
<Localized id="configure-moderationPhases-disableExternalModerationPhase">
<Label>Disable external moderation phase</Label>
</Localized>
<Localized id="configure-moderationPhases-disableExternalModerationPhaseDescription">
<FormFieldDescription>
This external moderation phase is current enabled. By disabling,
no new moderation queries will be sent to the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-moderationPhases-disableExternalModerationPhaseButton">
<Button color="alert" onClick={onDisable}>
Disable phase
</Button>
</Localized>
</FormField>
) : (
<FormField>
<Localized id="configure-moderationPhases-enableExternalModerationPhase">
<Label>Enable external moderation phase</Label>
</Localized>
<Localized id="configure-moderationPhases-enableExternalModerationPhaseDescription">
<FormFieldDescription>
This external moderation phase is currently disabled. By enabling,
new moderation queries will be sent to the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-moderationPhases-enableExternalModerationPhaseButton">
<Button color="regular" onClick={onEnable}>
Enable phase
</Button>
</Localized>
</FormField>
)}
<FormField>
<Localized id="configure-moderationPhases-deleteExternalModerationPhase">
<Label>Delete external moderation phase</Label>
</Localized>
<Localized id="configure-moderationPhases-deleteExternalModerationPhaseDescription">
<FormFieldDescription>
Deleting this external moderation phase will stop any new moderation
queries from being sent to this URL and will remove all the
associated settings.
</FormFieldDescription>
</Localized>
<Localized id="configure-moderationPhases-deleteExternalModerationPhaseButton">
<Button color="alert" onClick={onDelete}>
Delete phase
</Button>
</Localized>
</FormField>
</>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
phase: graphql`
fragment ExternalModerationPhaseDangerZone_phase on ExternalModerationPhase {
id
enabled
}
`,
})(ExternalModerationPhaseDangerZone)
);
export default enhanced;
@@ -0,0 +1,35 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { ExternalModerationPhaseDetails_phase } from "coral-admin/__generated__/ExternalModerationPhaseDetails_phase.graphql";
import ConfigureExternalModerationPhaseForm from "../ConfigureExternalModerationPhaseForm";
interface Props {
phase: ExternalModerationPhaseDetails_phase;
}
const ExternalModerationPhaseDetails: FunctionComponent<Props> = ({
phase,
}) => (
<>
<Localized id="configure-moderationPhases-phaseDetails">
<Subheader>Phase details</Subheader>
</Localized>
<ConfigureExternalModerationPhaseForm phase={phase} />
</>
);
const enhanced = withFragmentContainer<Props>({
phase: graphql`
fragment ExternalModerationPhaseDetails_phase on ExternalModerationPhase {
...ConfigureExternalModerationPhaseForm_phase
}
`,
})(ExternalModerationPhaseDetails);
export default enhanced;
@@ -0,0 +1,91 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { CopyButton } from "coral-framework/components";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
Flex,
FormField,
FormFieldDescription,
HelperText,
Label,
PasswordField,
} from "coral-ui/components/v2";
import { ExternalModerationPhaseStatus_phase } from "coral-admin/__generated__/ExternalModerationPhaseStatus_phase.graphql";
import StatusMarker from "../StatusMarker";
interface Props {
phase: ExternalModerationPhaseStatus_phase;
}
const ExternalModerationPhaseStatus: FunctionComponent<Props> = ({ phase }) => {
return (
<>
<Localized id="configure-moderationPhases-phaseStatus">
<Subheader>Phase status</Subheader>
</Localized>
<FormField>
<Localized id="configure-moderationPhases-status">
<Label>Status</Label>
</Localized>
<StatusMarker enabled={phase.enabled} />
</FormField>
<FormField>
<Localized id="configure-moderationPhases-signingSecret">
<Label>Signing secret</Label>
</Localized>
<Localized
id="configure-moderationPhases-signingSecretDescription"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/EXTERNAL_MODERATION_PHASES.md#request-signing" />
}
>
<FormFieldDescription>
The following signing secret is used to sign request payloads sent
to the URL. To learn more about webhook signing, visit our{" "}
<ExternalLink href="https://github.com/coralproject/talk/blob/master/EXTERNAL_MODERATION_PHASES.md#request-signing">
docs
</ExternalLink>
.
</FormFieldDescription>
</Localized>
<Flex direction="row" itemGutter="half" alignItems="center">
<PasswordField
value={phase.signingSecret.secret}
fullWidth
readOnly
/>
<CopyButton text={phase.signingSecret.secret} />
</Flex>
<Localized
id="configure-moderationPhases-generatedAt"
$date={new Date(phase.signingSecret.createdAt)}
>
<HelperText>
KEY GENERATED AT: {phase.signingSecret.createdAt}
</HelperText>
</Localized>
</FormField>
</>
);
};
const enhanced = withFragmentContainer<Props>({
phase: graphql`
fragment ExternalModerationPhaseStatus_phase on ExternalModerationPhase {
id
enabled
signingSecret {
secret
createdAt
}
}
`,
})(ExternalModerationPhaseStatus);
export default enhanced;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RotateExternalModerationPhaseSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateExternalModerationPhaseSigningSecretMutation.graphql";
let clientMutationId = 0;
const RotateExternalModerationPhaseSigningSecretMutation = createMutation(
"rotateExternalModerationPhaseSigningSecret",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateExternalModerationPhaseSigningSecretMutation(
$input: RotateExternalModerationPhaseSigningSecretInput!
) {
rotateExternalModerationPhaseSigningSecret(input: $input) {
phase {
...ConfigureExternalModerationPhaseContainer_phase
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default RotateExternalModerationPhaseSigningSecretMutation;
@@ -0,0 +1,10 @@
.root {
width: 500px;
}
.title {
font-size: var(--v2-font-size-5);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-title);
}
@@ -0,0 +1,170 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
CallOut,
Card,
CardCloseButton,
Flex,
FormField,
HelperText,
HorizontalGutter,
Label,
Modal,
Option,
SelectField,
} from "coral-ui/components/v2";
import AppNotification from "coral-ui/components/v2/AppNotification";
import RotateExternalModerationPhaseSigningSecretMutation from "./RotateExternalModerationPhaseSigningSecretMutation";
import styles from "./RotateSigningSecretModal.css";
interface Props {
phaseID: string;
onHide: () => void;
open: boolean;
}
const RotateWebhookEndpointSigningSecretModal: FunctionComponent<Props> = ({
onHide,
open,
phaseID,
}) => {
const rotateExternalModerationPhaseSigningSecret = useMutation(
RotateExternalModerationPhaseSigningSecretMutation
);
const { setMessage, clearMessage } = useNotification();
const onRotateSecret = useCallback(
async ({ inactiveIn: inactiveInString }) => {
try {
const inactiveIn = parseInt(inactiveInString, 10);
await rotateExternalModerationPhaseSigningSecret({
id: phaseID,
inactiveIn,
});
// Post a notification about the successful change.
setMessage(
<Localized id="configure-moderationPhases-rotateSigningSecretSuccessUseNewSecret">
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
External moderation phase signing secret has been rotated. Please
ensure you update your integrations to use the new secret below.
</AppNotification>
</Localized>
);
// Scroll after a zero timeout because chrome won't scroll otherwise.
setTimeout(() => window.scroll(0, 0), 0);
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return { [FORM_ERROR]: err.message };
}
// Dismiss the modal.
onHide();
return;
},
[phaseID, rotateExternalModerationPhaseSigningSecret]
);
return (
<Modal open={open}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
<Flex justifyContent="flex-end">
<CardCloseButton onClick={onHide} ref={firstFocusableRef} />
</Flex>
<Form onSubmit={onRotateSecret} initialValues={{ inactiveIn: 0 }}>
{({ handleSubmit, submitting, submitError }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<Localized id="configure-moderationPhases-rotateSigningSecret">
<h2 className={styles.title}>Rotate signing secret</h2>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="configure-moderationPhases-rotateSigningSecretHelper">
<HelperText>
After it expires, signatures will no longer be generated
with the old secret.
</HelperText>
</Localized>
<Field name="inactiveIn">
{({ input }) => (
<FormField>
<Localized id="configure-moderationPhases-expiresOldSecret">
<Label>Expire the old secret</Label>
</Localized>
<SelectField {...input} fullWidth>
<Localized id="configure-moderationPhases-expiresOldSecretImmediately">
<Option value="0">Immediately</Option>
</Localized>
<Localized
id="configure-moderationPhases-expiresOldSecretHoursFromNow"
$hours={1}
>
<Option value="3600">1 hour from now</Option>
</Localized>
<Localized
id="configure-moderationPhases-expiresOldSecretHoursFromNow"
$hours={2}
>
<Option value="7200">2 hours from now</Option>
</Localized>
<Localized
id="configure-moderationPhases-expiresOldSecretHoursFromNow"
$hours={12}
>
<Option value="43200">12 hours from now</Option>
</Localized>
<Localized
id="configure-moderationPhases-expiresOldSecretHoursFromNow"
$hours={24}
>
<Option value="86400">24 hours from now</Option>
</Localized>
</SelectField>
</FormField>
)}
</Field>
<Flex direction="row" justifyContent="flex-end" itemGutter>
<Localized id="configure-moderationPhases-cancelButton">
<Button color="regular" onClick={onHide}>
Cancel
</Button>
</Localized>
<Localized id="configure-moderationPhases-rotateSigningSecretButton">
<Button
type="submit"
color="alert"
disabled={submitting}
ref={lastFocusableRef}
>
Rotate signing secret
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</Card>
)}
</Modal>
);
};
export default RotateWebhookEndpointSigningSecretModal;
@@ -0,0 +1,4 @@
export {
default,
default as ConfigureWebhookEndpointRoute,
} from "./ConfigureExternalModerationPhaseRoute";
@@ -0,0 +1,240 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { graphql } from "react-relay";
import getExternalModerationPhaseLink from "coral-admin/helpers/getExternalModerationPhaseLink";
import { InvalidRequestError } from "coral-framework/lib/errors";
import {
colorFromMeta,
parseInteger,
ValidationMessage,
} from "coral-framework/lib/form";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import {
composeValidators,
required,
validateURL,
validateWholeNumberBetween,
} from "coral-framework/lib/validation";
import { GQLCOMMENT_BODY_FORMAT } from "coral-framework/schema";
import {
Button,
CallOut,
Flex,
FormField,
HelperText,
HorizontalGutter,
Label,
Option,
SelectField,
TextField,
} from "coral-ui/components/v2";
import { ConfigureExternalModerationPhaseForm_phase } from "coral-admin/__generated__/ConfigureExternalModerationPhaseForm_phase.graphql";
import CreateExternalModerationPhaseMutation from "./CreateExternalModerationPhaseMutation";
import UpdateExternalModerationPhaseMutation from "./UpdateExternalModerationPhaseMutation";
interface Props {
onCancel?: () => void;
router: Router;
match: Match;
phase: ConfigureExternalModerationPhaseForm_phase | null;
}
const initialValues = (phase?: any) =>
phase
? phase
: {
name: "",
url: "",
timeout: 200,
format: "HTML",
};
const ConfigureExternalModerationPhaseForm: FunctionComponent<Props> = ({
onCancel,
phase,
router,
}) => {
const create = useMutation(CreateExternalModerationPhaseMutation);
const update = useMutation(UpdateExternalModerationPhaseMutation);
const onSubmit = useCallback(
async (values) => {
try {
if (phase) {
// The external moderation phase was defined, update it.
await update(values);
} else {
// The external moderation phase wasn't defined, create it.
const result = await create(values);
// Redirect the user to the new external moderation phase page.
router.push(getExternalModerationPhaseLink(result.phase.id));
// We don't need to close this modal because we are navigating...
}
return;
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return { [FORM_ERROR]: err.message };
}
},
[phase, create, update, router]
);
return (
<Form onSubmit={onSubmit} initialValues={initialValues(phase)}>
{({ handleSubmit, submitting, submitError, pristine }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field name="name" validate={composeValidators(required)}>
{({ input, meta }) => (
<FormField>
<Localized id="configure-moderationPhases-name">
<Label>Name</Label>
</Localized>
<TextField {...input} color={colorFromMeta(meta)} fullWidth />
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<Field
name="url"
validate={composeValidators(required, validateURL)}
>
{({ input, meta }) => (
<FormField>
<Localized id="configure-moderationPhases-endpointURL">
<Label>Callback URL</Label>
</Localized>
<Localized id="configure-moderationPhases-endpointURL-details">
<HelperText>
The URL that Coral moderation requests will be POST'ed to.
The provided URL must respond within the designated
timeout or the decision of the moderation action will be
skipped.
</HelperText>
</Localized>
<TextField
{...input}
placeholder="https://"
color={colorFromMeta(meta)}
fullWidth
/>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<Field
name="timeout"
parse={parseInteger}
validate={composeValidators(
required,
validateWholeNumberBetween(100, 10000)
)}
>
{({ input, meta }) => (
<FormField>
<Localized id="configure-moderationPhases-timeout">
<Label>Timeout</Label>
</Localized>
<Localized id="configure-moderationPhases-timeout-details">
<HelperText>
The time that Coral will wait for your moderation response
in milliseconds.
</HelperText>
</Localized>
<TextField
{...input}
type="number"
color={colorFromMeta(meta)}
fullWidth
/>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<Field name="format" validate={composeValidators(required)}>
{({ input, meta }) => (
<FormField>
<Localized id="configure-moderationPhases-format">
<Label>Comment Body Format</Label>
</Localized>
<Localized id="configure-moderationPhases-format-details">
<HelperText>
The format that Coral will send the comment body in. By
default, Coral will send the comment in the original HTML
encoded format. If "Plain Text" is selected, then the HTML
stripped version will be sent instead.
</HelperText>
</Localized>
<SelectField {...input} fullWidth>
<Localized id="configure-moderationPhases-format-html">
<Option value={GQLCOMMENT_BODY_FORMAT.HTML}>HTML</Option>
</Localized>
<Localized id="configure-moderationPhases-format-plain">
<Option value={GQLCOMMENT_BODY_FORMAT.PLAIN_TEXT}>
Plain Text
</Option>
</Localized>
</SelectField>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<Flex direction="row" justifyContent="flex-end" itemGutter>
{onCancel && (
<Localized id="configure-moderationPhases-cancelButton">
<Button type="button" color="mono" onClick={onCancel}>
Cancel
</Button>
</Localized>
)}
{phase ? (
<Localized id="configure-moderationPhases-updateExternalModerationPhaseButton">
<Button type="submit" disabled={submitting || pristine}>
Update details
</Button>
</Localized>
) : (
<Localized id="configure-moderationPhases-addExternalModerationPhase">
<Button type="submit" disabled={submitting}>
Add external moderation phase
</Button>
</Localized>
)}
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
phase: graphql`
fragment ConfigureExternalModerationPhaseForm_phase on ExternalModerationPhase {
id
name
url
timeout
format
}
`,
})(ConfigureExternalModerationPhaseForm)
);
export default enhanced;
@@ -0,0 +1,41 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { CreateExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/CreateExternalModerationPhaseMutation.graphql";
let clientMutationId = 0;
const CreateExternalModerationPhaseMutation = createMutation(
"createExternalModerationPhase",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CreateExternalModerationPhaseMutation(
$input: CreateExternalModerationPhaseInput!
) {
createExternalModerationPhase(input: $input) {
phase {
id
}
settings {
...ModerationPhasesConfigContainer_settings
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default CreateExternalModerationPhaseMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateExternalModerationPhaseMutation as MutationTypes } from "coral-admin/__generated__/UpdateExternalModerationPhaseMutation.graphql";
let clientMutationId = 0;
const UpdateExternalModerationPhaseMutation = createMutation(
"updateExternalModerationPhase",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateExternalModerationPhaseMutation(
$input: UpdateExternalModerationPhaseInput!
) {
updateExternalModerationPhase(input: $input) {
phase {
...ConfigureExternalModerationPhaseContainer_phase
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateExternalModerationPhaseMutation;
@@ -0,0 +1,4 @@
export {
default,
default as ConfigureExternalModerationPhaseForm,
} from "./ConfigureExternalModerationPhaseForm";
@@ -0,0 +1,23 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import ExperimentalCallOut from "coral-admin/components/ExperimentalCallOut";
import { ExternalLink } from "coral-framework/lib/i18n/components";
const ExperimentalExternalModerationPhaseCallOut: FunctionComponent = () => (
<Localized
id="configure-moderationPhases-experimentalFeature"
ContactUsLink={<ExternalLink href="https://coralproject.net/contact/" />}
>
<ExperimentalCallOut>
The custom moderation phases feature is currently in active development.
Please{" "}
<ExternalLink href="https://coralproject.net/contact/">
contact us with any feedback or requests
</ExternalLink>
.
</ExperimentalCallOut>
</Localized>
);
export default ExperimentalExternalModerationPhaseCallOut;
@@ -0,0 +1,10 @@
.urlColumn {
width: 100%;
}
.detailsButton {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-reset);
font-size: var(--v2-font-size-2);
}
@@ -0,0 +1,61 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import getExternalModerationPhaseLink from "coral-admin/helpers/getExternalModerationPhaseLink";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
Flex,
Icon,
TableCell,
TableRow,
} from "coral-ui/components/v2";
import { ExternalModerationPhaseRow_phase } from "coral-admin/__generated__/ExternalModerationPhaseRow_phase.graphql";
import StatusMarker from "./StatusMarker";
import styles from "./ExternalModerationPhaseRow.css";
interface Props {
phase: ExternalModerationPhaseRow_phase;
}
const ExternalModerationPhaseRow: FunctionComponent<Props> = ({ phase }) => (
<TableRow data-testid={`moderation-phase-${phase.id}`}>
<TableCell className={styles.urlColumn}>{phase.name}</TableCell>
<TableCell>
<StatusMarker enabled={phase.enabled} />
</TableCell>
<TableCell>
<Flex justifyContent="flex-end">
<Localized
id="configure-moderationPhases-detailsButton"
icon={<Icon>keyboard_arrow_right</Icon>}
>
<Button
variant="text"
to={getExternalModerationPhaseLink(phase.id)}
iconRight
>
Details
<Icon>keyboard_arrow_right</Icon>
</Button>
</Localized>
</Flex>
</TableCell>
</TableRow>
);
const enhanced = withFragmentContainer<Props>({
phase: graphql`
fragment ExternalModerationPhaseRow_phase on ExternalModerationPhase {
id
name
enabled
}
`,
})(ExternalModerationPhaseRow);
export default enhanced;
@@ -0,0 +1,123 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { urls } from "coral-framework/helpers";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
CallOut,
FormFieldDescription,
HorizontalGutter,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "coral-ui/components/v2";
import { ModerationPhasesConfigContainer_settings } from "coral-admin/__generated__/ModerationPhasesConfigContainer_settings.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import Subheader from "../../Subheader";
import ExperimentalExternalModerationPhaseCallOut from "./ExperimentalExternalModerationPhaseCallOut";
import ExternalModerationPhaseRow from "./ExternalModerationPhaseRow";
interface Props {
settings: ModerationPhasesConfigContainer_settings;
}
const ModerationPhasesConfigContainer: FunctionComponent<Props> = ({
settings,
}) => {
return (
<HorizontalGutter size="double" data-testid="moderation-phases-container">
<ExperimentalExternalModerationPhaseCallOut />
<ConfigBox
title={
<Localized id="configure-moderationPhases-header-title">
<Header htmlFor="configure-moderationPhases-header.title">
Moderation Phases
</Header>
</Localized>
}
>
<Localized
id="configure-moderationPhases-description"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/EXTERNAL_MODERATION_PHASES.md#request-signing" />
}
>
<FormFieldDescription>
Configure a external moderation phase to automate some moderation
actions. Moderation requests will be JSON encoded and signed. To
learn more about moderation requests, visit our{" "}
<ExternalLink href="https://github.com/coralproject/talk/blob/master/EXTERNAL_MODERATION_PHASES.md#request-signing">
docs
</ExternalLink>
.
</FormFieldDescription>
</Localized>
<Button
to={urls.admin.addExternalModerationPhase}
iconLeft
data-testid="add-external-moderation-phase"
>
<Icon size="md">add</Icon>
<Localized id="configure-moderationPhases-addExternalModerationPhaseButton">
Add external moderation phase
</Localized>
</Button>
<Localized id="configure-moderationPhases-moderationPhases">
<Subheader>Moderation Phases</Subheader>
</Localized>
{settings.integrations.external &&
settings.integrations.external.phases.length > 0 ? (
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="configure-moderationPhases-name">
<TableCell>Name</TableCell>
</Localized>
<Localized id="configure-moderationPhases-status">
<TableCell>Status</TableCell>
</Localized>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{settings.integrations.external.phases.map((phase, idx) => (
<ExternalModerationPhaseRow key={idx} phase={phase} />
))}
</TableBody>
</Table>
) : (
<Localized id="configure-moderationPhases-noExternalModerationPhases">
<CallOut color="regular" fullWidth>
There are no external moderation phases configured, add one above.
</CallOut>
</Localized>
)}
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment ModerationPhasesConfigContainer_settings on Settings {
integrations {
external {
phases {
...ExternalModerationPhaseRow_phase
}
}
}
}
`,
})(ModerationPhasesConfigContainer);
export default enhanced;
@@ -0,0 +1,37 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components/v2";
import { ModerationPhasesConfigRouteQueryResponse } from "coral-admin/__generated__/ModerationPhasesConfigRouteQuery.graphql";
import ModerationPhasesConfigContainer from "./ModerationPhasesConfigContainer";
interface Props {
data: ModerationPhasesConfigRouteQueryResponse | null;
}
const ModerationPhasesConfigRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return <ModerationPhasesConfigContainer settings={data.settings} />;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query ModerationPhasesConfigRouteQuery {
settings {
...ModerationPhasesConfigContainer_settings
}
}
`,
})(ModerationPhasesConfigRoute);
export default enhanced;
@@ -0,0 +1,27 @@
import React, { FunctionComponent } from "react";
import MainLayout from "coral-admin/components/MainLayout";
import ConfigureLinks from "../../ConfigureLinks";
import Layout from "../../Layout";
import Main from "../../Main";
import SideBar from "../../SideBar";
interface Props {
children: React.ReactElement;
}
const ModerationPhasesLayout: FunctionComponent<Props> = (props) => {
return (
<MainLayout>
<Layout>
<SideBar>
<ConfigureLinks />
</SideBar>
<Main>{props.children}</Main>
</Layout>
</MainLayout>
);
};
export default ModerationPhasesLayout;
@@ -0,0 +1,12 @@
.success {
background-color: var(--v2-palette-success-main);
border-color: var(--v2-palette-success-main);
color: var(--v2-colors-pure-white);
}
.error {
background-color: var(--v2-palette-error-darkest);
border-color: var(--v2-palette-error-darkest);
color: var(--v2-colors-pure-white);
font-weight: var(--v2-font-weight-primary-semi-bold);
}
@@ -0,0 +1,23 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Marker } from "coral-ui/components/v2";
import styles from "./StatusMarker.css";
interface Props {
enabled: boolean;
}
const StatusMarker: FunctionComponent<Props> = ({ enabled }) =>
enabled ? (
<Localized id="configure-moderationPhases-enabledModerationPhase">
<Marker className={styles.success}>Enabled</Marker>
</Localized>
) : (
<Localized id="configure-moderationPhases-disableModerationPhase">
<Marker className={styles.error}>Disabled</Marker>
</Localized>
);
export default StatusMarker;
@@ -0,0 +1,6 @@
export {
default,
default as ModerationPhasesConfigRoute,
} from "./ModerationPhasesConfigRoute";
export { default as AddExternalModerationPhaseRoute } from "./AddExternalModerationPhase";
export { default as ConfigureExternalModerationPhaseRoute } from "./ConfigureExternalModerationPhase";
@@ -1,3 +1,4 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
@@ -19,7 +20,9 @@ const EndpointDetails: FunctionComponent<Props> = ({
settings,
}) => (
<>
<Subheader>Endpoint details</Subheader>
<Localized id="configure-webhooks-endpointDetails">
<Subheader>Endpoint details</Subheader>
</Localized>
<ConfigureWebhookEndpointForm
settings={settings}
webhookEndpoint={webhookEndpoint}
@@ -62,9 +62,14 @@ const EndpointStatus: FunctionComponent<Props> = ({ webhookEndpoint }) => {
/>
<CopyButton text={webhookEndpoint.signingSecret.secret} />
</Flex>
<HelperText>
KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt}
</HelperText>
<Localized
id="configure-webhooks-generatedAt"
$date={new Date(webhookEndpoint.signingSecret.createdAt)}
>
<HelperText>
KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt}
</HelperText>
</Localized>
</FormField>
</>
);
@@ -22,7 +22,7 @@ import {
} from "coral-ui/components/v2";
import AppNotification from "coral-ui/components/v2/AppNotification";
import RotateWebhookEndpointSecretMutation from "./RotateWebhookEndpointSecretMutation";
import RotateWebhookEndpointSigningSecretMutation from "./RotateWebhookEndpointSigningSecretMutation";
import styles from "./RotateSigningSecretModal.css";
@@ -32,20 +32,23 @@ interface Props {
open: boolean;
}
const RotateWebhookEndpointSecretModal: FunctionComponent<Props> = ({
const RotateWebhookEndpointSigningSecretModal: FunctionComponent<Props> = ({
onHide,
open,
endpointID,
}) => {
const rotateWebhookEndpointSecret = useMutation(
RotateWebhookEndpointSecretMutation
const rotateWebhookEndpointSigningSecret = useMutation(
RotateWebhookEndpointSigningSecretMutation
);
const { setMessage, clearMessage } = useNotification();
const onRotateSecret = useCallback(
async ({ inactiveIn: inactiveInString }) => {
try {
const inactiveIn = parseInt(inactiveInString, 10);
await rotateWebhookEndpointSecret({ id: endpointID, inactiveIn });
await rotateWebhookEndpointSigningSecret({
id: endpointID,
inactiveIn,
});
// Post a notification about the successful change.
setMessage(
@@ -56,7 +59,9 @@ const RotateWebhookEndpointSecretModal: FunctionComponent<Props> = ({
</AppNotification>
</Localized>
);
window.scroll(0, 0);
// Scroll after a zero timeout because chrome won't scroll otherwise.
setTimeout(() => window.scroll(0, 0), 0);
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
@@ -69,7 +74,7 @@ const RotateWebhookEndpointSecretModal: FunctionComponent<Props> = ({
return;
},
[endpointID, rotateWebhookEndpointSecret]
[endpointID, rotateWebhookEndpointSigningSecret]
);
return (
@@ -162,4 +167,4 @@ const RotateWebhookEndpointSecretModal: FunctionComponent<Props> = ({
);
};
export default RotateWebhookEndpointSecretModal;
export default RotateWebhookEndpointSigningSecretModal;
@@ -7,19 +7,19 @@ import {
MutationInput,
} from "coral-framework/lib/relay";
import { RotateWebhookEndpointSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSecretMutation.graphql";
import { RotateWebhookEndpointSigningSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSigningSecretMutation.graphql";
let clientMutationId = 0;
const RotateWebhookEndpointSecretMutation = createMutation(
"rotateWebhookEndpointSecret",
const RotateWebhookEndpointSigningSecretMutation = createMutation(
"rotateWebhookEndpointSigningSecret",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateWebhookEndpointSecretMutation(
$input: RotateWebhookEndpointSecretInput!
mutation RotateWebhookEndpointSigningSecretMutation(
$input: RotateWebhookEndpointSigningSecretInput!
) {
rotateWebhookEndpointSecret(input: $input) {
rotateWebhookEndpointSigningSecret(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
@@ -35,4 +35,4 @@ const RotateWebhookEndpointSecretMutation = createMutation(
})
);
export default RotateWebhookEndpointSecretMutation;
export default RotateWebhookEndpointSigningSecretMutation;
@@ -11,3 +11,8 @@ export {
ConfigureWebhookEndpointRoute,
AddWebhookEndpointRoute,
} from "./WebhookEndpoints";
export {
ModerationPhasesConfigRoute,
ConfigureExternalModerationPhaseRoute,
AddExternalModerationPhaseRoute,
} from "./ModerationPhases";
@@ -52,6 +52,15 @@ exports[`renders configure advanced 1`] = `
Moderation
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/moderation/phases"
onClick={[Function]}
>
Moderation Phases
</a>
</li>
<li>
<a
className="Link-link"
@@ -52,6 +52,15 @@ exports[`renders configure auth 1`] = `
Moderation
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/moderation/phases"
onClick={[Function]}
>
Moderation Phases
</a>
</li>
<li>
<a
className="Link-link"
@@ -1284,10 +1293,10 @@ more about creating a JWT Token with
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignCenter"
>
<div
className="SSOKeyCard-keySection"
className="SSOSigningSecretCard-keySection"
>
<div
className="SSOKeyCard-label"
className="SSOSigningSecretCard-label"
>
<label
className="Label-root"
@@ -1309,10 +1318,10 @@ more about creating a JWT Token with
</div>
</div>
<div
className="SSOKeyCard-secretSection"
className="SSOSigningSecretCard-secretSection"
>
<div
className="SSOKeyCard-label"
className="SSOSigningSecretCard-label"
>
<label
className="Label-root"
@@ -1390,10 +1399,10 @@ more about creating a JWT Token with
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
>
<div
className="SSOKeyCard-statusSection"
className="SSOSigningSecretCard-statusSection"
>
<div
className="SSOKeyCard-label"
className="SSOSigningSecretCard-label"
>
<label
className="Label-root"
@@ -52,6 +52,15 @@ exports[`renders configure general 1`] = `
Moderation
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/moderation/phases"
onClick={[Function]}
>
Moderation Phases
</a>
</li>
<li>
<a
className="Link-link"
@@ -52,6 +52,15 @@ exports[`renders configure moderation 1`] = `
Moderation
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/moderation/phases"
onClick={[Function]}
>
Moderation Phases
</a>
</li>
<li>
<a
className="Link-link"
@@ -52,6 +52,15 @@ exports[`renders configure organization 1`] = `
Moderation
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/moderation/phases"
onClick={[Function]}
>
Moderation Phases
</a>
</li>
<li>
<a
className="Link-link"
@@ -52,6 +52,15 @@ exports[`renders configure wordList 1`] = `
Moderation
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/moderation/phases"
onClick={[Function]}
>
Moderation Phases
</a>
</li>
<li>
<a
className="Link-link Link-linkActive"
@@ -62,7 +62,7 @@ it("rotate sso key", async () => {
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Mutation: {
rotateSSOKey: () => {
rotateSSOSigningSecret: () => {
return {
settings: pureMerge<typeof settingsWithEmptyAuth>(
settingsWithEmptyAuth,
@@ -71,7 +71,7 @@ it("rotate sso key", async () => {
integrations: {
sso: {
enabled: true,
keys: [
signingSecrets: [
{
kid: "kid-01",
secret: "secret",
+2 -2
View File
@@ -114,7 +114,7 @@ export const settings = createFixture<GQLSettings>({
admin: true,
stream: true,
},
keys: [
signingSecrets: [
{
kid: "kid-01",
secret: "secret",
@@ -212,7 +212,7 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
stream: true,
},
key: "",
keys: [
signingSecrets: [
{
kid: "kid-01",
secret: "secret",
+3 -1
View File
@@ -1,7 +1,9 @@
export default {
admin: {
moderate: "/admin/moderate",
configureWebhooks: "/admin/configure/webhooks",
moderationPhases: "/admin/configure/moderation/phases",
addExternalModerationPhase: "/admin/configure/moderation/phases/add",
configureExternalModerationPhase: "/admin/configure/moderation/phases",
webhooks: "/admin/configure/webhooks",
addWebhookEndpoint: "/admin/configure/webhooks/add",
configureWebhookEndpoint: "/admin/configure/webhooks/endpoint",
+2 -2
View File
@@ -92,13 +92,13 @@ export const NOT_A_WHOLE_NUMBER = () => (
export const NOT_A_WHOLE_NUMBER_GREATER_THAN = (x: number) => (
<Localized id="framework-validation-notAWholeNumberGreaterThan" $x={x}>
<span>Please enter a valid whole number greater than $x</span>
<span>Please enter a valid whole number greater than {x}</span>
</Localized>
);
export const NOT_A_WHOLE_NUMBER_GREATER_THAN_OR_EQUAL = (x: number) => (
<Localized id="framework-validation-notAWholeNumberGreaterThanOrEqual" $x={x}>
<span>Please enter a valid whole number greater than or equal to $x</span>
<span>Please enter a valid whole number greater than or equal to {x}</span>
</Localized>
);
+1 -2
View File
@@ -1,7 +1,5 @@
import { ReactNode } from "react";
import startsWith from "coral-common/utils/startsWith";
import {
EMAIL_REGEX,
PASSWORD_MIN_LENGTH,
@@ -10,6 +8,7 @@ import {
USERNAME_MIN_LENGTH,
USERNAME_REGEX,
} from "coral-common/helpers/validate";
import startsWith from "coral-common/utils/startsWith";
import {
DELETE_CONFIRMATION_INVALID,