mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 17:50:42 +08:00
[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:
@@ -65,11 +65,10 @@ jobs:
|
||||
name: Lint Source Code
|
||||
command: npm run lint
|
||||
- run:
|
||||
name: Lint README.md
|
||||
name: Lint Markdown
|
||||
command: |
|
||||
cp README.md README.md.orig
|
||||
npm run doctoc
|
||||
diff -q README.md README.md.orig
|
||||
git diff --exit-code
|
||||
|
||||
# unit_tests will run the unit tests.
|
||||
unit_tests:
|
||||
|
||||
@@ -1,31 +1,52 @@
|
||||
# Client Events Guide
|
||||
|
||||
This serves as a guide to events emitted by the javascript via the embed events
|
||||
hook, as described below in [Viewer Events](#viewer-events).
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [Viewer Events](#viewer-events)
|
||||
- [Viewer Network Events](#viewer-network-events)
|
||||
- [Event List](#event-list)
|
||||
- [Index](#index)
|
||||
- [Events](#events)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## Viewer Events
|
||||
|
||||
_Viewer Events_ are emitted when the viewer performs certain actions.
|
||||
They can be subscribed to using the `events` parameter in
|
||||
`Coral.createStreamEmbed`.
|
||||
|
||||
```html
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
events: function(events) {
|
||||
events.onAny(function(eventName, data) {
|
||||
console.log(eventName, data);
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
events: function(events) {
|
||||
events.onAny(function(eventName, data) {
|
||||
console.log(eventName, data);
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Example events:
|
||||
|
||||
- `setMainTab {tab: "PROFILE"}`
|
||||
- `showFeaturedCommentTooltip`
|
||||
- `viewConversation {from: "FEATURED_COMMENTS", commentID: "c45fb5f5-03f9-49a3-a755-488c698ca0df"}`
|
||||
|
||||
### Viewer Network Events
|
||||
|
||||
_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name.
|
||||
_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name.
|
||||
|
||||
Moreover _Viewer Network Events_ contains the `rtt` field which indicates the time it needed from initiating the request until the _UI_ has been updated with the response data.
|
||||
|
||||
Example events:
|
||||
|
||||
```
|
||||
createComment.success
|
||||
{
|
||||
@@ -53,6 +74,7 @@ createComment.error
|
||||
```
|
||||
|
||||
## Event List
|
||||
|
||||
<!-- START docs:events -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN npm run docs:events -->
|
||||
### Index
|
||||
@@ -8,6 +8,18 @@ do so, please [let us know how we can improve it](https://github.com/coralprojec
|
||||
By contributing to this project you agree to the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [What should I Contribute?](#what-should-i-contribute)
|
||||
- [Writing Code](#writing-code)
|
||||
- [When should I create an issue?](#when-should-i-create-an-issue)
|
||||
- [What should I include?](#what-should-i-include)
|
||||
- [Localization](#localization)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## What should I Contribute?
|
||||
|
||||
There are at least three ways to contribute to Coral:
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# Webhooks Guide
|
||||
|
||||
This document is in reference to webhooks emitted by Coral. You can configure
|
||||
webhooks on your installation of Coral by visiting `/admin/configure/webhooks`.
|
||||
|
||||
Once you've configured a webhook endpoint in Coral, you will receive updates
|
||||
from Coral when those events occur. These will be in the form of `POST` requests
|
||||
with a `JSON` payload consisting of the schema represented below.
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [Webhook Signing](#webhook-signing)
|
||||
- [How to verify the signature(s)](#how-to-verify-the-signatures)
|
||||
- [Schema](#schema)
|
||||
- [Events Listing](#events-listing)
|
||||
- [Events](#events)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## Webhook Signing
|
||||
|
||||
Each webhook sent by Coral is signed by your webhook endpoint signing secret.
|
||||
The signature method closely resembles the signing method used by Stripe for
|
||||
their `v1` signing method. The `X-Coral-Signature` header contains one or more
|
||||
signatures prefixed by `sha256=`.
|
||||
|
||||
If you receive a signature containing multiple signatures, it is typically when
|
||||
you have rolled the signing secret from the administrative panel, and chosen to
|
||||
keep the previous secret active for a duration of time.
|
||||
|
||||
### How to verify the signature(s)
|
||||
|
||||
```js
|
||||
// Set your signing secret here from the administration panel.
|
||||
const SIGNING_SECRET = "< YOUR SIGNING SECRET HERE >";
|
||||
|
||||
// We're using crypto to verify the signatures.
|
||||
const crypto = require("crypto");
|
||||
|
||||
// We're using express to receive webhooks here.
|
||||
const app = require("express")();
|
||||
|
||||
// Use the body-parser to get the raw body as a buffer so we can use it with the
|
||||
// hashing functions.
|
||||
const parser = require("body-parser");
|
||||
|
||||
function extractEvent(body, sig) {
|
||||
// Step 1: Extract signatures from the header.
|
||||
const signatures = sig
|
||||
// Split the header by `,` to get a list of elements.
|
||||
.split(",")
|
||||
// Split each element by `=` to get a prefix and value pair.
|
||||
.map(element => element.split("="))
|
||||
// Grab all the elements with the prefix of `sha256`.
|
||||
.filter(([prefix]) => prefix === "sha256")
|
||||
// Grab the value from the prefix and value pair.
|
||||
.map(([, value]) => value);
|
||||
|
||||
// Step 2: Prepare the `signed_payload`.
|
||||
const signed_payload = body;
|
||||
|
||||
// Step 3: Calculate the expected signature.
|
||||
const expected = crypto
|
||||
.createHmac("sha256", SIGNING_SECRET)
|
||||
.update(signed_payload)
|
||||
.digest()
|
||||
.toString("hex");
|
||||
|
||||
// Step 4: Compare signatures.
|
||||
if (
|
||||
// For each of the signatures on the request...
|
||||
!signatures.some(signature =>
|
||||
// Compare the expected signature to the signature on in the header. If at
|
||||
// least one of the match, we should continue to process the event.
|
||||
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
// Parse the JSON for the event.
|
||||
return JSON.parse(body.toString());
|
||||
}
|
||||
|
||||
app.post("/webhook", parser.raw({ type: "application/json" }), (req, res) => {
|
||||
const sig = req.headers["x-coral-signature"];
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// Parse the JSON for the event.
|
||||
event = extractEvent(req.body, sig);
|
||||
} catch (err) {
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
// Handle the event.
|
||||
switch (event.type) {
|
||||
case "STORY_CREATED":
|
||||
const data = event.data;
|
||||
console.log(
|
||||
`A Story with ID ${data.storyID} and URL ${data.storyURL} was created!`
|
||||
);
|
||||
break;
|
||||
// ... handle other event types.
|
||||
default:
|
||||
// Unexpected event type
|
||||
return response.status(400).end();
|
||||
}
|
||||
|
||||
// Return a response to acknowledge receipt of the event
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
app.listen(4242, () => console.log("Running on port 4242"));
|
||||
```
|
||||
|
||||
The procedure of how to verify the signatures follows.
|
||||
|
||||
#### **Step 1**: Extract signatures from the header
|
||||
|
||||
Split the header using `,` as the separator, to get a list of elements. Then
|
||||
split each of these elements using `=` as the separator, to get a prefix and
|
||||
value pair. The value for the prefix `sha256` corresponds to the signature(s).
|
||||
|
||||
#### **Step 2**: Prepare the `signed_payload` string
|
||||
|
||||
You can do this by taking the string contents of the body (before parsing or the
|
||||
request body).
|
||||
|
||||
#### **Step 3**: Calculate the expected signature
|
||||
|
||||
Compute an HMAC signature using the SHA256 hash function. You can use the
|
||||
webhook endpoint's signing secret as the key, and the above calculated
|
||||
`signed_payload` as the message.
|
||||
|
||||
#### **Step 4**: Compare signatures
|
||||
|
||||
Compare the signature(s) in the header to the expected signature. To protect
|
||||
against timing attacks, ensure you use a constant-time string comparison
|
||||
function when comparing signatures.
|
||||
|
||||
## Schema
|
||||
|
||||
```ts
|
||||
{
|
||||
/**
|
||||
* id is the identifier for this event, each event
|
||||
* will have a unique id.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* type is the name of this event, this indicates
|
||||
* what is stored in the following `data` property.
|
||||
* Refer to the `Events List` below to see what the
|
||||
* type is for each event.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* data is the object representing this particular
|
||||
* event. Each type of event has a different shape
|
||||
* to the data property. Refer to the `Events List`
|
||||
* below to see what the data looks like for each
|
||||
* event.
|
||||
*/
|
||||
data: object;
|
||||
|
||||
/**
|
||||
* createdAt is the ISO 8601 representation of the
|
||||
* date when this event was created.
|
||||
*/
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Events Listing
|
||||
|
||||
- [`STORY_CREATED`](#story-created-event)
|
||||
|
||||
## Events
|
||||
|
||||
- <a id="story-created-event">**STORY_CREATED**</a>
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string;
|
||||
type: "STORY_CREATED";
|
||||
data: {
|
||||
/**
|
||||
* storyID is the ID of the newly created Story.
|
||||
*/
|
||||
storyID: string;
|
||||
|
||||
/**
|
||||
* storyURL is the URL of the newly created Story.
|
||||
*/
|
||||
storyURL: string;
|
||||
}
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
+7
-3
@@ -25,8 +25,8 @@
|
||||
"build:client": "ts-node --transpile-only ./scripts/build.ts",
|
||||
"build:server": "gulp server",
|
||||
"migration:create": "ts-node --transpile-only ./scripts/migration/create.ts",
|
||||
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
|
||||
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./events.md",
|
||||
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./CLIENT_EVENTS.md",
|
||||
"doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.md",
|
||||
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
|
||||
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
|
||||
"generate:css-types": "tcm src/core/client/",
|
||||
@@ -400,8 +400,12 @@
|
||||
"src/core/server/graph/schema/schema.graphql": [
|
||||
"graphql-schema-linter"
|
||||
],
|
||||
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,events.md}": [
|
||||
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,CLIENT_EVENTS.md}": [
|
||||
"npm run docs:events -- --verify"
|
||||
],
|
||||
"{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS}.md": [
|
||||
"npm run doctoc",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"bundlesize": [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
|
||||
import { codeBlock, stripIndent } from "common-tags";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
|
||||
interface DocEntry {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { urls } from "coral-framework/helpers";
|
||||
|
||||
export default function getEndpointLink(endpointID: string) {
|
||||
return `${urls.admin.configureWebhookEndpoint}/${endpointID}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+58
@@ -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;
|
||||
+38
@@ -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;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as AddWebhookEndpointRoute,
|
||||
} from "./AddWebhookEndpointRoute";
|
||||
+62
@@ -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;
|
||||
+62
@@ -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;
|
||||
+38
@@ -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;
|
||||
+38
@@ -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;
|
||||
+38
@@ -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;
|
||||
+181
@@ -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;
|
||||
+42
@@ -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;
|
||||
+85
@@ -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;
|
||||
+10
@@ -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);
|
||||
}
|
||||
+165
@@ -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;
|
||||
+38
@@ -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;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as ConfigureWebhookEndpointRoute,
|
||||
} from "./ConfigureWebhookEndpointRoute";
|
||||
+162
@@ -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;
|
||||
+41
@@ -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;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.list {
|
||||
max-height: 295px;
|
||||
}
|
||||
|
||||
.event {
|
||||
font-family: monospace;
|
||||
}
|
||||
+152
@@ -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;
|
||||
+38
@@ -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;
|
||||
+4
@@ -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;
|
||||
+10
@@ -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);
|
||||
}
|
||||
+56
@@ -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;
|
||||
+115
@@ -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;
|
||||
+37
@@ -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;
|
||||
+27
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -17,7 +17,7 @@ export type GraphMiddlewareOptions = Pick<
|
||||
| "pubsub"
|
||||
| "tenantCache"
|
||||
| "metrics"
|
||||
| "notifierQueue"
|
||||
| "broker"
|
||||
>;
|
||||
|
||||
export const graphQLHandler = ({
|
||||
|
||||
@@ -15,9 +15,9 @@ import { HTMLErrorHandler } from "coral-server/app/middleware/error";
|
||||
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
|
||||
import { createPassport } from "coral-server/app/middleware/passport";
|
||||
import { Config } from "coral-server/config";
|
||||
import CoralEventListenerBroker from "coral-server/events/publisher";
|
||||
import logger from "coral-server/logger";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
@@ -41,7 +41,6 @@ export interface AppOptions {
|
||||
mailerQueue: MailerQueue;
|
||||
metrics?: Metrics;
|
||||
mongo: Db;
|
||||
notifierQueue: NotifierQueue;
|
||||
parent: Express;
|
||||
persistedQueriesRequired: boolean;
|
||||
persistedQueryCache: PersistedQueryCache;
|
||||
@@ -52,6 +51,7 @@ export interface AppOptions {
|
||||
signingConfig: JWTSigningConfig;
|
||||
tenantCache: TenantCache;
|
||||
migrationManager: MigrationManager;
|
||||
broker: CoralEventListenerBroker;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fetch from "node-fetch";
|
||||
import { URL } from "url";
|
||||
|
||||
import { ensureNoEndSlash } from "coral-common/utils";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
|
||||
/**
|
||||
* Configuration that Coral is expecting.
|
||||
@@ -25,6 +25,12 @@ interface DiscoveryRawConfiguration {
|
||||
jwks_uri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch provides a single source for managing the fetching operations for
|
||||
* discovery.
|
||||
*/
|
||||
const fetch = createFetch({ name: "OIDC" });
|
||||
|
||||
/**
|
||||
* discover will discover the configuration for the issuer.
|
||||
*
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Db } from "mongodb";
|
||||
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
|
||||
import { SSOAuthIntegration, SSOKey } from "coral-server/models/settings";
|
||||
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
retrieveUserWithProfile,
|
||||
@@ -169,7 +169,7 @@ export function getRelevantSSOKeys(
|
||||
tokenString: string,
|
||||
now: Date,
|
||||
kid?: string
|
||||
): SSOKey[] {
|
||||
): Secret[] {
|
||||
// Collect all the current valid keys.
|
||||
const keys = integration.keys.filter(k => {
|
||||
if (k.inactiveAt && now >= k.inactiveAt) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# events
|
||||
|
||||
This is the events package for Coral.
|
||||
|
||||
## Adding new events
|
||||
|
||||
You can add new events by adding to the `events.ts` file. Each event must export
|
||||
a `{ eventName }Payload` type and a `{ eventName }` Coral Event.
|
||||
|
||||
## Adding new event listeners
|
||||
|
||||
You can add a new event listener by adding to the `listeners/` folder. These
|
||||
events must implement the `CoralEventListener` abstract class. You can then
|
||||
register this listener in the `src/core/server/index.ts` file by registering
|
||||
it on the broker.
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
import { CoralEventPublisherBroker } from "./publisher";
|
||||
import { CoralEventType } from "./types";
|
||||
|
||||
export interface CoralEventPayload<
|
||||
T extends CoralEventType = CoralEventType,
|
||||
U extends {} = {}
|
||||
> {
|
||||
/**
|
||||
* id is the identifier for this specific event. Every copy of this unique
|
||||
* event will share the same identifier.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* type identifies this particular event.
|
||||
*/
|
||||
readonly type: T;
|
||||
|
||||
/**
|
||||
* data stores the underlying content of the event.
|
||||
*/
|
||||
readonly data: Readonly<U>;
|
||||
|
||||
/**
|
||||
* createdAt is the date that this event was published at.
|
||||
*/
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export function createCoralEvent<T extends CoralEventPayload>(type: T["type"]) {
|
||||
return {
|
||||
publish: async (broker: CoralEventPublisherBroker, data: T["data"]) => {
|
||||
const event: CoralEventPayload = {
|
||||
id: uuid(),
|
||||
createdAt: new Date(),
|
||||
data,
|
||||
type,
|
||||
};
|
||||
|
||||
logger.trace(
|
||||
{ eventType: event.type, eventID: event.id },
|
||||
"publishing event"
|
||||
);
|
||||
await broker.emit(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
CommentCreatedInput,
|
||||
CommentEnteredModerationQueueInput,
|
||||
CommentFeaturedInput,
|
||||
CommentLeftModerationQueueInput,
|
||||
CommentReleasedInput,
|
||||
CommentReplyCreatedInput,
|
||||
CommentStatusUpdatedInput,
|
||||
} from "coral-server/graph/resolvers/Subscription";
|
||||
|
||||
import { CoralEventPayload, createCoralEvent } from "./event";
|
||||
import { CoralEventType } from "./types";
|
||||
|
||||
export type CommentEnteredModerationQueueCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
CommentEnteredModerationQueueInput
|
||||
>;
|
||||
|
||||
export const CommentEnteredModerationQueueCoralEvent = createCoralEvent<
|
||||
CommentEnteredModerationQueueCoralEventPayload
|
||||
>(CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE);
|
||||
|
||||
export type CommentLeftModerationQueueCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
CommentLeftModerationQueueInput
|
||||
>;
|
||||
|
||||
export const CommentLeftModerationQueueCoralEvent = createCoralEvent<
|
||||
CommentLeftModerationQueueCoralEventPayload
|
||||
>(CoralEventType.COMMENT_LEFT_MODERATION_QUEUE);
|
||||
|
||||
export type CommentStatusUpdatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_STATUS_UPDATED,
|
||||
CommentStatusUpdatedInput
|
||||
>;
|
||||
|
||||
export const CommentStatusUpdatedCoralEvent = createCoralEvent<
|
||||
CommentStatusUpdatedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_STATUS_UPDATED);
|
||||
|
||||
export type CommentReplyCreatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
CommentReplyCreatedInput
|
||||
>;
|
||||
|
||||
export const CommentReplyCreatedCoralEvent = createCoralEvent<
|
||||
CommentReplyCreatedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_REPLY_CREATED);
|
||||
|
||||
export type CommentCreatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_CREATED,
|
||||
CommentCreatedInput
|
||||
>;
|
||||
|
||||
export const CommentCreatedCoralEvent = createCoralEvent<
|
||||
CommentCreatedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_CREATED);
|
||||
|
||||
export type CommentFeaturedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_FEATURED,
|
||||
CommentFeaturedInput
|
||||
>;
|
||||
|
||||
export const CommentFeaturedCoralEvent = createCoralEvent<
|
||||
CommentFeaturedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_FEATURED);
|
||||
|
||||
export type CommentReleasedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.COMMENT_RELEASED,
|
||||
CommentReleasedInput
|
||||
>;
|
||||
|
||||
export const CommentReleasedCoralEvent = createCoralEvent<
|
||||
CommentReleasedCoralEventPayload
|
||||
>(CoralEventType.COMMENT_RELEASED);
|
||||
|
||||
export type StoryCreatedCoralEventPayload = CoralEventPayload<
|
||||
CoralEventType.STORY_CREATED,
|
||||
{
|
||||
storyID: string;
|
||||
storyURL: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export const StoryCreatedCoralEvent = createCoralEvent<
|
||||
StoryCreatedCoralEventPayload
|
||||
>(CoralEventType.STORY_CREATED);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./events";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { categories } from "coral-server/services/notifications/categories";
|
||||
|
||||
import {
|
||||
CommentFeaturedCoralEventPayload,
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
export type NotifierCoralEventListenerPayloads =
|
||||
| CommentFeaturedCoralEventPayload
|
||||
| CommentStatusUpdatedCoralEventPayload
|
||||
| CommentReplyCreatedCoralEventPayload;
|
||||
|
||||
export class NotifierCoralEventListener
|
||||
implements CoralEventListener<NotifierCoralEventListenerPayloads> {
|
||||
public readonly name = "notifier";
|
||||
|
||||
private readonly queue: NotifierQueue;
|
||||
|
||||
constructor(queue: NotifierQueue) {
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* events are the events that this listener handles. These are parsed from the
|
||||
* notification categories.
|
||||
*/
|
||||
public readonly events = categories.reduce(
|
||||
(events, category) => {
|
||||
for (const event of category.events) {
|
||||
if (!events.includes(event)) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
[] as CoralEventType[]
|
||||
);
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
NotifierCoralEventListenerPayloads
|
||||
> = ({ tenant: { id } }) => async input => {
|
||||
await this.queue.add({ tenantID: id, input });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import logger from "coral-server/logger";
|
||||
import { getLatestRevision } from "coral-server/models/comment";
|
||||
import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story";
|
||||
import { createFetch } from "coral-server/services/fetch";
|
||||
|
||||
import { GQLMODERATION_QUEUE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
CommentEnteredModerationQueueCoralEventPayload,
|
||||
CommentFeaturedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
type SlackCoralEventListenerPayloads =
|
||||
| CommentFeaturedCoralEventPayload
|
||||
| CommentEnteredModerationQueueCoralEventPayload;
|
||||
|
||||
type Trigger = "reported" | "pending" | "featured";
|
||||
|
||||
export class SlackCoralEventListener
|
||||
implements CoralEventListener<SlackCoralEventListenerPayloads> {
|
||||
public readonly name = "slack";
|
||||
public readonly events = [
|
||||
CoralEventType.COMMENT_FEATURED,
|
||||
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
];
|
||||
private readonly fetch = createFetch({ name: "slack" });
|
||||
|
||||
private payloadTrigger(
|
||||
payload: SlackCoralEventListenerPayloads
|
||||
): Trigger | null {
|
||||
switch (payload.type) {
|
||||
case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE:
|
||||
if (payload.data.queue === GQLMODERATION_QUEUE.REPORTED) {
|
||||
return "reported";
|
||||
} else if (payload.data.queue === GQLMODERATION_QUEUE.PENDING) {
|
||||
return "pending";
|
||||
}
|
||||
break;
|
||||
case CoralEventType.COMMENT_FEATURED:
|
||||
return "featured";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* postMessage will prepare and send the incoming Slack webhook.
|
||||
*
|
||||
* @param ctx context of the request
|
||||
* @param message the message prefix for the request
|
||||
* @param payload payload for the event that occurred
|
||||
* @param hookURL url to the Slack webhook that we should send the message to
|
||||
*/
|
||||
private async postMessage(
|
||||
{ loaders, config, tenant, req }: GraphContext,
|
||||
message: string,
|
||||
payload: SlackCoralEventListenerPayloads,
|
||||
hookURL: string
|
||||
) {
|
||||
// Get the comment.
|
||||
const comment = await loaders.Comments.comment.load(payload.data.commentID);
|
||||
if (!comment || !comment.authorID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the story.
|
||||
const story = await loaders.Stories.story.load(payload.data.storyID);
|
||||
if (!story) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the author.
|
||||
const author = await loaders.Users.user.load(comment.authorID);
|
||||
if (!author) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some properties about the event.
|
||||
const storyTitle = getStoryTitle(story);
|
||||
const moderateLink = reconstructTenantURL(
|
||||
config,
|
||||
tenant,
|
||||
req,
|
||||
`/admin/moderate/comment/${comment.id}`
|
||||
);
|
||||
const commentLink = getURLWithCommentID(story.url, comment.id);
|
||||
|
||||
// Replace HTML link breaks with newlines.
|
||||
const body = striptags(getLatestRevision(comment).body);
|
||||
|
||||
// Send the post to the Slack URL. We don't wrap this in a try/catch because
|
||||
// it's handled in the calling function.
|
||||
const res = await this.fetch(hookURL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `${message} on *<${story.url}|${storyTitle}>*`,
|
||||
},
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: body,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `Authored by *${author.username}* | <${moderateLink}|Go to Moderation> | <${commentLink}|See Comment>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Check that the request was completed successfully.
|
||||
if (!res.ok) {
|
||||
throw new Error(`slack returned non-200 status code: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getMessage(trigger: Trigger): string {
|
||||
switch (trigger) {
|
||||
case "featured":
|
||||
return "This comment has been featured";
|
||||
case "pending":
|
||||
return "This comment is pending";
|
||||
case "reported":
|
||||
return "This comment has been reported";
|
||||
default:
|
||||
throw new Error("invalid trigger");
|
||||
}
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
SlackCoralEventListenerPayloads
|
||||
> = ctx => async payload => {
|
||||
const {
|
||||
tenant: { id: tenantID, slack },
|
||||
} = ctx;
|
||||
|
||||
if (
|
||||
// If slack is not defined,
|
||||
!slack ||
|
||||
// Or there are no slack channels,
|
||||
slack.channels.length === 0 ||
|
||||
// Or each channel isn't enabled or configured right.
|
||||
slack.channels.every(c => !c.enabled || !c.hookURL)
|
||||
) {
|
||||
// Exit out then.
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the trigger that is associated with this payload.
|
||||
const trigger = this.payloadTrigger(payload);
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For each channel that is enabled with configuration.
|
||||
for (const channel of slack.channels) {
|
||||
if (!channel.enabled || !channel.hookURL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
// If featured comments are, and it's a featured comment,
|
||||
(channel.triggers.featuredComments && trigger === "featured") ||
|
||||
// Or reported comments are, and it's a reported comment,
|
||||
(channel.triggers.reportedComments && trigger === "reported") ||
|
||||
// Or pending comments are, and it's a pending comment,
|
||||
(channel.triggers.pendingComments && trigger === "pending")
|
||||
) {
|
||||
try {
|
||||
// Post the message to slack.
|
||||
await this.postMessage(
|
||||
ctx,
|
||||
this.getMessage(trigger),
|
||||
payload,
|
||||
channel.hookURL
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, tenantID, payload, channel },
|
||||
"could not post the comment to slack"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers";
|
||||
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/resolvers/Subscription/types";
|
||||
|
||||
import {
|
||||
CommentCreatedCoralEventPayload,
|
||||
CommentEnteredModerationQueueCoralEventPayload,
|
||||
CommentFeaturedCoralEventPayload,
|
||||
CommentLeftModerationQueueCoralEventPayload,
|
||||
CommentReleasedCoralEventPayload,
|
||||
CommentReplyCreatedCoralEventPayload,
|
||||
CommentStatusUpdatedCoralEventPayload,
|
||||
} from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
type SubscriptionCoralEventListenerPayloads =
|
||||
| CommentEnteredModerationQueueCoralEventPayload
|
||||
| CommentLeftModerationQueueCoralEventPayload
|
||||
| CommentStatusUpdatedCoralEventPayload
|
||||
| CommentReplyCreatedCoralEventPayload
|
||||
| CommentCreatedCoralEventPayload
|
||||
| CommentFeaturedCoralEventPayload
|
||||
| CommentReleasedCoralEventPayload;
|
||||
|
||||
export class SubscriptionCoralEventListener
|
||||
implements CoralEventListener<SubscriptionCoralEventListenerPayloads> {
|
||||
public readonly name = "subscription";
|
||||
public readonly events = [
|
||||
CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
CoralEventType.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
CoralEventType.COMMENT_STATUS_UPDATED,
|
||||
CoralEventType.COMMENT_REPLY_CREATED,
|
||||
CoralEventType.COMMENT_CREATED,
|
||||
CoralEventType.COMMENT_FEATURED,
|
||||
CoralEventType.COMMENT_RELEASED,
|
||||
];
|
||||
|
||||
private translate(
|
||||
type: SubscriptionCoralEventListenerPayloads["type"]
|
||||
): SUBSCRIPTION_CHANNELS {
|
||||
switch (type) {
|
||||
case CoralEventType.COMMENT_ENTERED_MODERATION_QUEUE:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE;
|
||||
case CoralEventType.COMMENT_LEFT_MODERATION_QUEUE:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE;
|
||||
case CoralEventType.COMMENT_STATUS_UPDATED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED;
|
||||
case CoralEventType.COMMENT_REPLY_CREATED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED;
|
||||
case CoralEventType.COMMENT_CREATED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_CREATED;
|
||||
case CoralEventType.COMMENT_FEATURED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_FEATURED;
|
||||
case CoralEventType.COMMENT_RELEASED:
|
||||
return SUBSCRIPTION_CHANNELS.COMMENT_RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
private trigger(
|
||||
tenantID: string,
|
||||
type: SubscriptionCoralEventListenerPayloads["type"]
|
||||
) {
|
||||
return createSubscriptionChannelName(tenantID, this.translate(type));
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
SubscriptionCoralEventListenerPayloads
|
||||
> = ({ clientID, pubsub, tenant: { id } }) => async ({ type, data }) => {
|
||||
await pubsub.publish(this.trigger(id, type), {
|
||||
...data,
|
||||
clientID,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import logger from "coral-server/logger";
|
||||
import { WebhookQueue } from "coral-server/queue/tasks/webhook";
|
||||
|
||||
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { StoryCreatedCoralEventPayload } from "../events";
|
||||
import { CoralEventListener, CoralEventPublisherFactory } from "../publisher";
|
||||
import { CoralEventType } from "../types";
|
||||
|
||||
export type WebhookCoralEventListenerPayloads = StoryCreatedCoralEventPayload;
|
||||
|
||||
export class WebhookCoralEventListener
|
||||
implements CoralEventListener<WebhookCoralEventListenerPayloads> {
|
||||
public readonly name = "webhook";
|
||||
public readonly events = [CoralEventType.STORY_CREATED];
|
||||
|
||||
private readonly queue: WebhookQueue;
|
||||
|
||||
constructor(queue: WebhookQueue) {
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
public initialize: CoralEventPublisherFactory<
|
||||
WebhookCoralEventListenerPayloads
|
||||
> = ({ id: contextID, tenant }) => async event => {
|
||||
const log = logger.child(
|
||||
{
|
||||
tenantID: tenant.id,
|
||||
contextID,
|
||||
eventType: event.type,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Based on the incoming event, determine which endpoints we should send.
|
||||
const endpoints = tenant.webhooks.endpoints.filter(endpoint => {
|
||||
// If the endpoint is disabled, don't include it.
|
||||
if (!endpoint.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If all notifications have been enabled for this endpoint, include it.
|
||||
if (endpoint.all) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If this event name is specifically listed, include it. We have to do
|
||||
// some nasty casting here to address the fact that the types don't
|
||||
// technically overlap.
|
||||
if (
|
||||
endpoint.events.includes(
|
||||
(event.type as unknown) as GQLWEBHOOK_EVENT_NAME
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Log some important details.
|
||||
if (endpoints.length === 0) {
|
||||
log.debug("no endpoints matched for event");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
{ endpoints: endpoints.length },
|
||||
"matched endpoints that will receive event"
|
||||
);
|
||||
|
||||
// For each of these endpoints that need a delivery of these notifications,
|
||||
// queue up the job that will send it.
|
||||
await Promise.all(
|
||||
endpoints.map(endpoint =>
|
||||
this.queue.add({
|
||||
tenantID: tenant.id,
|
||||
contextID,
|
||||
endpointID: endpoint.id,
|
||||
event,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
import { CoralEventPayload } from "./event";
|
||||
import { CoralEventType } from "./types";
|
||||
|
||||
export type CoralEventPublisher<T extends CoralEventPayload = any> = (
|
||||
payload: T
|
||||
) => Promise<void>;
|
||||
|
||||
export type CoralEventPublisherFactory<T extends CoralEventPayload = any> = (
|
||||
ctx: GraphContext
|
||||
) => CoralEventPublisher<T>;
|
||||
|
||||
export abstract class CoralEventListener<T extends CoralEventPayload = any> {
|
||||
/**
|
||||
* name is the name of the listener used for identification in logs.
|
||||
*/
|
||||
public abstract readonly name: string;
|
||||
|
||||
/**
|
||||
* events is the array of event types that this listener should listen for.
|
||||
*/
|
||||
public abstract readonly events: CoralEventType[];
|
||||
|
||||
/**
|
||||
* initialize is a function that when
|
||||
*/
|
||||
public abstract initialize: CoralEventPublisherFactory<T>;
|
||||
}
|
||||
|
||||
export class CoralEventPublisherBroker {
|
||||
private readonly ctx: GraphContext;
|
||||
private readonly events: Set<CoralEventType>;
|
||||
private readonly listeners: CoralEventListener[];
|
||||
private registry?: Map<CoralEventType, CoralEventPublisher[]>;
|
||||
|
||||
constructor(
|
||||
ctx: GraphContext,
|
||||
events: Set<CoralEventType>,
|
||||
listeners: CoralEventListener[]
|
||||
) {
|
||||
this.ctx = ctx;
|
||||
this.events = events;
|
||||
this.listeners = listeners;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
const registry = new Map<CoralEventType, CoralEventPublisher[]>();
|
||||
|
||||
// Iterate over the listeners to initialize them.
|
||||
for (const listener of this.listeners) {
|
||||
// Initialize this listener.
|
||||
const publisher = listener.initialize(this.ctx);
|
||||
|
||||
// Associate the publisher with each of the events.
|
||||
for (const event of listener.events) {
|
||||
// Get the current publishers associated with this event.
|
||||
const publishers = registry.get(event) || [];
|
||||
|
||||
// Add this publisher to the array.
|
||||
publishers.push(publisher);
|
||||
|
||||
// Update this item in the registry.
|
||||
registry.set(event, publishers);
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
public emit = (payload: CoralEventPayload) => {
|
||||
// Check to see if this event is even registered.
|
||||
if (!this.events.has(payload.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazily create the registry.
|
||||
if (!this.registry) {
|
||||
this.registry = this.initialize();
|
||||
}
|
||||
|
||||
// Get the current publishers for this event. We can assert that this is
|
||||
// found because the event was checked in the above events set. If the event
|
||||
// did not exist in the events set, then it does not have an associated
|
||||
// registry entry.
|
||||
const publishers = this.registry.get(payload.type)!;
|
||||
|
||||
// Begin resolving these publishers.
|
||||
return Promise.all(publishers.map(publisher => publisher(payload)));
|
||||
};
|
||||
}
|
||||
|
||||
export default class CoralEventListenerBroker {
|
||||
private readonly events = new Set<CoralEventType>();
|
||||
private readonly listeners: CoralEventListener[] = [];
|
||||
|
||||
public instance = (ctx: GraphContext) =>
|
||||
new CoralEventPublisherBroker(ctx, this.events, this.listeners);
|
||||
|
||||
public register(listener: CoralEventListener) {
|
||||
if (listener.events.length === 0) {
|
||||
logger.warn(
|
||||
{ listenerName: listener.name },
|
||||
"listener was registered without any events"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ listenerName: listener.name, listenerEvents: listener.events },
|
||||
"registering listener for events"
|
||||
);
|
||||
|
||||
// Add this listener to this listener set.
|
||||
this.listeners.push(listener);
|
||||
|
||||
// Add each event to the set of registered events.
|
||||
for (const event of listener.events) {
|
||||
this.events.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum CoralEventType {
|
||||
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
|
||||
COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE",
|
||||
COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED",
|
||||
COMMENT_REPLY_CREATED = "COMMENT_REPLY_CREATED",
|
||||
COMMENT_CREATED = "COMMENT_CREATED",
|
||||
COMMENT_FEATURED = "COMMENT_FEATURED",
|
||||
COMMENT_RELEASED = "COMMENT_RELEASED",
|
||||
STORY_CREATED = "STORY_CREATED",
|
||||
}
|
||||
@@ -4,21 +4,18 @@ import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
createPublisher,
|
||||
Publisher,
|
||||
} from "coral-server/graph/subscriptions/publisher";
|
||||
import CoralEventListenerBroker, {
|
||||
CoralEventPublisherBroker,
|
||||
} from "coral-server/events/publisher";
|
||||
import logger, { Logger } from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import createSlackPublisher from "coral-server/services/slack/publisher";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
@@ -41,16 +38,17 @@ export interface GraphContextOptions {
|
||||
i18n: I18n;
|
||||
mailerQueue: MailerQueue;
|
||||
mongo: Db;
|
||||
notifierQueue: NotifierQueue;
|
||||
pubsub: RedisPubSub;
|
||||
redis: AugmentedRedis;
|
||||
scraperQueue: ScraperQueue;
|
||||
tenant: Tenant;
|
||||
tenantCache: TenantCache;
|
||||
broker: CoralEventListenerBroker;
|
||||
}
|
||||
|
||||
export default class GraphContext {
|
||||
public readonly config: Config;
|
||||
public readonly broker: CoralEventPublisherBroker;
|
||||
public readonly disableCaching: boolean;
|
||||
public readonly i18n: I18n;
|
||||
public readonly id: string;
|
||||
@@ -61,7 +59,6 @@ export default class GraphContext {
|
||||
public readonly mongo: Db;
|
||||
public readonly mutators: ReturnType<typeof mutators>;
|
||||
public readonly now: Date;
|
||||
public readonly publisher: Publisher;
|
||||
public readonly pubsub: RedisPubSub;
|
||||
public readonly redis: AugmentedRedis;
|
||||
public readonly scraperQueue: ScraperQueue;
|
||||
@@ -100,18 +97,7 @@ export default class GraphContext {
|
||||
this.signingConfig = options.signingConfig;
|
||||
this.clientID = options.clientID;
|
||||
|
||||
this.publisher = createPublisher({
|
||||
pubsub: this.pubsub,
|
||||
slackPublisher: createSlackPublisher(
|
||||
this.mongo,
|
||||
this.config,
|
||||
this.tenant
|
||||
),
|
||||
notifierQueue: options.notifierQueue,
|
||||
tenantID: this.tenant.id,
|
||||
clientID: this.clientID,
|
||||
});
|
||||
|
||||
this.broker = options.broker.instance(this);
|
||||
this.loaders = loaders(this);
|
||||
this.mutators = mutators(this);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,14 @@ const primeStoriesFromConnection = (ctx: GraphContext) => (
|
||||
export default (ctx: GraphContext) => ({
|
||||
findOrCreate: new DataLoader(
|
||||
createManyBatchLoadFn((input: FindOrCreateStory) =>
|
||||
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue, ctx.now)
|
||||
findOrCreate(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.broker,
|
||||
input,
|
||||
ctx.scraperQueue,
|
||||
ctx.now
|
||||
)
|
||||
),
|
||||
{
|
||||
// TODO: (wyattjoh) see if there's something we can do to improve the cache key
|
||||
|
||||
@@ -12,7 +12,7 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
input.commentID,
|
||||
input.commentRevisionID,
|
||||
@@ -24,7 +24,7 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
input.commentID,
|
||||
input.commentRevisionID,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{ authorID: ctx.user!.id, ...comment },
|
||||
@@ -68,7 +68,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -92,7 +92,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
createReaction(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -102,7 +102,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.now
|
||||
),
|
||||
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
|
||||
removeReaction(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, ctx.user!, {
|
||||
removeReaction(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, {
|
||||
commentID,
|
||||
}),
|
||||
createDontAgree: ({
|
||||
@@ -113,7 +113,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
createDontAgree(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -128,14 +128,9 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.now
|
||||
),
|
||||
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
|
||||
removeDontAgree(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{ commentID }
|
||||
),
|
||||
removeDontAgree(ctx.mongo, ctx.redis, ctx.broker, ctx.tenant, ctx.user!, {
|
||||
commentID,
|
||||
}),
|
||||
createFlag: ({
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
@@ -145,7 +140,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
createFlag(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -179,7 +174,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.config,
|
||||
ctx.publisher,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
@@ -190,7 +185,7 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
)
|
||||
.then(comment => {
|
||||
// Publish that the comment was featured.
|
||||
publishCommentFeatured(ctx.publisher, comment);
|
||||
publishCommentFeatured(ctx.broker, comment);
|
||||
|
||||
// Return it to the next step.
|
||||
return comment;
|
||||
|
||||
@@ -2,19 +2,33 @@ import GraphContext from "coral-server/graph/context";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
createAnnouncement,
|
||||
createWebhookEndpoint,
|
||||
deleteAnnouncement,
|
||||
deleteWebhookEndpoint,
|
||||
disableFeatureFlag,
|
||||
disableWebhookEndpoint,
|
||||
enableFeatureFlag,
|
||||
enableWebhookEndpoint,
|
||||
regenerateSSOKey,
|
||||
rotateWebhookEndpointSecret,
|
||||
update,
|
||||
updateWebhookEndpoint,
|
||||
} from "coral-server/services/tenant";
|
||||
|
||||
import {
|
||||
GQLCreateAnnouncementInput,
|
||||
GQLCreateWebhookEndpointInput,
|
||||
GQLDeleteWebhookEndpointInput,
|
||||
GQLDisableWebhookEndpointInput,
|
||||
GQLEnableWebhookEndpointInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLRotateWebhookEndpointSecretInput,
|
||||
GQLUpdateSettingsInput,
|
||||
GQLUpdateWebhookEndpointInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { WithoutMutationID } from "./util";
|
||||
|
||||
export const Settings = ({
|
||||
mongo,
|
||||
redis,
|
||||
@@ -23,7 +37,9 @@ export const Settings = ({
|
||||
config,
|
||||
now,
|
||||
}: GraphContext) => ({
|
||||
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
|
||||
update: (
|
||||
input: WithoutMutationID<GQLUpdateSettingsInput>
|
||||
): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
|
||||
@@ -35,4 +51,42 @@ export const Settings = ({
|
||||
createAnnouncement(mongo, redis, tenantCache, tenant, input, now),
|
||||
deleteAnnouncement: () =>
|
||||
deleteAnnouncement(mongo, redis, tenantCache, tenant),
|
||||
createWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLCreateWebhookEndpointInput>
|
||||
) =>
|
||||
createWebhookEndpoint(
|
||||
mongo,
|
||||
redis,
|
||||
config,
|
||||
tenantCache,
|
||||
tenant,
|
||||
input,
|
||||
now
|
||||
),
|
||||
enableWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLEnableWebhookEndpointInput>
|
||||
) => enableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
disableWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLDisableWebhookEndpointInput>
|
||||
) => disableWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
updateWebhookEndpoint: ({
|
||||
id,
|
||||
...input
|
||||
}: WithoutMutationID<GQLUpdateWebhookEndpointInput>) =>
|
||||
updateWebhookEndpoint(mongo, redis, config, tenantCache, tenant, id, input),
|
||||
deleteWebhookEndpoint: (
|
||||
input: WithoutMutationID<GQLDeleteWebhookEndpointInput>
|
||||
) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
|
||||
rotateWebhookEndpointSecret: (
|
||||
input: WithoutMutationID<GQLRotateWebhookEndpointSecretInput>
|
||||
) =>
|
||||
rotateWebhookEndpointSecret(
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
tenant,
|
||||
input.id,
|
||||
input.inactiveIn,
|
||||
now
|
||||
),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export const Stories = (ctx: GraphContext) => ({
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.broker,
|
||||
ctx.config,
|
||||
input.story.id,
|
||||
input.story.url,
|
||||
|
||||
@@ -86,7 +86,7 @@ export const storyModerationInputResolver = (
|
||||
*
|
||||
* @param source the source of the type, not used
|
||||
* @param args the args of the type, not used
|
||||
* @param ctx the TenantContext that will be used to get the shared counts
|
||||
* @param ctx the GraphContext that will be used to get the shared counts
|
||||
*/
|
||||
export const sharedModerationInputResolver = async (
|
||||
source: any,
|
||||
@@ -106,7 +106,7 @@ export const sharedModerationInputResolver = async (
|
||||
*
|
||||
* @param source the source of the payload, not used
|
||||
* @param args the args of the payload containing potentially a Story ID
|
||||
* @param ctx the TenantContext for which we can use to retrieve the shared data
|
||||
* @param ctx the GraphContext for which we can use to retrieve the shared data
|
||||
*/
|
||||
export const moderationQueuesResolver: QueryToModerationQueuesResolver = async (
|
||||
source,
|
||||
|
||||
@@ -33,9 +33,13 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.updateNotificationSettings(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
updateSettings: async (source, { input }, ctx) => ({
|
||||
updateSettings: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
settings: await ctx.mutators.Settings.update(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
clientMutationId,
|
||||
}),
|
||||
createCommentReaction: async (source, { input }, ctx) => ({
|
||||
comment: await ctx.mutators.Comments.createReaction(input),
|
||||
@@ -252,4 +256,52 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
site: await ctx.mutators.Sites.update(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
...(await ctx.mutators.Settings.createWebhookEndpoint(input)),
|
||||
clientMutationId,
|
||||
}),
|
||||
updateWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.updateWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
disableWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.disableWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
enableWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.enableWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
deleteWebhookEndpoint: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
rotateWebhookEndpointSecret: async (
|
||||
source,
|
||||
{ input: { clientMutationId, ...input } },
|
||||
ctx
|
||||
) => ({
|
||||
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getWebhookEndpoint } from "coral-server/models/tenant";
|
||||
|
||||
import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { moderationQueuesResolver } from "./ModerationQueues";
|
||||
@@ -25,4 +27,5 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
|
||||
ctx.loaders.Stories.activeStories(limit),
|
||||
sites: (source, args, ctx) => ctx.loaders.Sites.connection(args),
|
||||
site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null),
|
||||
webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
function getActiveSSOKey(keys: settings.SSOKey[]) {
|
||||
function getActiveSSOKey(keys: settings.Secret[]) {
|
||||
// Any key that has been rotated cannot be the active key.
|
||||
return keys.find(key => !key.rotatedAt);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSettingsTypeResolver,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
const filterValidFeatureFlags = () => {
|
||||
@@ -27,4 +28,5 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
|
||||
const sites = await ctx.loaders.Sites.connection({});
|
||||
return sites.edges.length > 1;
|
||||
},
|
||||
webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME),
|
||||
};
|
||||
|
||||
@@ -17,3 +17,13 @@ export const Subscription: GQLSubscriptionTypeResolver = {
|
||||
commentFeatured,
|
||||
commentReleased,
|
||||
};
|
||||
|
||||
export { CommentFeaturedInput } from "./commentFeatured";
|
||||
export { CommentCreatedInput } from "./commentCreated";
|
||||
export {
|
||||
CommentEnteredModerationQueueInput,
|
||||
} from "./commentEnteredModerationQueue";
|
||||
export { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
|
||||
export { CommentReleasedInput } from "./commentReleased";
|
||||
export { CommentReplyCreatedInput } from "./commentReplyCreated";
|
||||
export { CommentStatusUpdatedInput } from "./commentStatusUpdated";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as tenant from "coral-server/models/tenant";
|
||||
|
||||
import { GQLWebhookEndpointTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const WebhookEndpoint: GQLWebhookEndpointTypeResolver<
|
||||
tenant.Endpoint
|
||||
> = {
|
||||
signingSecret: ({ signingSecrets }) =>
|
||||
signingSecrets[signingSecrets.length - 1],
|
||||
};
|
||||
@@ -50,6 +50,7 @@ import { User } from "./User";
|
||||
import { UsernameHistory } from "./UsernameHistory";
|
||||
import { UsernameStatus } from "./UsernameStatus";
|
||||
import { UserStatus } from "./UserStatus";
|
||||
import { WebhookEndpoint } from "./WebhookEndpoint";
|
||||
|
||||
const Resolvers: GQLResolver = {
|
||||
ApproveCommentPayload,
|
||||
@@ -101,6 +102,7 @@ const Resolvers: GQLResolver = {
|
||||
UserStatus,
|
||||
Settings,
|
||||
SlackConfiguration,
|
||||
WebhookEndpoint,
|
||||
};
|
||||
|
||||
export default Resolvers;
|
||||
|
||||
@@ -1002,7 +1002,7 @@ type SlackChannel {
|
||||
triggers are the filters of types of comments that will be sent to
|
||||
the correlated channel
|
||||
"""
|
||||
triggers: SlackChannelTriggers
|
||||
triggers: SlackChannelTriggers!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -1204,6 +1204,79 @@ type StaffConfiguration {
|
||||
label: String!
|
||||
}
|
||||
|
||||
type WebhookDelivery {
|
||||
success: Boolean!
|
||||
status: Int!
|
||||
statusText: String!
|
||||
request: String!
|
||||
response: String!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
enum WEBHOOK_EVENT_NAME {
|
||||
STORY_CREATED
|
||||
}
|
||||
|
||||
"""
|
||||
TODO: merge with SSOKey with PR #2732
|
||||
"""
|
||||
type Secret {
|
||||
"""
|
||||
secret is the actual underlying secret used to verify the tokens with.
|
||||
"""
|
||||
secret: String!
|
||||
|
||||
"""
|
||||
createdAt is the date that the key was created at.
|
||||
"""
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
type WebhookEndpoint {
|
||||
"""
|
||||
id is the unique identifier for this specific endpoint.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
enabled when true will enable events to be sent to this endpoint.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
url is the URL that we will POST event data to.
|
||||
"""
|
||||
url: String!
|
||||
|
||||
"""
|
||||
signingSecret is the current secret used to sign the events sent out.
|
||||
"""
|
||||
signingSecret: Secret!
|
||||
|
||||
"""
|
||||
deliveries store the deliveries for each event sent for the last 50 events.
|
||||
"""
|
||||
deliveries: [WebhookDelivery!]!
|
||||
|
||||
"""
|
||||
all is true when all events are subscribed to.
|
||||
"""
|
||||
all: Boolean!
|
||||
|
||||
"""
|
||||
events are the specific event names that this endpoint is configured to send
|
||||
for.
|
||||
"""
|
||||
events: [WEBHOOK_EVENT_NAME!]!
|
||||
}
|
||||
|
||||
type WebhookConfiguration {
|
||||
"""
|
||||
endpoints is all the configured endpoints that should receive events.
|
||||
"""
|
||||
endpoints: [WebhookEndpoint!]!
|
||||
}
|
||||
|
||||
"""
|
||||
NewCommenterConfiguration specifies the features that apply to new commenters
|
||||
"""
|
||||
@@ -1266,6 +1339,16 @@ type Settings {
|
||||
"""
|
||||
domain: String! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
webhooks store the webhook configuration.
|
||||
"""
|
||||
webhooks: WebhookConfiguration! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
webhookEvents returns all the events that can trigger webhooks.
|
||||
"""
|
||||
webhookEvents: [WEBHOOK_EVENT_NAME!]! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
staticURI if configured, is the static URI used to serve static files from.
|
||||
"""
|
||||
@@ -2891,6 +2974,11 @@ type Query {
|
||||
activeStories(limit: Int = 10 @constraint(max: 25)): [Story!]!
|
||||
@auth(roles: [ADMIN])
|
||||
@rate(max: 2, seconds: 1)
|
||||
|
||||
"""
|
||||
webhookEndpint will return a specific WebhookEndpoint if it exists.
|
||||
"""
|
||||
webhookEndpoint(id: ID!): WebhookEndpoint @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -4768,6 +4856,212 @@ type DeleteModeratorNotePayload {
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
# createWebhookEndpoint
|
||||
##################
|
||||
|
||||
input CreateWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
url is the URL that Coral will POST event data to.
|
||||
"""
|
||||
url: String!
|
||||
|
||||
"""
|
||||
all is true when all events are subscribed to.
|
||||
"""
|
||||
all: Boolean!
|
||||
|
||||
"""
|
||||
events are the specific event names that this endpoint is configured to send
|
||||
for.
|
||||
"""
|
||||
events: [WEBHOOK_EVENT_NAME!]!
|
||||
}
|
||||
|
||||
type CreateWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just created.
|
||||
"""
|
||||
endpoint: WebhookEndpoint!
|
||||
|
||||
"""
|
||||
settings is the updated settings also containing the new endpoint.
|
||||
"""
|
||||
settings: Settings!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateWebhookEndpoint
|
||||
##################
|
||||
|
||||
input UpdateWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being updated.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
url is the URL that Coral will POST event data to.
|
||||
"""
|
||||
url: String
|
||||
|
||||
"""
|
||||
all is true when all events are subscribed to.
|
||||
"""
|
||||
all: Boolean
|
||||
|
||||
"""
|
||||
events are the specific event names that this endpoint is configured to send
|
||||
for.
|
||||
"""
|
||||
events: [WEBHOOK_EVENT_NAME!]
|
||||
}
|
||||
|
||||
type UpdateWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just created.
|
||||
"""
|
||||
endpoint: WebhookEndpoint!
|
||||
}
|
||||
|
||||
##################
|
||||
# rotateWebhookEndpointSecret
|
||||
##################
|
||||
|
||||
input RotateWebhookEndpointSecretInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being updated.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
inactiveIn is the number of seconds that the current active Secret should be
|
||||
kept active.
|
||||
"""
|
||||
inactiveIn: Int!
|
||||
}
|
||||
|
||||
type RotateWebhookEndpointSecretPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just updated.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# disableWebhookEndpoint
|
||||
##################
|
||||
|
||||
input DisableWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being disabled.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type DisableWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just disabled.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# enableWebhookEndpoint
|
||||
##################
|
||||
|
||||
input EnableWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being enabled.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type EnableWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just enabled.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# deleteWebhookEndpoint
|
||||
##################
|
||||
|
||||
input DeleteWebhookEndpointInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
id is the ID of the WebhookEndpoint being deleted.
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type DeleteWebhookEndpointPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
endpoint is the endpoint that we just deleted.
|
||||
"""
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
##################
|
||||
# setEmail
|
||||
##################
|
||||
@@ -5899,6 +6193,49 @@ type Mutation {
|
||||
createSite(input: CreateSiteInput!): CreateSitePayload! @auth(roles: [ADMIN])
|
||||
|
||||
updateSite(input: UpdateSiteInput!): UpdateSitePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
createWebhookEndpoint will create a new WebhookEndpoint.
|
||||
"""
|
||||
createWebhookEndpoint(
|
||||
input: CreateWebhookEndpointInput!
|
||||
): CreateWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
updateWebhookEndpoint will update a WebhookEndpoint.
|
||||
"""
|
||||
updateWebhookEndpoint(
|
||||
input: UpdateWebhookEndpointInput!
|
||||
): UpdateWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
enableWebhookEndpoint will enable a WebhookEndpoint to recieve new events.
|
||||
"""
|
||||
enableWebhookEndpoint(
|
||||
input: EnableWebhookEndpointInput!
|
||||
): EnableWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
disableWebhookEndpoint will disable a WebhookEndpoint from recieving new
|
||||
events.
|
||||
"""
|
||||
disableWebhookEndpoint(
|
||||
input: DisableWebhookEndpointInput!
|
||||
): DisableWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
deleteWebhookEndpoint will delete a WebhookEndpoint.
|
||||
"""
|
||||
deleteWebhookEndpoint(
|
||||
input: DeleteWebhookEndpointInput!
|
||||
): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
rotateWebhookEndpointSecret will roll the current active secret to a new key.
|
||||
"""
|
||||
rotateWebhookEndpointSecret(
|
||||
input: RotateWebhookEndpointSecretInput!
|
||||
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
|
||||
import { createSubscriptionChannelName } from "coral-server/graph/resolvers/Subscription/helpers";
|
||||
import { SUBSCRIPTION_INPUT } from "coral-server/graph/resolvers/Subscription/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { SlackPublisher } from "coral-server/services/slack/publisher";
|
||||
|
||||
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
|
||||
|
||||
export interface PublisherOptions {
|
||||
pubsub: RedisPubSub;
|
||||
slackPublisher: SlackPublisher;
|
||||
notifierQueue: NotifierQueue;
|
||||
tenantID: string;
|
||||
clientID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* createPublisher will create a new Publisher that can be used to send events
|
||||
* over the pubsub broker to facilitate live updates and notifications.
|
||||
*
|
||||
* TODO: Update
|
||||
*
|
||||
* @param options options object
|
||||
* @param options.pubsub the pubsub broker to be used to facilitate the publish action
|
||||
* @param options.slackPublisher the slack publisher instance
|
||||
* @param options.notifierQueue the queue
|
||||
* @param options.tenantID the ID of the Tenant where the event will be published with
|
||||
* @param options.clientID the ID of the client to de-duplicate mutation responses
|
||||
*/
|
||||
export const createPublisher = ({
|
||||
pubsub,
|
||||
slackPublisher,
|
||||
notifierQueue,
|
||||
tenantID,
|
||||
clientID,
|
||||
}: PublisherOptions): Publisher => async input => {
|
||||
const { channel, payload } = input;
|
||||
|
||||
logger.trace({ channel, tenantID, clientID }, "publishing event");
|
||||
|
||||
// Start the publishing operation out to all affected subscribers.
|
||||
await Promise.all([
|
||||
// Publish to the underlying pubsub system for subscriptions.
|
||||
pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
|
||||
...payload,
|
||||
clientID,
|
||||
}),
|
||||
|
||||
slackPublisher(channel, payload),
|
||||
|
||||
// Notify the notifications queue so we can offload notification processing
|
||||
// to it.
|
||||
notifierQueue.add({ tenantID, input }),
|
||||
]);
|
||||
};
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
} from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
|
||||
import { NotifierCoralEventListener } from "./events/listeners/notifier";
|
||||
import { SlackCoralEventListener } from "./events/listeners/slack";
|
||||
import { SubscriptionCoralEventListener } from "./events/listeners/subscription";
|
||||
import { WebhookCoralEventListener } from "./events/listeners/webhook";
|
||||
import CoralEventListenerBroker from "./events/publisher";
|
||||
import { isInstalled } from "./services/tenant";
|
||||
|
||||
export interface ServerOptions {
|
||||
@@ -108,6 +113,12 @@ class Server {
|
||||
// migrationManager is the manager for performing migrations on Coral.
|
||||
private migrationManager: MigrationManager;
|
||||
|
||||
/**
|
||||
* broker stores a reference to all of the listeners that can be used in
|
||||
* conjunction with an event to publish activity occurring inside Coral.
|
||||
*/
|
||||
private broker: CoralEventListenerBroker;
|
||||
|
||||
constructor(options: ServerOptions) {
|
||||
this.parentApp = express();
|
||||
|
||||
@@ -190,6 +201,7 @@ class Server {
|
||||
this.tasks = await createQueue({
|
||||
config: this.config,
|
||||
mongo: this.mongo,
|
||||
redis: this.redis,
|
||||
tenantCache: this.tenantCache,
|
||||
i18n: this.i18n,
|
||||
signingConfig: this.signingConfig,
|
||||
@@ -201,6 +213,13 @@ class Server {
|
||||
createRedisClient(this.config)
|
||||
);
|
||||
|
||||
// Setup the broker.
|
||||
this.broker = new CoralEventListenerBroker();
|
||||
this.broker.register(new NotifierCoralEventListener(this.tasks.notifier));
|
||||
this.broker.register(new SlackCoralEventListener());
|
||||
this.broker.register(new SubscriptionCoralEventListener());
|
||||
this.broker.register(new WebhookCoralEventListener(this.tasks.webhook));
|
||||
|
||||
// Setup the metrics collectors.
|
||||
collectDefaultMetrics({ timeout: 5000 });
|
||||
}
|
||||
@@ -233,6 +252,7 @@ class Server {
|
||||
this.tasks.mailer.process();
|
||||
this.tasks.scraper.process();
|
||||
this.tasks.notifier.process();
|
||||
this.tasks.webhook.process();
|
||||
|
||||
// Start up the cron job processors.
|
||||
this.scheduledTasks = startScheduledTasks({
|
||||
@@ -323,6 +343,7 @@ class Server {
|
||||
|
||||
const options: AppOptions = {
|
||||
parent,
|
||||
broker: this.broker,
|
||||
pubsub: this.pubsub,
|
||||
mongo: this.mongo,
|
||||
redis: this.redis,
|
||||
@@ -333,7 +354,6 @@ class Server {
|
||||
i18n: this.i18n,
|
||||
mailerQueue: this.tasks.mailer,
|
||||
scraperQueue: this.tasks.scraper,
|
||||
notifierQueue: this.tasks.notifier,
|
||||
disableClientRoutes,
|
||||
persistedQueryCache: this.persistedQueryCache,
|
||||
persistedQueriesRequired:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./settings";
|
||||
export * from "./secret";
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface Secret {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) {
|
||||
if (inactiveAt && inactiveAt <= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterExpiredSecrets(now = new Date()) {
|
||||
return (secret: Secret) => isSecretExpired(secret, now);
|
||||
}
|
||||
|
||||
export function filterActiveSecrets(now = new Date()) {
|
||||
return (secret: Secret) => !isSecretExpired(secret, now);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
GQLSettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { Secret } from "./secret";
|
||||
|
||||
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
|
||||
|
||||
export type EmailConfiguration = GQLEmailConfiguration;
|
||||
@@ -38,40 +40,11 @@ export type FacebookAuthIntegration = Omit<
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export interface SSOKey {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with.
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* createdAt is the date that the key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* rotatedAt is the time that the token was rotated out.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
|
||||
/**
|
||||
* inactiveAt is the date that the token can no longer be used to validate
|
||||
* tokens.
|
||||
*/
|
||||
inactiveAt?: Date;
|
||||
}
|
||||
|
||||
export interface SSOAuthIntegration {
|
||||
enabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
targetFilter: GQLAuthenticationTargetFilter;
|
||||
keys: SSOKey[];
|
||||
keys: Secret[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,24 +93,27 @@ export interface UpsertStoryInput {
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
export interface UpsertStoryResult {
|
||||
story: Story;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function upsertStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id = uuid.v4(), url, siteID }: UpsertStoryInput,
|
||||
now = new Date()
|
||||
) {
|
||||
): Promise<UpsertStoryResult> {
|
||||
// Create the story, optionally sourcing the id from the input, additionally
|
||||
// porting in the tenantID.
|
||||
const update: { $setOnInsert: Story } = {
|
||||
$setOnInsert: {
|
||||
id,
|
||||
url,
|
||||
siteID,
|
||||
tenantID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
},
|
||||
const story: Story = {
|
||||
id,
|
||||
url,
|
||||
tenantID,
|
||||
siteID,
|
||||
createdAt: now,
|
||||
commentCounts: createEmptyRelatedCommentCounts(),
|
||||
settings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -121,18 +124,26 @@ export async function upsertStory(
|
||||
url,
|
||||
tenantID,
|
||||
},
|
||||
update,
|
||||
{ $setOnInsert: story },
|
||||
{
|
||||
// Create the object if it doesn't already exist.
|
||||
upsert: true,
|
||||
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new Story, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
return {
|
||||
// The story will either be found (via `result.value`) or upserted (via
|
||||
// `story`).
|
||||
story: result.value || story,
|
||||
|
||||
// The story was upserted if the value isn't provided.
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
@@ -172,13 +183,18 @@ export interface FindOrCreateStoryInput {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface FindOrCreateStoryResult {
|
||||
story: Story | null;
|
||||
wasUpserted: boolean;
|
||||
}
|
||||
|
||||
export async function findOrCreateStory(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
{ id, url }: FindOrCreateStoryInput,
|
||||
siteID: string | null,
|
||||
now = new Date()
|
||||
) {
|
||||
): Promise<FindOrCreateStoryResult> {
|
||||
if (id) {
|
||||
if (url && siteID) {
|
||||
// The URL was specified, this is an upsert operation.
|
||||
@@ -194,8 +210,14 @@ export async function findOrCreateStory(
|
||||
);
|
||||
}
|
||||
|
||||
// The URL and siteID were not specified, this is a lookup operation.
|
||||
return retrieveStory(mongo, tenantID, id);
|
||||
// The URL was not specified, this is a lookup operation.
|
||||
const story = await retrieveStory(mongo, tenantID, id);
|
||||
|
||||
// Return the result object.
|
||||
return {
|
||||
story,
|
||||
wasUpserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The ID was not specified, this is an upsert operation. Check to see that
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
GQLStaffConfiguration,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { SSOKey } from "../settings";
|
||||
import { Secret } from "../settings";
|
||||
import { Tenant } from "./tenant";
|
||||
|
||||
export const getDefaultReactionConfiguration = (
|
||||
@@ -39,12 +39,12 @@ export function generateRandomString(size: number, drift = 5) {
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSSOKey(createdAt: Date): SSOKey {
|
||||
export function generateSecret(prefix: string, createdAt: Date): Secret {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
const secret = generateRandomString(32, 5);
|
||||
const secret = prefix + "_" + generateRandomString(32, 5);
|
||||
const kid = generateRandomString(8, 3);
|
||||
|
||||
return { kid, secret, createdAt };
|
||||
@@ -67,3 +67,10 @@ export function hasFeatureFlag(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getWebhookEndpoint(
|
||||
tenant: Pick<Tenant, "webhooks">,
|
||||
endpointID: string
|
||||
) {
|
||||
return tenant.webhooks.endpoints.find(e => e.id === endpointID) || null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import TIME from "coral-common/time";
|
||||
import { DeepPartial, Omit, Sub } from "coral-common/types";
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import { Settings } from "coral-server/models/settings";
|
||||
import logger from "coral-server/logger";
|
||||
import { Secret, Settings } from "coral-server/models/settings";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
@@ -18,12 +19,14 @@ import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLMODERATION_MODE,
|
||||
GQLSettings,
|
||||
GQLWEBHOOK_EVENT_NAME,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
generateSSOKey,
|
||||
generateSecret,
|
||||
getDefaultReactionConfiguration,
|
||||
getDefaultStaffConfiguration,
|
||||
getWebhookEndpoint,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,49 @@ export interface TenantResource {
|
||||
readonly tenantID: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
/**
|
||||
* id is the unique identifier for this specific endpoint.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* enabled when true will enable events to be sent to this endpoint.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* url is the URL that we will POST event data to.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* signingSecret is the secret used to sign the events sent out.
|
||||
*/
|
||||
signingSecrets: Secret[];
|
||||
|
||||
/**
|
||||
* all when true indicates that all events should trigger.
|
||||
*/
|
||||
all: boolean;
|
||||
|
||||
/**
|
||||
* events is the array of events that will trigger the delivery of an
|
||||
* event.
|
||||
*/
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
|
||||
/**
|
||||
* createdAt is the date that this endpoint was created.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* modifiedAt is the date that this Endpoint was last modified at.
|
||||
*/
|
||||
modifiedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TenantSettings
|
||||
extends Pick<GQLSettings, "domain" | "organization"> {
|
||||
readonly id: string;
|
||||
@@ -51,6 +97,16 @@ export interface TenantSettings
|
||||
* featureFlags is the set of flags enabled on this Tenant.
|
||||
*/
|
||||
featureFlags?: GQLFEATURE_FLAG[];
|
||||
|
||||
/**
|
||||
* webhooks stores the configurations for this Tenant's webhook rules.
|
||||
*/
|
||||
webhooks: {
|
||||
/**
|
||||
* endpoints is all the configured endpoints that should receive events.
|
||||
*/
|
||||
endpoints: Endpoint[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +168,9 @@ export async function createTenant(
|
||||
enabled: false,
|
||||
},
|
||||
editCommentWindowLength: 30 * TIME.SECOND,
|
||||
webhooks: {
|
||||
endpoints: [],
|
||||
},
|
||||
charCount: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -138,7 +197,7 @@ export async function createTenant(
|
||||
stream: true,
|
||||
},
|
||||
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
|
||||
keys: [generateSSOKey(now)],
|
||||
keys: [generateSecret("ssosec", now)],
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
@@ -294,9 +353,11 @@ export async function updateTenant(
|
||||
{ id },
|
||||
// Only update fields that have been updated.
|
||||
{ $set },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
@@ -309,7 +370,7 @@ export async function updateTenant(
|
||||
*/
|
||||
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
|
||||
// Construct the new key.
|
||||
const key = generateSSOKey(now);
|
||||
const key = generateSecret("ssosec", now);
|
||||
|
||||
// Update the Tenant with this new key.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
@@ -466,3 +527,243 @@ export function retrieveAnnouncementIfEnabled(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function rollTenantWebhookEndpointSecret(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
inactiveAt: Date,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new secret.
|
||||
const secret = generateSecret("whsec", now);
|
||||
|
||||
// Update the Tenant with this new secret.
|
||||
let result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret },
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grab the endpoint we just modified.
|
||||
const endpoint = getWebhookEndpoint(result.value, endpointID);
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the secrets we need to deactivate...
|
||||
const secretKIDsToDeprecate = endpoint.signingSecrets
|
||||
// By excluding the last one (the one we just pushed)...
|
||||
.splice(0, endpoint.signingSecrets.length - 1)
|
||||
// And only finding keys that have not been rotated yet.
|
||||
.filter(s => !s.rotatedAt)
|
||||
// And get their kid's.
|
||||
.map(s => s.kid);
|
||||
if (secretKIDsToDeprecate.length > 0) {
|
||||
logger.trace(
|
||||
{ kids: secretKIDsToDeprecate },
|
||||
"deprecating old signingSecrets"
|
||||
);
|
||||
|
||||
// Deactivate the old keys.
|
||||
result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt,
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now,
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayFilters: [
|
||||
// Select the endpoint we're updating.
|
||||
{ "endpoint.id": endpointID },
|
||||
// Select any signing secrets with the given ids.
|
||||
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export interface CreateTenantWebhookEndpointInput {
|
||||
url: string;
|
||||
all: boolean;
|
||||
events: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function createTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantWebhookEndpointInput,
|
||||
now: Date
|
||||
) {
|
||||
// Create the new endpoint.
|
||||
const endpoint: Endpoint = {
|
||||
...input,
|
||||
id: uuid(),
|
||||
enabled: true,
|
||||
signingSecrets: [generateSecret("whsec", now)],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Update the Tenant with this new endpoint.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $push: { "webhooks.endpoints": endpoint } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return {
|
||||
endpoint: null,
|
||||
tenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
tenant: result.value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateTenantWebhookEndpointInput {
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
all?: boolean;
|
||||
events?: GQLWEBHOOK_EVENT_NAME[];
|
||||
}
|
||||
|
||||
export async function updateTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
update: UpdateTenantWebhookEndpointInput
|
||||
) {
|
||||
const $set = dotize(
|
||||
{ "webhooks.endpoints.$[endpoint]": update },
|
||||
{ embedArrays: true }
|
||||
);
|
||||
|
||||
// Check to see if there is any updates that will be made.
|
||||
if (isEmpty($set)) {
|
||||
// No updates need to be made, abort here and just return the tenant.
|
||||
return retrieveTenant(mongo, id);
|
||||
}
|
||||
|
||||
// Perform the actual update operation.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{ $set },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
arrayFilters: [{ "endpoint.id": endpointID }],
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteEndpointSecrets(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string,
|
||||
kids: string[]
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
|
||||
},
|
||||
},
|
||||
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getWebhookEndpoint(tenant, endpointID);
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
`endpoint not found with id: ${endpointID} on tenant: ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantWebhookEndpoint(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
endpointID: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: {
|
||||
"webhooks.endpoints": { id: endpointID },
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const tenant = await retrieveTenant(mongo, id);
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error("update failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ export async function findOrCreateUser(
|
||||
const user = await findOrCreateUserInput(tenantID, input, now);
|
||||
|
||||
try {
|
||||
await collection(mongo).findOneAndUpdate(
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
profiles: {
|
||||
@@ -588,12 +588,18 @@ export async function findOrCreateUser(
|
||||
},
|
||||
{ $setOnInsert: user },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// True to return the original document instead of the updated document.
|
||||
// This will ensure that when an upsert operation adds a new User, it
|
||||
// should return null.
|
||||
returnOriginal: true,
|
||||
upsert: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: result.value || user,
|
||||
wasUpserted: !result.value,
|
||||
};
|
||||
} catch (err) {
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate User error.
|
||||
@@ -607,8 +613,6 @@ export async function findOrCreateUser(
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export type CreateUserInput = FindOrCreateUserInput;
|
||||
|
||||
@@ -4,9 +4,11 @@ import Logger from "bunyan";
|
||||
import TIME from "coral-common/time";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
export interface TaskOptions<T, U = any> {
|
||||
export type JobProcessor<T, U = void> = (job: Job<T>) => Promise<U>;
|
||||
|
||||
export interface TaskOptions<T, U = void> {
|
||||
jobName: string;
|
||||
jobProcessor: (job: Job<T>) => Promise<U>;
|
||||
jobProcessor: JobProcessor<T, U>;
|
||||
jobOptions?: Queue.JobOptions;
|
||||
queue: Queue.QueueOptions;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Queue from "bull";
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
@@ -10,6 +11,7 @@ import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { createMailerTask, MailerQueue } from "./tasks/mailer";
|
||||
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
|
||||
import { createScraperTask, ScraperQueue } from "./tasks/scraper";
|
||||
import { createWebhookTask, WebhookQueue } from "./tasks/webhook";
|
||||
|
||||
const createQueueOptions = async (
|
||||
config: Config
|
||||
@@ -47,12 +49,14 @@ export interface QueueOptions {
|
||||
tenantCache: TenantCache;
|
||||
i18n: I18n;
|
||||
signingConfig: JWTSigningConfig;
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export interface TaskQueue {
|
||||
mailer: MailerQueue;
|
||||
scraper: ScraperQueue;
|
||||
notifier: NotifierQueue;
|
||||
webhook: WebhookQueue;
|
||||
}
|
||||
|
||||
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
|
||||
@@ -67,11 +71,13 @@ export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
|
||||
mailerQueue: mailer,
|
||||
...options,
|
||||
});
|
||||
const webhook = createWebhookTask(queueOptions, options);
|
||||
|
||||
// Return the tasks + client.
|
||||
return {
|
||||
mailer,
|
||||
scraper,
|
||||
notifier,
|
||||
webhook,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user