[CORL-687] Webhooks (#2738)

* feat: initial webhook impl

* feat: added support for key rotation

* feat: harmonized fetcher

* feat: added expired secrets cleaning

* feat: event system refactor

* feat: added story event

* feat: simplfiied webhook handler

* feat: added ref's to locations where user events can be added

* feat: added UI to support webhooks

* fix: renaming some Webhook -> WebhookEndpoint

* fix: review comments to adjuist flow

* feat: added localizations

* fix: linting, updated snapshots

* fix: adapted for new fluent

* fix: rearranged folders

* fix: linting

* feat: added webhooks documentation

* feat: improved toc generation

* feat: added some tests to webhooks

* fix: chain transition hooks

* feat: added tests around webhook ui

* fix: renamed events

* fix: adjusted circle markdown linting

* fix: adjusted doctoc script call

* review: review fixes

* review: review comments

* review: adjusted signing secret confirmation

* review: adjusted styles to harmonize button usage

* fix: updated snapshots and tests

* review: move form out of webhooks

Moved the form out of the webhooks by relocating the layout used for the
route associated with the configure routes.

* fix: fixed bugs and snapshots with tests

* feat: revised slack message format to use block api

* fix: fixed a small text bug

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Wyatt Johnson
2020-02-18 13:25:48 -05:00
committed by GitHub
co-authored by Vinh Kim Gardner
parent 34ba2da88d
commit e42c2b925d
137 changed files with 5633 additions and 1020 deletions
@@ -0,0 +1,5 @@
import { urls } from "coral-framework/helpers";
export default function getEndpointLink(endpointID: string) {
return `${urls.admin.configureWebhookEndpoint}/${endpointID}`;
}
+12
View File
@@ -9,18 +9,22 @@ import { createAuthCheckRoute } from "./routes/AuthCheck";
import CommunityRoute from "./routes/Community";
import ConfigureRoute from "./routes/Configure";
import {
AddWebhookEndpointRoute,
AdvancedConfigRoute,
AuthConfigRoute,
ConfigureWebhookEndpointRoute,
EmailConfigRoute,
GeneralConfigRoute,
ModerationConfigRoute,
OrganizationConfigRoute,
SlackConfigRoute,
WebhookEndpointsConfigRoute,
WordListConfigRoute,
} from "./routes/Configure/sections";
import { Sites } from "./routes/Configure/sections/Sites";
import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute";
import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute";
import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout";
import ForgotPasswordRoute from "./routes/ForgotPassword";
import InviteRoute from "./routes/Invite";
import LoginRoute from "./routes/Login";
@@ -113,6 +117,14 @@ export default makeRouteConfig(
<Route path="email" {...EmailConfigRoute.routeConfig} />
<Route path="slack" {...SlackConfigRoute.routeConfig} />
</Route>
<Route path="configure/webhooks" Component={WebhookEndpointsLayout}>
<Route path="/" {...WebhookEndpointsConfigRoute.routeConfig} />
<Route path="add" {...AddWebhookEndpointRoute.routeConfig} />
<Route
path="endpoint/:webhookEndpointID"
{...ConfigureWebhookEndpointRoute.routeConfig}
/>
</Route>
<Route path="configure/organization/sites" Component={Sites}>
<Redirect from="/" to="/admin/configure/organization/sites/new" />
<Route path="new" {...AddSiteRoute.routeConfig} />
@@ -28,6 +28,9 @@ const ConfigureLinks: FunctionComponent<{}> = () => {
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-webhooks">
<Link to="/admin/configure/webhooks">Webhooks</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
@@ -24,7 +24,7 @@ class NavigationWarningContainer extends React.Component<Props> {
);
this.removeTransitionHook = props.router.addTransitionHook(() =>
this.props.active ? warningMessage : true
this.props.active ? warningMessage : undefined
);
}
@@ -44,11 +44,7 @@ const SitesConfig: FunctionComponent<Props> = ({
id="configure-organization-sites-add-site"
icon={<Icon>add</Icon>}
>
<Button
to="/admin/configure/organization/sites/new"
iconLeft
size="large"
>
<Button to="/admin/configure/organization/sites/new" iconLeft>
<Icon>add</Icon>
Add a site
</Button>
@@ -107,7 +107,7 @@ const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
on how to create a Slack App see our documentation.
</FormFieldDescription>
</Localized>
<Button color="dark" onClick={onAddChannel}>
<Button iconLeft onClick={onAddChannel}>
<ButtonIcon size="md" className={styles.icon}>
add
</ButtonIcon>
@@ -0,0 +1,58 @@
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 { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components/v2";
import { AddWebhookEndpointContainer_settings } from "coral-admin/__generated__/AddWebhookEndpointContainer_settings.graphql";
import { ConfigureWebhookEndpointForm } from "../ConfigureWebhookEndpointForm";
interface Props {
router: Router;
match: Match;
settings: AddWebhookEndpointContainer_settings;
}
const AddWebhookEndpointContainer: FunctionComponent<Props> = ({
settings,
router,
}) => {
const onCancel = useCallback(() => {
router.push(urls.admin.webhooks);
}, [router]);
return (
<HorizontalGutter size="double">
<ConfigBox
title={
<Localized id="configure-webhooks-addEndpoint">
<Header>Add a webhook endpoint</Header>
</Localized>
}
>
<ConfigureWebhookEndpointForm
settings={settings}
webhookEndpoint={null}
onCancel={onCancel}
/>
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
settings: graphql`
fragment AddWebhookEndpointContainer_settings on Settings {
...ConfigureWebhookEndpointForm_settings
}
`,
})(AddWebhookEndpointContainer)
);
export default enhanced;
@@ -0,0 +1,38 @@
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 { AddWebhookEndpointRouteQueryResponse } from "coral-admin/__generated__/AddWebhookEndpointRouteQuery.graphql";
import AddWebhookEndpointContainer from "./AddWebhookEndpointContainer";
interface Props {
data: AddWebhookEndpointRouteQueryResponse | null;
}
const AddWebhookEndpointRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return <AddWebhookEndpointContainer settings={data.settings} />;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query AddWebhookEndpointRouteQuery {
settings {
...AddWebhookEndpointContainer_settings
}
}
`,
cacheConfig: { force: true },
})(AddWebhookEndpointRoute);
export default enhanced;
@@ -0,0 +1,4 @@
export {
default,
default as AddWebhookEndpointRoute,
} from "./AddWebhookEndpointRoute";
@@ -0,0 +1,62 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import ConfigBox from "coral-admin/routes/Configure/ConfigBox";
import Header from "coral-admin/routes/Configure/Header";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components/v2";
import { ConfigureWebhookEndpointContainer_settings } from "coral-admin/__generated__/ConfigureWebhookEndpointContainer_settings.graphql";
import { ConfigureWebhookEndpointContainer_webhookEndpoint } from "coral-admin/__generated__/ConfigureWebhookEndpointContainer_webhookEndpoint.graphql";
import EndpointDangerZone from "./EndpointDangerZone";
import EndpointDetails from "./EndpointDetails";
import EndpointStatus from "./EndpointStatus";
interface Props {
webhookEndpoint: ConfigureWebhookEndpointContainer_webhookEndpoint;
settings: ConfigureWebhookEndpointContainer_settings;
}
const ConfigureWebhookEndpointContainer: FunctionComponent<Props> = ({
webhookEndpoint,
settings,
}) => {
return (
<HorizontalGutter size="double" data-testid="webhook-endpoint-container">
<ConfigBox
title={
<Localized id="configure-webhooks-configureWebhookEndpoint">
<Header htmlFor="configure-webhooks-header.title">
Configure webhook endpoint
</Header>
</Localized>
}
>
<EndpointDetails
webhookEndpoint={webhookEndpoint}
settings={settings}
/>
<EndpointStatus webhookEndpoint={webhookEndpoint} />
<EndpointDangerZone webhookEndpoint={webhookEndpoint} />
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment ConfigureWebhookEndpointContainer_webhookEndpoint on WebhookEndpoint {
...EndpointDangerZone_webhookEndpoint
...EndpointDetails_webhookEndpoint
...EndpointStatus_webhookEndpoint
}
`,
settings: graphql`
fragment ConfigureWebhookEndpointContainer_settings on Settings {
...EndpointDetails_settings
}
`,
})(ConfigureWebhookEndpointContainer);
export default enhanced;
@@ -0,0 +1,62 @@
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 { ConfigureWebhookEndpointRouteQueryResponse } from "coral-admin/__generated__/ConfigureWebhookEndpointRouteQuery.graphql";
import ConfigureWebhookContainer from "./ConfigureWebhookEndpointContainer";
interface Props {
data: ConfigureWebhookEndpointRouteQueryResponse | null;
}
const ConfigureWebhookEndpointRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
if (!data.webhookEndpoint) {
return (
<Localized id="configure-webhooks-webhookEndpointNotFound">
<CallOut color="error" fullWidth>
Webhook endpoint not found
</CallOut>
</Localized>
);
}
return (
<ConfigureWebhookContainer
webhookEndpoint={data.webhookEndpoint}
settings={data.settings}
/>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query ConfigureWebhookEndpointRouteQuery($webhookEndpointID: ID!) {
webhookEndpoint(id: $webhookEndpointID) {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
settings {
...ConfigureWebhookEndpointContainer_settings
}
}
`,
cacheConfig: { force: true },
prepareVariables: (params, match) => {
return {
webhookEndpointID: match.params.webhookEndpointID,
};
},
})(ConfigureWebhookEndpointRoute);
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 { DeleteWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/DeleteWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const DeleteWebhookEndpointMutation = createMutation(
"deleteWebhookEndpoint",
(environment: Environment, { id }: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteWebhookEndpointMutation(
$input: DeleteWebhookEndpointInput!
) {
deleteWebhookEndpoint(input: $input) {
endpoint {
id
}
}
}
`,
variables: {
input: {
id,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default DeleteWebhookEndpointMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DisableWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/DisableWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const DisableWebhookEndpointMutation = createMutation(
"disableWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DisableWebhookEndpointMutation(
$input: DisableWebhookEndpointInput!
) {
disableWebhookEndpoint(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default DisableWebhookEndpointMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { EnableWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/EnableWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const EnableWebhookEndpointMutation = createMutation(
"enableWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation EnableWebhookEndpointMutation(
$input: EnableWebhookEndpointInput!
) {
enableWebhookEndpoint(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default EnableWebhookEndpointMutation;
@@ -0,0 +1,181 @@
import { Localized } from "@fluent/react/compat";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback, useState } from "react";
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 {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import {
Button,
FormField,
FormFieldDescription,
Label,
} from "coral-ui/components/v2";
import { EndpointDangerZone_webhookEndpoint } from "coral-admin/__generated__/EndpointDangerZone_webhookEndpoint.graphql";
import DeleteWebhookEndpointMutation from "./DeleteWebhookEndpointMutation";
import DisableWebhookEndpointMutation from "./DisableWebhookEndpointMutation";
import EnableWebhookEndpointMutation from "./EnableWebhookEndpointMutation";
import RotateSigningSecretModal from "./RotateSigningSecretModal";
interface Props {
webhookEndpoint: EndpointDangerZone_webhookEndpoint;
router: Router;
match: Match;
}
const EndpointDangerZone: FunctionComponent<Props> = ({
webhookEndpoint,
router,
}) => {
const { localeBundles } = useCoralContext();
const enableWebhookEndpoint = useMutation(EnableWebhookEndpointMutation);
const disableWebhookEndpoint = useMutation(DisableWebhookEndpointMutation);
const deleteWebhookEndpoint = useMutation(DeleteWebhookEndpointMutation);
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-webhooks-confirmEnable",
"Enabling the webhook endpoint will start to send events to this URL. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await enableWebhookEndpoint({ id: webhookEndpoint.id });
}
}, [webhookEndpoint, enableWebhookEndpoint]);
const onDisable = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-webhooks-confirmDisable",
"Disabling this webhook endpoint will stop any new events from being sent to this URL. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await disableWebhookEndpoint({ id: webhookEndpoint.id });
}
}, [webhookEndpoint, disableWebhookEndpoint]);
const onDelete = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-webhooks-confirmDelete",
"Deleting this webhook endpoint will stop any new events from being sent to this URL, and remove all the associated settings with this webhook endpoint. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await deleteWebhookEndpoint({ id: webhookEndpoint.id });
// Send the user back to the webhook endpoints listing.
router.push(urls.admin.webhooks);
}
}, [webhookEndpoint, disableWebhookEndpoint, router]);
return (
<>
<Localized id="configure-webhooks-dangerZone">
<Subheader>Danger Zone</Subheader>
</Localized>
<FormField>
<Localized id="configure-webhooks-rotateSigningSecret">
<Label>Rotate signing secret</Label>
</Localized>
<Localized id="configure-webhooks-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-webhooks-rotateSigningSecretButton">
<Button color="alert" onClick={onRotateSecret}>
Rotate signing secret
</Button>
</Localized>
</FormField>
<RotateSigningSecretModal
endpointID={webhookEndpoint.id}
onHide={onHideRotateSecret}
open={rotateSecretOpen}
/>
{webhookEndpoint.enabled ? (
<FormField>
<Localized id="configure-webhooks-disableEndpoint">
<Label>Disable endpoint</Label>
</Localized>
<Localized id="configure-webhooks-disableEndpointDescription">
<FormFieldDescription>
This endpoint is current enabled. By disabling this endpoint no
new events will be sent to the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-disableEndpointButton">
<Button color="alert" onClick={onDisable}>
Disable endpoint
</Button>
</Localized>
</FormField>
) : (
<FormField>
<Localized id="configure-webhooks-enableEndpoint">
<Label>Enable endpoint</Label>
</Localized>
<Localized id="configure-webhooks-enableEndpointDescription">
<FormFieldDescription>
This endpoint is current disabled. By enabling this endpoint new
events will be sent to the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-enableEndpointButton">
<Button color="regular" onClick={onEnable}>
Enable endpoint
</Button>
</Localized>
</FormField>
)}
<FormField>
<Localized id="configure-webhooks-deleteEndpoint">
<Label>Delete endpoint</Label>
</Localized>
<Localized id="configure-webhooks-deleteEndpointDescription">
<FormFieldDescription>
Deleting the endpoint will prevent any new events from being sent to
the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-deleteEndpointButton">
<Button color="alert" onClick={onDelete}>
Delete endpoint
</Button>
</Localized>
</FormField>
</>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment EndpointDangerZone_webhookEndpoint on WebhookEndpoint {
id
enabled
}
`,
})(EndpointDangerZone)
);
export default enhanced;
@@ -0,0 +1,42 @@
import React, { FunctionComponent } from "react";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { EndpointDetails_settings } from "coral-admin/__generated__/EndpointDetails_settings.graphql";
import { EndpointDetails_webhookEndpoint } from "coral-admin/__generated__/EndpointDetails_webhookEndpoint.graphql";
import ConfigureWebhookEndpointForm from "../ConfigureWebhookEndpointForm";
interface Props {
webhookEndpoint: EndpointDetails_webhookEndpoint;
settings: EndpointDetails_settings;
}
const EndpointDetails: FunctionComponent<Props> = ({
webhookEndpoint,
settings,
}) => (
<>
<Subheader>Endpoint details</Subheader>
<ConfigureWebhookEndpointForm
settings={settings}
webhookEndpoint={webhookEndpoint}
/>
</>
);
const enhanced = withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment EndpointDetails_webhookEndpoint on WebhookEndpoint {
...ConfigureWebhookEndpointForm_webhookEndpoint
}
`,
settings: graphql`
fragment EndpointDetails_settings on Settings {
...ConfigureWebhookEndpointForm_settings
}
`,
})(EndpointDetails);
export default enhanced;
@@ -0,0 +1,85 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { CopyButton } from "coral-framework/components";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Flex,
FormField,
FormFieldDescription,
HelperText,
Label,
PasswordField,
} from "coral-ui/components/v2";
import { EndpointStatus_webhookEndpoint } from "coral-admin/__generated__/EndpointStatus_webhookEndpoint.graphql";
import StatusMarker from "../StatusMarker";
interface Props {
webhookEndpoint: EndpointStatus_webhookEndpoint;
}
const EndpointStatus: FunctionComponent<Props> = ({ webhookEndpoint }) => {
return (
<>
<Localized id="configure-webhooks-endpointStatus">
<Subheader>Endpoint status</Subheader>
</Localized>
<FormField>
<Localized id="configure-webhooks-status">
<Label>Status</Label>
</Localized>
<StatusMarker enabled={webhookEndpoint.enabled} />
</FormField>
<FormField>
<Localized id="configure-webhooks-signingSecret">
<Label>Signing secret</Label>
</Localized>
<Localized
id="configure-webhooks-signingSecretDescription"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#webhook-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/WEBHOOKS.md#webhook-signing">
Webhook Guide
</ExternalLink>
.
</FormFieldDescription>
</Localized>
<Flex direction="row" itemGutter="half" alignItems="center">
<PasswordField
value={webhookEndpoint.signingSecret.secret}
fullWidth
readOnly
/>
<CopyButton text={webhookEndpoint.signingSecret.secret} />
</Flex>
<HelperText>
KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt}
</HelperText>
</FormField>
</>
);
};
const enhanced = withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment EndpointStatus_webhookEndpoint on WebhookEndpoint {
id
enabled
signingSecret {
secret
createdAt
}
}
`,
})(EndpointStatus);
export default enhanced;
@@ -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,165 @@
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 RotateWebhookEndpointSecretMutation from "./RotateWebhookEndpointSecretMutation";
import styles from "./RotateSigningSecretModal.css";
interface Props {
endpointID: string;
onHide: () => void;
open: boolean;
}
const RotateWebhookEndpointSecretModal: FunctionComponent<Props> = ({
onHide,
open,
endpointID,
}) => {
const rotateWebhookEndpointSecret = useMutation(
RotateWebhookEndpointSecretMutation
);
const { setMessage, clearMessage } = useNotification();
const onRotateSecret = useCallback(
async ({ inactiveIn: inactiveInString }) => {
try {
const inactiveIn = parseInt(inactiveInString, 10);
await rotateWebhookEndpointSecret({ id: endpointID, inactiveIn });
// Post a notification about the successful change.
setMessage(
<Localized id="configure-webhooks-rotateSigningSecretSuccessUseNewSecret">
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Webhook endpoint signing secret has been rotated. Please ensure
you update your integrations to use the new secret below.
</AppNotification>
</Localized>
);
window.scroll(0, 0);
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return { [FORM_ERROR]: err.message };
}
// Dismiss the modal.
onHide();
return;
},
[endpointID, rotateWebhookEndpointSecret]
);
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-webhooks-rotateSigningSecret">
<h2 className={styles.title}>Rotate signing secret</h2>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="configure-webhooks-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-webhooks-expiresOldSecret">
<Label>Expire the old secret</Label>
</Localized>
<SelectField {...input} fullWidth>
<Localized id="configure-webhooks-expiresOldSecretImmediately">
<Option value="0">Immediately</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={1}
>
<Option value="3600">1 hour from now</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={2}
>
<Option value="7200">2 hours from now</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={12}
>
<Option value="43200">12 hours from now</Option>
</Localized>
<Localized
id="configure-webhooks-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-webhooks-cancelButton">
<Button color="regular" onClick={onHide}>
Cancel
</Button>
</Localized>
<Localized id="configure-webhooks-rotateSigningSecretButton">
<Button
type="submit"
color="alert"
disabled={submitting}
ref={lastFocusableRef}
>
Rotate signing secret
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</Card>
)}
</Modal>
);
};
export default RotateWebhookEndpointSecretModal;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RotateWebhookEndpointSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSecretMutation.graphql";
let clientMutationId = 0;
const RotateWebhookEndpointSecretMutation = createMutation(
"rotateWebhookEndpointSecret",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateWebhookEndpointSecretMutation(
$input: RotateWebhookEndpointSecretInput!
) {
rotateWebhookEndpointSecret(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default RotateWebhookEndpointSecretMutation;
@@ -0,0 +1,4 @@
export {
default,
default as ConfigureWebhookEndpointRoute,
} from "./ConfigureWebhookEndpointRoute";
@@ -0,0 +1,162 @@
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 getEndpointLink from "coral-admin/helpers/getEndpointLink";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import {
composeValidators,
required,
validateURL,
} from "coral-framework/lib/validation";
import {
Button,
CallOut,
Flex,
FormField,
HorizontalGutter,
Label,
TextField,
} from "coral-ui/components/v2";
import { ConfigureWebhookEndpointForm_settings } from "coral-admin/__generated__/ConfigureWebhookEndpointForm_settings.graphql";
import { ConfigureWebhookEndpointForm_webhookEndpoint } from "coral-admin/__generated__/ConfigureWebhookEndpointForm_webhookEndpoint.graphql";
import CreateWebhookEndpointMutation from "./CreateWebhookEndpointMutation";
import EventsSelectField from "./EventsSelectField";
import UpdateWebhookEndpointMutation from "./UpdateWebhookEndpointMutation";
interface Props {
onCancel?: () => void;
router: Router;
match: Match;
webhookEndpoint: ConfigureWebhookEndpointForm_webhookEndpoint | null;
settings: ConfigureWebhookEndpointForm_settings;
}
const ConfigureWebhookEndpointForm: FunctionComponent<Props> = ({
onCancel,
settings,
webhookEndpoint,
router,
}) => {
const create = useMutation(CreateWebhookEndpointMutation);
const update = useMutation(UpdateWebhookEndpointMutation);
const onSubmit = useCallback(
async values => {
try {
if (webhookEndpoint) {
// The webhook endpoint was defined, update it.
await update(values);
} else {
// The webhook endpoint wasn't defined, created it.
const result = await create(values);
// Redirect the user to the new webhook endpoint page.
router.push(getEndpointLink(result.endpoint.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 };
}
},
[webhookEndpoint, create, update, router]
);
return (
<Form
onSubmit={onSubmit}
initialValues={
webhookEndpoint ? webhookEndpoint : { events: [], all: false, url: "" }
}
>
{({ handleSubmit, submitting, submitError, pristine }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field
name="url"
validate={composeValidators(required, validateURL)}
>
{({ input, meta }) => (
<FormField>
<Localized id="configure-webhooks-endpointURL">
<Label>Endpoint URL</Label>
</Localized>
<TextField
{...input}
placeholder="https://"
color={colorFromMeta(meta)}
fullWidth
/>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<EventsSelectField settings={settings} />
<Flex direction="row" justifyContent="flex-end" itemGutter>
{onCancel && (
<Localized id="configure-webhooks-cancelButton">
<Button type="button" color="mono" onClick={onCancel}>
Cancel
</Button>
</Localized>
)}
{webhookEndpoint ? (
<Localized id="configure-webhooks-updateWebhookEndpointButton">
<Button type="submit" disabled={submitting || pristine}>
Update details
</Button>
</Localized>
) : (
<Localized id="configure-webhooks-addEndpointButton">
<Button type="submit" disabled={submitting}>
Add webhook endpoint
</Button>
</Localized>
)}
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment ConfigureWebhookEndpointForm_webhookEndpoint on WebhookEndpoint {
id
url
events
all
}
`,
settings: graphql`
fragment ConfigureWebhookEndpointForm_settings on Settings {
...EventsSelectField_settings
}
`,
})(ConfigureWebhookEndpointForm)
);
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 { CreateWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/CreateWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const CreateWebhookEndpointMutation = createMutation(
"createWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CreateWebhookEndpointMutation(
$input: CreateWebhookEndpointInput!
) {
createWebhookEndpoint(input: $input) {
endpoint {
id
}
settings {
...WebhookEndpointsConfigContainer_settings
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default CreateWebhookEndpointMutation;
@@ -0,0 +1,7 @@
.list {
max-height: 295px;
}
.event {
font-family: monospace;
}
@@ -0,0 +1,152 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useField } from "react-final-form";
import { ValidationMessage } from "coral-framework/lib/form";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { validateWebhookEventSelection } from "coral-framework/lib/validation";
import { Typography } from "coral-ui/components";
import {
Button,
CheckBox,
Flex,
FormField,
FormFieldDescription,
HelperText,
Label,
ListGroup,
ListGroupRow,
} from "coral-ui/components/v2";
import {
EventsSelectField_settings,
WEBHOOK_EVENT_NAME,
} from "coral-admin/__generated__/EventsSelectField_settings.graphql";
import styles from "./EventsSelectField.css";
interface Props {
settings: EventsSelectField_settings;
}
const EventsSelectField: FunctionComponent<Props> = ({ settings }) => {
const { input: all } = useField<boolean>("all");
const { input: events, meta } = useField<WEBHOOK_EVENT_NAME[]>("events", {
validate: validateWebhookEventSelection,
});
const onClear = useCallback(() => {
if (all.value) {
all.onChange(false);
} else {
events.onChange([]);
}
}, [all, events]);
const onCheckChange = useCallback(
(event: WEBHOOK_EVENT_NAME, selectedIndex: number) => () => {
const changed = [...events.value];
if (selectedIndex >= 0) {
changed.splice(selectedIndex, 1);
} else {
changed.push(event);
}
events.onChange(changed);
},
[events]
);
const onRecieveAll = useCallback(() => {
all.onChange(true);
}, [all]);
return (
<FormField>
<Flex justifyContent="space-between">
<Localized id="configure-webhooks-eventsToSend">
<Label>Events to send</Label>
</Localized>
{(all.value || events.value.length > 0) && (
<Localized id="configure-webhooks-clearEventsToSend">
<Button variant="text" onClick={onClear}>
Clear
</Button>
</Localized>
)}
</Flex>
<Localized
id="configure-webhooks-eventsToSendDescription"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#events-listing" />
}
>
<FormFieldDescription>
These are the events that are registered to this particular endpoint.
Visit our{" "}
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#events-listing">
Webhook Guide
</ExternalLink>{" "}
for the schema of these events. Any event matching the following will
be sent to the endpoint if it is enabled:
</FormFieldDescription>
</Localized>
<ListGroup className={styles.list}>
{settings.webhookEvents.map(event => {
const selectedIndex = events.value.indexOf(event);
return (
<ListGroupRow key={event}>
<CheckBox
disabled={all.value}
checked={all.value || selectedIndex >= 0}
onChange={onCheckChange(event, selectedIndex)}
>
<Typography className={styles.event}>{event}</Typography>
</CheckBox>
</ListGroupRow>
);
})}
</ListGroup>
{all.value ? (
<Localized id="configure-webhooks-allEvents">
<HelperText>
The endpoint will receive all events, including any added in the
future.
</HelperText>
</Localized>
) : events.value.length > 0 ? (
<Localized
id="configure-webhooks-selectedEvents"
$count={events.value.length}
>
<HelperText>{events.value.length} event selected.</HelperText>
</Localized>
) : (
<Localized
id="configure-webhooks-selectAnEvent"
button={<Button variant="text" onClick={onRecieveAll} />}
>
<HelperText>
Select events above or{" "}
<Button variant="text" onClick={onRecieveAll}>
receive all events
</Button>
.
</HelperText>
</Localized>
)}
<ValidationMessage meta={meta} fullWidth />
</FormField>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment EventsSelectField_settings on Settings {
webhookEvents
}
`,
})(EventsSelectField);
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 { UpdateWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/UpdateWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const UpdateWebhookEndpointMutation = createMutation(
"updateWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateWebhookEndpointMutation(
$input: UpdateWebhookEndpointInput!
) {
updateWebhookEndpoint(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateWebhookEndpointMutation;
@@ -0,0 +1,4 @@
export {
default,
default as ConfigureWebhookEndpointForm,
} from "./ConfigureWebhookEndpointForm";
@@ -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-webhooks-enabledWebhookEndpoint">
<Marker className={styles.success}>Enabled</Marker>
</Localized>
) : (
<Localized id="configure-webhooks-disabledWebhookEndpoint">
<Marker className={styles.error}>Disabled</Marker>
</Localized>
);
export default StatusMarker;
@@ -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,56 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import getEndpointLink from "coral-admin/helpers/getEndpointLink";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
Flex,
Icon,
TableCell,
TableRow,
} from "coral-ui/components/v2";
import { WebhookEndpointRow_webhookEndpoint } from "coral-admin/__generated__/WebhookEndpointRow_webhookEndpoint.graphql";
import StatusMarker from "./StatusMarker";
import styles from "./WebhookEndpointRow.css";
interface Props {
endpoint: WebhookEndpointRow_webhookEndpoint;
}
const WebhookEndpointRow: FunctionComponent<Props> = ({ endpoint }) => (
<TableRow data-testid={`webhook-endpoint-${endpoint.id}`}>
<TableCell className={styles.urlColumn}>{endpoint.url}</TableCell>
<TableCell>
<StatusMarker enabled={endpoint.enabled} />
</TableCell>
<TableCell>
<Flex justifyContent="flex-end">
<Localized
id="configure-webhooks-detailsButton"
icon={<Icon>keyboard_arrow_right</Icon>}
>
<Button variant="text" to={getEndpointLink(endpoint.id)} iconRight>
Details
<Icon>keyboard_arrow_right</Icon>
</Button>
</Localized>
</Flex>
</TableCell>
</TableRow>
);
const enhanced = withFragmentContainer<Props>({
endpoint: graphql`
fragment WebhookEndpointRow_webhookEndpoint on WebhookEndpoint {
id
enabled
url
}
`,
})(WebhookEndpointRow);
export default enhanced;
@@ -0,0 +1,115 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { urls } from "coral-framework/helpers";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
CallOut,
FormFieldDescription,
HorizontalGutter,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "coral-ui/components/v2";
import { WebhookEndpointsConfigContainer_settings } from "coral-admin/__generated__/WebhookEndpointsConfigContainer_settings.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import Subheader from "../../Subheader";
import WebhookEndpointRow from "./WebhookEndpointRow";
interface Props {
settings: WebhookEndpointsConfigContainer_settings;
}
const WebhookEndpointsConfigContainer: FunctionComponent<Props> = ({
settings,
}) => {
return (
<HorizontalGutter size="double" data-testid="webhooks-container">
<ConfigBox
title={
<Localized id="configure-webhooks-header-title">
<Header htmlFor="configure-webhooks-header.title">Webhooks</Header>
</Localized>
}
>
<Localized
id="configure-webhooks-description"
externalLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/webhooks/" />
}
>
<FormFieldDescription>
Configure an endpoint to send events to when events occur within
Coral. These events will be JSON encoded and signed. To learn more
about webhook signing, visit our{" "}
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/webhooks/">
our docs
</ExternalLink>
.
</FormFieldDescription>
</Localized>
<Button
to={urls.admin.addWebhookEndpoint}
iconLeft
data-testid="add-webhook-endpoint"
>
<Icon size="md">add</Icon>
<Localized id="configure-webhooks-addEndpointButton">
Add webhook endpoint
</Localized>
</Button>
<Localized id="configure-webhooks-endpoints">
<Subheader>Endpoints</Subheader>
</Localized>
{settings.webhooks.endpoints.length > 0 ? (
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="configure-webhooks-url">
<TableCell>URL</TableCell>
</Localized>
<Localized id="configure-webhooks-status">
<TableCell>Status</TableCell>
</Localized>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{settings.webhooks.endpoints.map((endpoint, idx) => (
<WebhookEndpointRow key={idx} endpoint={endpoint} />
))}
</TableBody>
</Table>
) : (
<Localized id="configure-webhooks-noEndpoints">
<CallOut color="regular" fullWidth>
There are no webhook endpoints configured, add one above.
</CallOut>
</Localized>
)}
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment WebhookEndpointsConfigContainer_settings on Settings {
webhooks {
endpoints {
...WebhookEndpointRow_webhookEndpoint
}
}
}
`,
})(WebhookEndpointsConfigContainer);
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 { WebhookEndpointsConfigRouteQueryResponse } from "coral-admin/__generated__/WebhookEndpointsConfigRouteQuery.graphql";
import WebhookEndpointsConfigContainer from "./WebhookEndpointsConfigContainer";
interface Props {
data: WebhookEndpointsConfigRouteQueryResponse | null;
}
const WebhookEndpointsConfigRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return <WebhookEndpointsConfigContainer settings={data.settings} />;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query WebhookEndpointsConfigRouteQuery {
settings {
...WebhookEndpointsConfigContainer_settings
}
}
`,
})(WebhookEndpointsConfigRoute);
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 WebhookEndpointsLayout: FunctionComponent<Props> = props => {
return (
<MainLayout>
<Layout>
<SideBar>
<ConfigureLinks />
</SideBar>
<Main>{props.children}</Main>
</Layout>
</MainLayout>
);
};
export default WebhookEndpointsLayout;
@@ -0,0 +1,8 @@
export {
default,
default as WebhookEndpointsConfigRoute,
} from "./WebhookEndpointsConfigRoute";
export { default as AddWebhookEndpointRoute } from "./AddWebhookEndpoint";
export {
default as ConfigureWebhookEndpointRoute,
} from "./ConfigureWebhookEndpoint";
@@ -6,3 +6,8 @@ export { ModerationConfigRoute } from "./Moderation";
export { OrganizationConfigRoute } from "./Organization";
export { WordListConfigRoute } from "./WordList";
export { SlackConfigRoute } from "./Slack";
export {
WebhookEndpointsConfigRoute,
ConfigureWebhookEndpointRoute,
AddWebhookEndpointRoute,
} from "./WebhookEndpoints";
@@ -88,6 +88,15 @@ exports[`renders configure advanced 1`] = `
Slack
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/webhooks"
onClick={[Function]}
>
Webhooks
</a>
</li>
<li>
<a
className="Link-link Link-linkActive"
@@ -88,6 +88,15 @@ exports[`renders configure auth 1`] = `
Slack
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/webhooks"
onClick={[Function]}
>
Webhooks
</a>
</li>
<li>
<a
className="Link-link"
@@ -88,6 +88,15 @@ exports[`renders configure general 1`] = `
Slack
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/webhooks"
onClick={[Function]}
>
Webhooks
</a>
</li>
<li>
<a
className="Link-link"
@@ -88,6 +88,15 @@ exports[`renders configure moderation 1`] = `
Slack
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/webhooks"
onClick={[Function]}
>
Webhooks
</a>
</li>
<li>
<a
className="Link-link"
@@ -88,6 +88,15 @@ exports[`renders configure organization 1`] = `
Slack
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/webhooks"
onClick={[Function]}
>
Webhooks
</a>
</li>
<li>
<a
className="Link-link"
@@ -320,7 +329,7 @@ moderation questions.
Add a new site to your organization or edit an existing site's details.
</p>
<a
className="BaseButton-root Button-root Button-sizeLarge Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="regular"
href="/admin/configure/organization/sites/new"
@@ -0,0 +1,525 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`displays a list of webhook endpoints that have been configured 1`] = `
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
data-testid="webhooks-container"
>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<label
className="Header-root"
htmlFor="configure-webhooks-header.title"
>
Configure webhook endpoint
</label>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Configure an endpoint to send events to when events occur within
Coral. These events will be JSON encoded and signed. To learn more
about webhook signing, visit our
<a
className="ExternalLink-root"
href="https://docs.coralproject.net/coral/v5/integrating/webhooks/"
rel="noopener noreferrer"
target="_blank"
>
Webhook Guide
</a>
.
</p>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
data-color="regular"
data-testid="add-webhook-endpoint"
data-variant="regular"
href="/admin/configure/webhooks/add"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
add
</i>
Add webhook endpoint
</a>
<h3
className="Subheader-root"
>
Endpoints
</h3>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root TableCell-header"
>
URL
</th>
<th
className="TableCell-root TableCell-header"
>
Status
</th>
<th
className="TableCell-root TableCell-header"
/>
</tr>
</thead>
<tbody
className="TableBody-root"
>
<tr
className="TableRow-root TableRow-body"
data-testid="webhook-endpoint-webhook-endpoint-1"
>
<td
className="TableCell-root WebhookEndpointRow-urlColumn TableCell-body"
>
http://example.com/webhook-endpoint-1
</td>
<td
className="TableCell-root TableCell-body"
>
<span
className="Marker-root StatusMarker-success Marker-colorPending Marker-variantRegular"
>
Enabled
</span>
</td>
<td
className="TableCell-root TableCell-body"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconRight"
data-color="regular"
data-variant="text"
href="/admin/configure/webhooks/endpoint/webhook-endpoint-1"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Details
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
keyboard_arrow_right
</i>
</a>
</div>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
data-testid="webhook-endpoint-webhook-endpoint-2"
>
<td
className="TableCell-root WebhookEndpointRow-urlColumn TableCell-body"
>
http://example.com/webhook-endpoint-2
</td>
<td
className="TableCell-root TableCell-body"
>
<span
className="Marker-root StatusMarker-error Marker-colorPending Marker-variantRegular"
>
Disabled
</span>
</td>
<td
className="TableCell-root TableCell-body"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconRight"
data-color="regular"
data-variant="text"
href="/admin/configure/webhooks/endpoint/webhook-endpoint-2"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Details
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
keyboard_arrow_right
</i>
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
exports[`goes to add new webhook endpoint when clicking add 1`] = `
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
data-testid="webhooks-container"
>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<label
className="Header-root"
htmlFor="configure-webhooks-header.title"
>
Configure webhook endpoint
</label>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Configure an endpoint to send events to when events occur within
Coral. These events will be JSON encoded and signed. To learn more
about webhook signing, visit our
<a
className="ExternalLink-root"
href="https://docs.coralproject.net/coral/v5/integrating/webhooks/"
rel="noopener noreferrer"
target="_blank"
>
Webhook Guide
</a>
.
</p>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
data-color="regular"
data-testid="add-webhook-endpoint"
data-variant="regular"
href="/admin/configure/webhooks/add"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
add
</i>
Add webhook endpoint
</a>
<h3
className="Subheader-root"
>
Endpoints
</h3>
<div
className="CallOut-root CallOut-colorRegular CallOut-fullWidth"
>
<div
className="CallOut-inner"
>
There are no webhook endpoints configured, add one above.
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`goes to the webhook endpoint configuration page when selected 1`] = `
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
data-testid="webhooks-container"
>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<label
className="Header-root"
htmlFor="configure-webhooks-header.title"
>
Configure webhook endpoint
</label>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Configure an endpoint to send events to when events occur within
Coral. These events will be JSON encoded and signed. To learn more
about webhook signing, visit our
<a
className="ExternalLink-root"
href="https://docs.coralproject.net/coral/v5/integrating/webhooks/"
rel="noopener noreferrer"
target="_blank"
>
Webhook Guide
</a>
.
</p>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
data-color="regular"
data-testid="add-webhook-endpoint"
data-variant="regular"
href="/admin/configure/webhooks/add"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
add
</i>
Add webhook endpoint
</a>
<h3
className="Subheader-root"
>
Endpoints
</h3>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root TableCell-header"
>
URL
</th>
<th
className="TableCell-root TableCell-header"
>
Status
</th>
<th
className="TableCell-root TableCell-header"
/>
</tr>
</thead>
<tbody
className="TableBody-root"
>
<tr
className="TableRow-root TableRow-body"
data-testid="webhook-endpoint-webhook-endpoint-1"
>
<td
className="TableCell-root WebhookEndpointRow-urlColumn TableCell-body"
>
http://example.com/webhook-endpoint-1
</td>
<td
className="TableCell-root TableCell-body"
>
<span
className="Marker-root StatusMarker-success Marker-colorPending Marker-variantRegular"
>
Enabled
</span>
</td>
<td
className="TableCell-root TableCell-body"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconRight"
data-color="regular"
data-variant="text"
href="/admin/configure/webhooks/endpoint/webhook-endpoint-1"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Details
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
keyboard_arrow_right
</i>
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
exports[`renders webhooks 1`] = `
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
data-testid="webhooks-container"
>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<label
className="Header-root"
htmlFor="configure-webhooks-header.title"
>
Configure webhook endpoint
</label>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Configure an endpoint to send events to when events occur within
Coral. These events will be JSON encoded and signed. To learn more
about webhook signing, visit our
<a
className="ExternalLink-root"
href="https://docs.coralproject.net/coral/v5/integrating/webhooks/"
rel="noopener noreferrer"
target="_blank"
>
Webhook Guide
</a>
.
</p>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
data-color="regular"
data-testid="add-webhook-endpoint"
data-variant="regular"
href="/admin/configure/webhooks/add"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
className="Icon-root Icon-md"
>
add
</i>
Add webhook endpoint
</a>
<h3
className="Subheader-root"
>
Endpoints
</h3>
<div
className="CallOut-root CallOut-colorRegular CallOut-fullWidth"
>
<div
className="CallOut-inner"
>
There are no webhook endpoints configured, add one above.
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -88,6 +88,15 @@ exports[`renders configure wordList 1`] = `
Slack
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/webhooks"
onClick={[Function]}
>
Webhooks
</a>
</li>
<li>
<a
className="Link-link"
@@ -0,0 +1,182 @@
import { noop } from "lodash";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createResolversStub,
CreateTestRendererParams,
replaceHistoryLocation,
wait,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "../create";
import { settings, users } from "../fixtures";
beforeEach(async () => {
replaceHistoryLocation("http://localhost/admin/configure/webhooks");
});
const viewer = users.admins[0];
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () => viewer,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return await act(async () => {
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("webhooks-container")
);
return { testRenderer, container, context };
});
}
it("renders webhooks", async () => {
const { container } = await createTestRenderer();
await act(async () => {
await wait(() => {
expect(within(container).toJSON()).toMatchSnapshot();
});
});
});
it("goes to add new webhook endpoint when clicking add", async () => {
const {
container,
context: { transitionControl },
} = await createTestRenderer();
// Prevent router transitions.
transitionControl.allowTransition = false;
act(() => {
within(container)
.getByText(/Add webhook endpoint/)
.props.onClick({ button: 0, preventDefault: noop });
});
// Expect a routing request was made to the right url.
await act(async () => {
await wait(() => {
expect(transitionControl.history[0].pathname).toBe(
"/admin/configure/webhooks/add"
);
});
});
await act(async () => {
await wait(() => {
expect(within(container).toJSON()).toMatchSnapshot();
});
});
});
it("displays a list of webhook endpoints that have been configured", async () => {
const resolvers = createResolversStub<GQLResolver>({
Query: {
settings: () =>
pureMerge<typeof settings>(settings, {
webhooks: {
endpoints: [
{
id: "webhook-endpoint-1",
enabled: true,
url: "http://example.com/webhook-endpoint-1",
all: true,
events: [],
},
{
id: "webhook-endpoint-2",
enabled: false,
url: "http://example.com/webhook-endpoint-2",
all: true,
events: [],
},
],
},
}),
},
});
const { container } = await createTestRenderer({ resolvers });
await act(async () => {
await wait(() => {
expect(within(container).toJSON()).toMatchSnapshot();
});
});
});
it("goes to the webhook endpoint configuration page when selected", async () => {
const resolvers = createResolversStub<GQLResolver>({
Query: {
settings: () =>
pureMerge<typeof settings>(settings, {
webhooks: {
endpoints: [
{
id: "webhook-endpoint-1",
enabled: true,
url: "http://example.com/webhook-endpoint-1",
all: true,
events: [],
},
],
},
}),
},
});
const {
container,
context: { transitionControl },
} = await createTestRenderer({ resolvers });
// Prevent router transitions.
transitionControl.allowTransition = false;
act(() => {
const row = within(container).getByTestID(
"webhook-endpoint-webhook-endpoint-1"
);
within(row)
.getByText(/Details/, {
selector: "a",
})
.props.onClick({ button: 0, preventDefault: noop });
});
// Expect a routing request was made to the right url.
await act(async () => {
await wait(() => {
expect(transitionControl.history[0].pathname).toBe(
"/admin/configure/webhooks/endpoint/webhook-endpoint-1"
);
});
});
await act(async () => {
await wait(() => {
expect(within(container).toJSON()).toMatchSnapshot();
});
});
});
+5
View File
@@ -23,6 +23,7 @@ import {
GQLUSER_ROLE,
GQLUSER_STATUS,
GQLUsersConnection,
GQLWEBHOOK_EVENT_NAME,
} from "coral-framework/schema";
import { createFixture, createFixtures } from "coral-framework/testHelpers";
@@ -152,6 +153,10 @@ export const settings = createFixture<GQLSettings>({
},
},
},
webhooks: {
endpoints: [],
},
webhookEvents: [GQLWEBHOOK_EVENT_NAME.STORY_CREATED],
stories: {
scraping: {
enabled: true,
@@ -1,6 +1,10 @@
export default {
admin: {
moderate: "/admin/moderate",
configureWebhooks: "/admin/configure/webhooks",
webhooks: "/admin/configure/webhooks",
addWebhookEndpoint: "/admin/configure/webhooks/add",
configureWebhookEndpoint: "/admin/configure/webhooks/endpoint",
},
embed: {
stream: "/embed/stream",
+11 -5
View File
@@ -14,13 +14,13 @@ export const VALIDATION_REQUIRED = () => (
export const VALIDATION_TOO_SHORT = (minLength: number) => (
<Localized id="framework-validation-tooShort" $minLength={minLength}>
<span>{"Please enter at least {$minLength} characters."}</span>
<span>Please enter at least {minLength} characters.</span>
</Localized>
);
export const VALIDATION_TOO_LONG = (maxLength: number) => (
<Localized id="framework-validation-tooLong" $maxLength={maxLength}>
<span>{"Please enter at max {$maxLength} characters."}</span>
<span>Please enter at max {maxLength} characters.</span>
</Localized>
);
@@ -38,19 +38,19 @@ export const INVALID_CHARACTERS = () => (
export const USERNAME_TOO_SHORT = (minLength: number) => (
<Localized id="framework-validation-usernameTooShort" $minLength={minLength}>
<span>{"Usernames must contain at least {$minLength} characters."}</span>
<span>Usernames must contain at least {minLength} characters.</span>
</Localized>
);
export const USERNAME_TOO_LONG = (maxLength: number) => (
<Localized id="framework-validation-usernameTooLong" $maxLength={maxLength}>
<span>{"Usernames cannot be longer than {$maxLength} characters."}</span>
<span>Usernames cannot be longer than {maxLength} characters.</span>
</Localized>
);
export const PASSWORD_TOO_SHORT = (minLength: number) => (
<Localized id="framework-validation-passwordTooShort" $minLength={minLength}>
<span>{"Password must contain at least {$minLength} characters."}</span>
<span>Password must contain at least {minLength} characters.</span>
</Localized>
);
@@ -60,6 +60,12 @@ export const PASSWORDS_DO_NOT_MATCH = () => (
</Localized>
);
export const INVALID_WEBHOOK_ENDPOINT_EVENT_SELECTION = () => (
<Localized id="framework-validation-invalidWebhookEndpointEventSelection">
<span>Select at least one event to receive.</span>
</Localized>
);
export const USERNAMES_DO_NOT_MATCH = () => (
<Localized id="framework-validation-usernamesDoNotMatch">
<span>Usernames do not match. Try again.</span>
@@ -17,6 +17,7 @@ import {
INVALID_CHARACTERS,
INVALID_EMAIL,
INVALID_URL,
INVALID_WEBHOOK_ENDPOINT_EVENT_SELECTION,
NOT_A_WHOLE_NUMBER,
NOT_A_WHOLE_NUMBER_BETWEEN,
NOT_A_WHOLE_NUMBER_GREATER_THAN,
@@ -155,6 +156,15 @@ export const validateEqualPasswords = createValidator(
PASSWORDS_DO_NOT_MATCH()
);
/**
* validateWebhookEventSelection is a Validator that checks for a valid
* combination of event selections for webhook endpoints.
*/
export const validateWebhookEventSelection = createValidator(
(v, values) => values.all || (values.events && values.events.length > 0),
INVALID_WEBHOOK_ENDPOINT_EVENT_SELECTION()
);
/**
* validateEqualEmails is a Validator that checks for correct email confirmation.
*/
@@ -0,0 +1,5 @@
.root {
border: 1px solid var(--v2-colors-grey-300);
border-radius: var(--round-corners);
overflow-y: auto;
}
@@ -0,0 +1,20 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { Flex } from "coral-ui/components/v2";
import styles from "./ListGroup.css";
interface Props {
className?: string;
}
const ListGroup: FunctionComponent<Props> = ({ className, children }) => {
return (
<Flex direction="column" className={cn(styles.root, className)}>
{children}
</Flex>
);
};
export default ListGroup;
@@ -0,0 +1,8 @@
.root {
border-bottom: 1px solid var(--v2-colors-grey-200);
padding: var(--v2-spacing-2);
&:last-child {
border-bottom: none;
}
}
@@ -0,0 +1,14 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import styles from "./ListGroupRow.css";
interface Props {
className?: string;
}
const ListGroupRow: FunctionComponent<Props> = ({ className, children }) => {
return <div className={cn(styles.root, className)}>{children}</div>;
};
export default ListGroupRow;
@@ -0,0 +1,2 @@
export { default as ListGroup } from "./ListGroup";
export { default as ListGroupRow } from "./ListGroupRow";
@@ -36,6 +36,7 @@ export { default as HelperText } from "./HelperText";
export { default as HorizontalGutter } from "./HorizontalGutter";
export { default as Icon } from "./Icon";
export { default as Label } from "./Label";
export { ListGroup, ListGroupRow } from "./ListGroup";
export { Marker, Count as MarkerCount } from "./Marker";
export { default as Message, MessageIcon } from "./Message";
export { default as Modal, ModalProps } from "./Modal";