feat: Webhooks (#3691)
* Webhooks (#3607) * Get the migration and the model setup. Also make the sample env file a bit easier to use. Now just requires setting a SECRET_KEY and besides that will boot up from the sample * WIP: Start getting a Webhook page created. Just the skeleton state right now * WIP: Getting a form created to create webhooks, need to bring in react-hook-forms now * WIP: Get library installed and make TS happy * Get a few checkboxes ready to go * Get creating and destroying working with a decent start to a frontend * Didn't mean to enable this * Remove eslint and fix other random typescript issue * Rename some events to be more realistic * Revert these changes * PR review comments around policies. Also make sure this inherits from IdModel so it actually gets an id * Allow any admin on the team to edit webhooks * Start sending some webhooks for some User events * Make sure the URL is valid * Start recording webhook deliveries * Make sure to verify if the subscription is for the type of event we are looking at * Refactor sending Webhooks and follow better webhook schema This creates a presenter to unify the format of webhooks. We also extract the sending of webhooks and recording their deliveries to a method than can be used by each of the different event type methods We also add a status to WebhookDelivery since we need to save the record before we make the HTTP request to get its id. Then once we make the request and get a response we can update the delivery with the HTTP info * Turn off a subscription that has failed for the last 25 deliveries * Get a first spec passing. Found a bug in my returning of promises so good to patch that up now * This looks nicer * Get some tests added for the processor * Add cron task to delete older webhooks * Add Document Events to the Processor * Revisions, FileOperations and Collections * Get all the server side events added to the processor and make Typescript make sure they are all accounted for * Get all the events added to the Frontend and work on styling them a bit, still needs some love though * Get UI styled up a bit * Get events wired up for webhook subscriptions * Get delete events working and test at least one variant of them * Get deletes working and actually make sure to send the model id in the webhook * Remove webhook secrets from this slice * Add disabled label for subscriptions that are disabled * Make sure to cascade the delete * Reorg this file a bit * Fix association * I removed secret for the moment * Apply Copy changes from PR Review Co-authored-by: Tom Moor <tom.moor@gmail.com> * Actually apply the copy changes TIL that if you Resolve a conversation it _also_ removes the 'staged suggestion' from your list on Github Co-authored-by: Tom Moor <tom.moor@gmail.com> * Update app/scenes/Settings/Webhooks.tsx Missed this copy change before Co-authored-by: Tom Moor <tom.moor@gmail.com> * Add disabled as yellow badge * Resolve frontend comments * Fixup Schema a bit and remove the dependency on the subscription * Add test to make sure we don't disable until there are enough failures, and fix code to actually do that. Also some test fixes from the json response shape changes * Fix WebhookDeliveries to store the responses as Text instead of blobs * Switch to text better for response bodies, this is using the helpers better and makes the code read better * Move the logic to a task but run in through the processor cause the tests expect that right now, moving the tests over next * Split up the tests and actually enqueue the events from the WebhookProcessor instead of doing them inline * Allow any team admin to see any webhook subscription for the team * Add the indexes based on our lookup patterns * Run eslint --fix to fix auto correct issues from when I tried to use Github to merge copy changes * Allow subscriptions to be edited after creation * Types caught that I didn't add the new event to the webhook processor, also added it to the frontend here * I think this will get these into the translations file * Catch a few more translations, use styled components better and remove usage of webhook subscription in the copy Co-authored-by: Tom Moor <tom.moor@gmail.com> * fix: tsc fix: Document model payload empty * fix: Revision webhook payload Add custom UA for hooks * Add webhooks icon, move under Integrations settings Some spacing fixes * Add actorId to webhook payloads * Add View and ApiKey event types * Spacing tweaks, fix team payload * fix: Webhook not disabled after 25 failures * fix: Enable webhook when editing if previously disabled * fix: Correctly store response headers * fix: Error in json/parsing/presentation results in hanging 'pending' webhook delivery * fix: Awkward payload for users.invite webhook * Add BaseEvent, ShareEvent * fix: Add share events to form * fix: Move webhook delivery cleanup to single DB call Remove some unused abstraction * Add user, collection, group context to membership webhook events Some associated refactoring Co-authored-by: Corey Alexander <coreyja@gmail.com>
This commit is contained in:
@@ -104,31 +104,17 @@ export const LabelText = styled.div`
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
|
export type Props = React.InputHTMLAttributes<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement
|
||||||
|
> & {
|
||||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||||
value?: string;
|
|
||||||
label?: string;
|
|
||||||
className?: string;
|
|
||||||
labelHidden?: boolean;
|
labelHidden?: boolean;
|
||||||
|
label?: string;
|
||||||
flex?: boolean;
|
flex?: boolean;
|
||||||
short?: boolean;
|
short?: boolean;
|
||||||
margin?: string | number;
|
margin?: string | number;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
name?: string;
|
innerRef?: React.Ref<any>;
|
||||||
pattern?: string;
|
|
||||||
minLength?: number;
|
|
||||||
maxLength?: number;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
autoComplete?: boolean | string;
|
|
||||||
readOnly?: boolean;
|
|
||||||
required?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
onChange?: (
|
|
||||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => unknown;
|
|
||||||
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
|
||||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
|
||||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||||
};
|
};
|
||||||
@@ -171,8 +157,6 @@ class Input extends React.Component<Props> {
|
|||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const InputComponent: React.ComponentType =
|
|
||||||
type === "textarea" ? RealTextarea : RealInput;
|
|
||||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,15 +170,24 @@ class Input extends React.Component<Props> {
|
|||||||
))}
|
))}
|
||||||
<Outline focused={this.focused} margin={margin}>
|
<Outline focused={this.focused} margin={margin}>
|
||||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||||
<InputComponent
|
{type === "textarea" ? (
|
||||||
// @ts-expect-error no idea why this is not working
|
<RealTextarea
|
||||||
ref={this.input}
|
ref={this.props.innerRef}
|
||||||
onBlur={this.handleBlur}
|
onBlur={this.props.onBlur}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
hasIcon={!!icon}
|
hasIcon={!!icon}
|
||||||
type={type === "textarea" ? undefined : type}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<RealInput
|
||||||
|
ref={this.props.innerRef}
|
||||||
|
onBlur={this.props.onBlur}
|
||||||
|
onFocus={this.handleFocus}
|
||||||
|
hasIcon={!!icon}
|
||||||
|
type={type}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Outline>
|
</Outline>
|
||||||
</label>
|
</label>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
@@ -202,4 +195,10 @@ class Input extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ReactHookWrappedInput = React.forwardRef(
|
||||||
|
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||||
|
return <Input {...{ ...props, innerRef: ref }} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default Input;
|
export default Input;
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import useStores from "~/hooks/useStores";
|
|||||||
type StoreProps = keyof RootStore;
|
type StoreProps = keyof RootStore;
|
||||||
|
|
||||||
function withStores<
|
function withStores<
|
||||||
P extends React.ComponentType<React.ComponentProps<P> & RootStore>,
|
P extends React.ComponentType<ResolvedProps & RootStore>,
|
||||||
ResolvedProps = JSX.LibraryManagedAttributes<
|
ResolvedProps = JSX.LibraryManagedAttributes<
|
||||||
P,
|
P,
|
||||||
Omit<React.ComponentProps<P>, StoreProps>
|
Omit<React.ComponentProps<P>, StoreProps>
|
||||||
>
|
>
|
||||||
>(WrappedComponent: P): React.FC<Omit<ResolvedProps, StoreProps>> {
|
>(WrappedComponent: P): React.FC<ResolvedProps> {
|
||||||
const ComponentWithStore = (
|
const ComponentWithStore = (props: ResolvedProps) => {
|
||||||
props: Omit<React.ComponentProps<P>, StoreProps>
|
|
||||||
) => {
|
|
||||||
const stores = useStores();
|
const stores = useStores();
|
||||||
return <WrappedComponent {...(props as any)} {...stores} />;
|
return <WrappedComponent {...(props as any)} {...stores} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TeamIcon,
|
TeamIcon,
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
WebhooksIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -25,6 +26,7 @@ import Security from "~/scenes/Settings/Security";
|
|||||||
import Shares from "~/scenes/Settings/Shares";
|
import Shares from "~/scenes/Settings/Shares";
|
||||||
import Slack from "~/scenes/Settings/Slack";
|
import Slack from "~/scenes/Settings/Slack";
|
||||||
import Tokens from "~/scenes/Settings/Tokens";
|
import Tokens from "~/scenes/Settings/Tokens";
|
||||||
|
import Webhooks from "~/scenes/Settings/Webhooks";
|
||||||
import Zapier from "~/scenes/Settings/Zapier";
|
import Zapier from "~/scenes/Settings/Zapier";
|
||||||
import SlackIcon from "~/components/SlackIcon";
|
import SlackIcon from "~/components/SlackIcon";
|
||||||
import ZapierIcon from "~/components/ZapierIcon";
|
import ZapierIcon from "~/components/ZapierIcon";
|
||||||
@@ -46,6 +48,7 @@ type SettingsPage =
|
|||||||
| "Shares"
|
| "Shares"
|
||||||
| "Import"
|
| "Import"
|
||||||
| "Export"
|
| "Export"
|
||||||
|
| "Webhooks"
|
||||||
| "Slack"
|
| "Slack"
|
||||||
| "Zapier";
|
| "Zapier";
|
||||||
|
|
||||||
@@ -158,7 +161,15 @@ const useAuthorizedSettingsConfig = () => {
|
|||||||
group: t("Team"),
|
group: t("Team"),
|
||||||
icon: DownloadIcon,
|
icon: DownloadIcon,
|
||||||
},
|
},
|
||||||
// Intergrations
|
// Integrations
|
||||||
|
Webhooks: {
|
||||||
|
name: t("Webhooks"),
|
||||||
|
path: "/settings/webhooks",
|
||||||
|
component: Webhooks,
|
||||||
|
enabled: can.createWebhookSubscription,
|
||||||
|
group: t("Integrations"),
|
||||||
|
icon: WebhooksIcon,
|
||||||
|
},
|
||||||
Slack: {
|
Slack: {
|
||||||
name: "Slack",
|
name: "Slack",
|
||||||
path: "/settings/integrations/slack",
|
path: "/settings/integrations/slack",
|
||||||
@@ -176,7 +187,14 @@ const useAuthorizedSettingsConfig = () => {
|
|||||||
icon: ZapierIcon,
|
icon: ZapierIcon,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[can.createApiKey, can.export, can.manage, can.update, t]
|
[
|
||||||
|
can.createApiKey,
|
||||||
|
can.createWebhookSubscription,
|
||||||
|
can.export,
|
||||||
|
can.manage,
|
||||||
|
can.update,
|
||||||
|
t,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const enabledConfigs = React.useMemo(
|
const enabledConfigs = React.useMemo(
|
||||||
|
|||||||
27
app/models/WebhookSubscription.ts
Normal file
27
app/models/WebhookSubscription.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { observable } from "mobx";
|
||||||
|
import BaseModel from "./BaseModel";
|
||||||
|
import Field from "./decorators/Field";
|
||||||
|
|
||||||
|
class WebhookSubscription extends BaseModel {
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
events: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookSubscription;
|
||||||
69
app/scenes/Settings/Webhooks.tsx
Normal file
69
app/scenes/Settings/Webhooks.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { WebhooksIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
|
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||||
|
import { Action } from "~/components/Actions";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Heading from "~/components/Heading";
|
||||||
|
import Modal from "~/components/Modal";
|
||||||
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
import Scene from "~/components/Scene";
|
||||||
|
import Subheading from "~/components/Subheading";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import WebhookSubscriptionListItem from "./components/WebhookSubscriptionListItem";
|
||||||
|
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
|
||||||
|
|
||||||
|
function Webhooks() {
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { webhookSubscriptions } = useStores();
|
||||||
|
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||||
|
const can = usePolicy(team.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene
|
||||||
|
title={t("Webhooks")}
|
||||||
|
icon={<WebhooksIcon color="currentColor" />}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{can.createWebhookSubscription && (
|
||||||
|
<Action>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
value={`${t("New webhook")}…`}
|
||||||
|
onClick={handleNewModalOpen}
|
||||||
|
/>
|
||||||
|
</Action>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Heading>{t("Webhooks")}</Heading>
|
||||||
|
<Text type="secondary">
|
||||||
|
<Trans defaults="Webhooks can be used to notify your application when events happen in Outline. Events are sent as a https request with a JSON payload in near real-time." />
|
||||||
|
</Text>
|
||||||
|
<PaginatedList
|
||||||
|
fetch={webhookSubscriptions.fetchPage}
|
||||||
|
items={webhookSubscriptions.orderedData}
|
||||||
|
heading={<Subheading sticky>{t("Webhooks")}</Subheading>}
|
||||||
|
renderItem={(webhook: WebhookSubscription) => (
|
||||||
|
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={t("Create a webhook")}
|
||||||
|
onRequestClose={handleNewModalClose}
|
||||||
|
isOpen={newModalOpen}
|
||||||
|
>
|
||||||
|
<WebhookSubscriptionNew onSubmit={handleNewModalClose} />
|
||||||
|
</Modal>
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Webhooks);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||||
|
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
webhook: WebhookSubscription;
|
||||||
|
onSubmit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebhookSubscriptionRevokeDialog({
|
||||||
|
webhook,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await webhook.delete();
|
||||||
|
onSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationDialog
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitText={t("Delete")}
|
||||||
|
savingText={`${t("Deleting")}…`}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
{t("Are you sure you want to delete the {{ name }} webhook?", {
|
||||||
|
name: webhook.name,
|
||||||
|
})}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
app/scenes/Settings/components/WebhookSubscriptionEdit.tsx
Normal file
57
app/scenes/Settings/components/WebhookSubscriptionEdit.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import WebhookSubscriptionForm from "./WebhookSubscriptionForm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: () => void;
|
||||||
|
webhookSubscription: WebhookSubscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookSubscriptionEdit({ onSubmit, webhookSubscription }: Props) {
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleSubmit = React.useCallback(
|
||||||
|
async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
const events = Array.isArray(data.events) ? data.events : [data.events];
|
||||||
|
|
||||||
|
const toSend = {
|
||||||
|
...data,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
|
||||||
|
await webhookSubscription.save(toSend);
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
t("Webhook updated", {
|
||||||
|
type: "success",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onSubmit();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, showToast, onSubmit, webhookSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebhookSubscriptionForm
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
webhookSubscription={webhookSubscription}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookSubscriptionEdit;
|
||||||
284
app/scenes/Settings/components/WebhookSubscriptionForm.tsx
Normal file
284
app/scenes/Settings/components/WebhookSubscriptionForm.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { isEqual, filter, includes } from "lodash";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import { ReactHookWrappedInput } from "~/components/Input";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import useMobile from "~/hooks/useMobile";
|
||||||
|
|
||||||
|
const WEBHOOK_EVENTS = {
|
||||||
|
user: [
|
||||||
|
"users.create",
|
||||||
|
"users.signin",
|
||||||
|
"users.update",
|
||||||
|
"users.suspend",
|
||||||
|
"users.activate",
|
||||||
|
"users.delete",
|
||||||
|
"users.invite",
|
||||||
|
],
|
||||||
|
document: [
|
||||||
|
"documents.create",
|
||||||
|
"documents.publish",
|
||||||
|
"documents.unpublish",
|
||||||
|
"documents.delete",
|
||||||
|
"documents.permanent_delete",
|
||||||
|
"documents.archive",
|
||||||
|
"documents.unarchive",
|
||||||
|
"documents.restore",
|
||||||
|
"documents.star",
|
||||||
|
"documents.unstar",
|
||||||
|
"documents.move",
|
||||||
|
"documents.update",
|
||||||
|
"documents.update.delayed",
|
||||||
|
"documents.update.debounced",
|
||||||
|
"documents.title_change",
|
||||||
|
],
|
||||||
|
revision: ["revisions.create"],
|
||||||
|
fileOperation: [
|
||||||
|
"file_operations.create",
|
||||||
|
"file_operations.update",
|
||||||
|
"file_operations.delete",
|
||||||
|
],
|
||||||
|
collection: [
|
||||||
|
"collections.create",
|
||||||
|
"collections.update",
|
||||||
|
"collections.delete",
|
||||||
|
"collections.add_user",
|
||||||
|
"collections.remove_user",
|
||||||
|
"collections.add_group",
|
||||||
|
"collections.remove_group",
|
||||||
|
"collections.move",
|
||||||
|
"collections.permission_changed",
|
||||||
|
],
|
||||||
|
group: [
|
||||||
|
"groups.create",
|
||||||
|
"groups.update",
|
||||||
|
"groups.delete",
|
||||||
|
"groups.add_user",
|
||||||
|
"groups.remove_user",
|
||||||
|
],
|
||||||
|
integration: ["integrations.create", "integrations.update"],
|
||||||
|
share: ["shares.create", "shares.update", "shares.revoke"],
|
||||||
|
team: ["teams.update"],
|
||||||
|
pin: ["pins.create", "pins.update", "pins.delete"],
|
||||||
|
webhookSubscription: [
|
||||||
|
"webhook_subscriptions.create",
|
||||||
|
"webhook_subscriptions.delete",
|
||||||
|
"webhook_subscriptions.update",
|
||||||
|
],
|
||||||
|
view: ["views.create"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventCheckboxLabel = styled.label`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GroupEventCheckboxLabel = styled(EventCheckboxLabel)`
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.2em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AllEventCheckboxLabel = styled(GroupEventCheckboxLabel)`
|
||||||
|
font-size: 1.4em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EventCheckboxText = styled.span`
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
const FieldSet = styled.fieldset<FieldProps>`
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
${({ disabled }) =>
|
||||||
|
disabled &&
|
||||||
|
`
|
||||||
|
opacity: 0.5;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface MobileProps {
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
const GroupGrid = styled.div<MobileProps>`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
${({ isMobile }) =>
|
||||||
|
isMobile &&
|
||||||
|
`
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GroupWrapper = styled.div<MobileProps>`
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
|
||||||
|
${({ isMobile }) =>
|
||||||
|
isMobile &&
|
||||||
|
`
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TextFields = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleSubmit: (data: FormData) => void;
|
||||||
|
webhookSubscription?: WebhookSubscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit: formHandleSubmit,
|
||||||
|
formState,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
} = useForm<FormData>({
|
||||||
|
mode: "all",
|
||||||
|
defaultValues: {
|
||||||
|
events: webhookSubscription ? [...webhookSubscription.events] : [],
|
||||||
|
name: webhookSubscription?.name,
|
||||||
|
url: webhookSubscription?.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = watch("events");
|
||||||
|
const selectedGroups = filter(events, (e) => !e.includes("."));
|
||||||
|
const isAllEventSelected = includes(events, "*");
|
||||||
|
const filteredEvents = filter(events, (e) => {
|
||||||
|
const [beforePeriod] = e.split(".");
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedGroups.length === 0 ||
|
||||||
|
e === beforePeriod ||
|
||||||
|
!selectedGroups.includes(beforePeriod)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMobile = useMobile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAllEventSelected) {
|
||||||
|
setValue("events", ["*"]);
|
||||||
|
}
|
||||||
|
}, [isAllEventSelected, setValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEqual(events, filteredEvents)) {
|
||||||
|
setValue("events", filteredEvents);
|
||||||
|
}
|
||||||
|
}, [events, filteredEvents, setValue]);
|
||||||
|
|
||||||
|
const verb = webhookSubscription ? t("Update") : t("Create");
|
||||||
|
const inProgressVerb = webhookSubscription ? t("Updating") : t("Creating");
|
||||||
|
|
||||||
|
function EventCheckbox({ label, value }: { label: string; value: string }) {
|
||||||
|
const LabelComponent =
|
||||||
|
value === "*"
|
||||||
|
? AllEventCheckboxLabel
|
||||||
|
: Object.keys(WEBHOOK_EVENTS).includes(value)
|
||||||
|
? GroupEventCheckboxLabel
|
||||||
|
: EventCheckboxLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LabelComponent>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultValue={value}
|
||||||
|
{...register("events", {})}
|
||||||
|
/>
|
||||||
|
<EventCheckboxText>{label}</EventCheckboxText>
|
||||||
|
</LabelComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||||
|
<Text type="secondary">
|
||||||
|
<Trans>
|
||||||
|
Provide a descriptive name for this webhook and the URL we should send
|
||||||
|
a POST request to when matching events are created.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
<Trans>
|
||||||
|
Subscribe to all events, groups, or individual events. We recommend
|
||||||
|
only subscribing to the minimum amount of events that your application
|
||||||
|
needs to function.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<TextFields>
|
||||||
|
<ReactHookWrappedInput
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
flex
|
||||||
|
label={t("Name")}
|
||||||
|
{...register("name", {
|
||||||
|
required: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<ReactHookWrappedInput
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
flex
|
||||||
|
pattern="https://.*"
|
||||||
|
placeholder="https://…"
|
||||||
|
label={t("URL")}
|
||||||
|
{...register("url", { required: true })}
|
||||||
|
/>
|
||||||
|
</TextFields>
|
||||||
|
|
||||||
|
<EventCheckbox label={t("All events")} value="*" />
|
||||||
|
|
||||||
|
<FieldSet disabled={isAllEventSelected}>
|
||||||
|
<GroupGrid isMobile={isMobile}>
|
||||||
|
{Object.entries(WEBHOOK_EVENTS).map(([group, events], i) => (
|
||||||
|
<GroupWrapper key={i} isMobile={isMobile}>
|
||||||
|
<EventCheckbox
|
||||||
|
label={t(`All {{ groupName }} events`, { groupName: group })}
|
||||||
|
value={group}
|
||||||
|
/>
|
||||||
|
<FieldSet disabled={selectedGroups.includes(group)}>
|
||||||
|
{events.map((event) => (
|
||||||
|
<EventCheckbox label={event} value={event} key={event} />
|
||||||
|
))}
|
||||||
|
</FieldSet>
|
||||||
|
</GroupWrapper>
|
||||||
|
))}
|
||||||
|
</GroupGrid>
|
||||||
|
</FieldSet>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={formState.isSubmitting || !formState.isValid}
|
||||||
|
>
|
||||||
|
{formState.isSubmitting ? `${inProgressVerb}…` : verb}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookSubscriptionForm;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { EditIcon, TrashIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||||
|
import Badge from "~/components/Badge";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import ListItem from "~/components/List/Item";
|
||||||
|
import Modal from "~/components/Modal";
|
||||||
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import WebhookSubscriptionRevokeDialog from "./WebhookSubscriptionDeleteDialog";
|
||||||
|
import WebhookSubscriptionEdit from "./WebhookSubscriptionEdit";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
webhook: WebhookSubscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebhookSubscriptionListItem = ({ webhook }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { dialogs } = useStores();
|
||||||
|
const [
|
||||||
|
editModalOpen,
|
||||||
|
handleEditModalOpen,
|
||||||
|
handleEditModalClose,
|
||||||
|
] = useBoolean();
|
||||||
|
|
||||||
|
const showDeletionConfirmation = React.useCallback(() => {
|
||||||
|
dialogs.openModal({
|
||||||
|
title: t("Delete webhook"),
|
||||||
|
isCentered: true,
|
||||||
|
content: (
|
||||||
|
<WebhookSubscriptionRevokeDialog
|
||||||
|
onSubmit={dialogs.closeAllModals}
|
||||||
|
webhook={webhook}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [t, dialogs, webhook]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={webhook.id}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{webhook.name}
|
||||||
|
{!webhook.enabled && (
|
||||||
|
<StyledBadge yellow={true}>{t("Disabled")}</StyledBadge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
{t("Subscribed events")}: <code>{webhook.events.join(", ")}</code>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={showDeletionConfirmation}
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
{t("Delete")}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<EditIcon />} onClick={handleEditModalOpen} neutral>
|
||||||
|
{t("Edit")}
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
title={t("Edit webhook")}
|
||||||
|
onRequestClose={handleEditModalClose}
|
||||||
|
isOpen={editModalOpen}
|
||||||
|
>
|
||||||
|
<WebhookSubscriptionEdit
|
||||||
|
onSubmit={handleEditModalClose}
|
||||||
|
webhookSubscription={webhook}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledBadge = styled(Badge)`
|
||||||
|
position: absolute;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default WebhookSubscriptionListItem;
|
||||||
51
app/scenes/Settings/components/WebhookSubscriptionNew.tsx
Normal file
51
app/scenes/Settings/components/WebhookSubscriptionNew.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import WebhookSubscriptionForm from "./WebhookSubscriptionForm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookSubscriptionNew({ onSubmit }: Props) {
|
||||||
|
const { webhookSubscriptions } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleSubmit = React.useCallback(
|
||||||
|
async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
const events = Array.isArray(data.events) ? data.events : [data.events];
|
||||||
|
|
||||||
|
const toSend = {
|
||||||
|
...data,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
|
||||||
|
await webhookSubscriptions.create(toSend);
|
||||||
|
showToast(
|
||||||
|
t("Webhook created", {
|
||||||
|
type: "success",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onSubmit();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, showToast, onSubmit, webhookSubscriptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <WebhookSubscriptionForm handleSubmit={handleSubmit} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookSubscriptionNew;
|
||||||
@@ -22,6 +22,7 @@ import ToastsStore from "./ToastsStore";
|
|||||||
import UiStore from "./UiStore";
|
import UiStore from "./UiStore";
|
||||||
import UsersStore from "./UsersStore";
|
import UsersStore from "./UsersStore";
|
||||||
import ViewsStore from "./ViewsStore";
|
import ViewsStore from "./ViewsStore";
|
||||||
|
import WebhookSubscriptionsStore from "./WebhookSubscriptionStore";
|
||||||
|
|
||||||
export default class RootStore {
|
export default class RootStore {
|
||||||
apiKeys: ApiKeysStore;
|
apiKeys: ApiKeysStore;
|
||||||
@@ -48,6 +49,7 @@ export default class RootStore {
|
|||||||
views: ViewsStore;
|
views: ViewsStore;
|
||||||
toasts: ToastsStore;
|
toasts: ToastsStore;
|
||||||
fileOperations: FileOperationsStore;
|
fileOperations: FileOperationsStore;
|
||||||
|
webhookSubscriptions: WebhookSubscriptionsStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// PoliciesStore must be initialized before AuthStore
|
// PoliciesStore must be initialized before AuthStore
|
||||||
@@ -75,6 +77,7 @@ export default class RootStore {
|
|||||||
this.views = new ViewsStore(this);
|
this.views = new ViewsStore(this);
|
||||||
this.fileOperations = new FileOperationsStore(this);
|
this.fileOperations = new FileOperationsStore(this);
|
||||||
this.toasts = new ToastsStore();
|
this.toasts = new ToastsStore();
|
||||||
|
this.webhookSubscriptions = new WebhookSubscriptionsStore(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
@@ -100,5 +103,6 @@ export default class RootStore {
|
|||||||
// this.ui omitted to keep ui settings between sessions
|
// this.ui omitted to keep ui settings between sessions
|
||||||
this.users.clear();
|
this.users.clear();
|
||||||
this.views.clear();
|
this.views.clear();
|
||||||
|
this.webhookSubscriptions.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/stores/WebhookSubscriptionStore.ts
Normal file
18
app/stores/WebhookSubscriptionStore.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||||
|
import BaseStore, { RPCAction } from "./BaseStore";
|
||||||
|
import RootStore from "./RootStore";
|
||||||
|
|
||||||
|
export default class WebhookSubscriptionsStore extends BaseStore<
|
||||||
|
WebhookSubscription
|
||||||
|
> {
|
||||||
|
actions = [
|
||||||
|
RPCAction.List,
|
||||||
|
RPCAction.Create,
|
||||||
|
RPCAction.Delete,
|
||||||
|
RPCAction.Update,
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(rootStore: RootStore) {
|
||||||
|
super(rootStore, WebhookSubscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"mobx-react": "^6.3.1",
|
"mobx-react": "^6.3.1",
|
||||||
"natural-sort": "^1.0.0",
|
"natural-sort": "^1.0.0",
|
||||||
"nodemailer": "^6.6.1",
|
"nodemailer": "^6.6.1",
|
||||||
"outline-icons": "^1.42.0",
|
"outline-icons": "^1.43.1",
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"passport": "^0.4.1",
|
"passport": "^0.4.1",
|
||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
@@ -168,6 +168,7 @@
|
|||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-dropzone": "^11.3.2",
|
"react-dropzone": "^11.3.2",
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-hook-form": "^7.31.2",
|
||||||
"react-i18next": "^11.16.6",
|
"react-i18next": "^11.16.6",
|
||||||
"react-medium-image-zoom": "^3.1.3",
|
"react-medium-image-zoom": "^3.1.3",
|
||||||
"react-merge-refs": "^1.1.0",
|
"react-merge-refs": "^1.1.0",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default async function userInviter({
|
|||||||
name: "users.invite",
|
name: "users.invite",
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
|
userId: newUser.id,
|
||||||
data: {
|
data: {
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
name: invite.name,
|
name: invite.name,
|
||||||
|
|||||||
@@ -485,6 +485,16 @@ export class Environment {
|
|||||||
*/
|
*/
|
||||||
public OIDC_SCOPES = process.env.OIDC_SCOPES ?? "openid profile email";
|
public OIDC_SCOPES = process.env.OIDC_SCOPES ?? "openid profile email";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string representing the version of the software.
|
||||||
|
*
|
||||||
|
* SOURCE_COMMIT is used by Docker Hub
|
||||||
|
* SOURCE_VERSION is used by Heroku
|
||||||
|
*/
|
||||||
|
public VERSION = this.toOptionalString(
|
||||||
|
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
private toOptionalString(value: string | undefined) {
|
private toOptionalString(value: string | undefined) {
|
||||||
return value ? value : undefined;
|
return value ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ export * as APM from "@theo.gravity/datadog-apm";
|
|||||||
if (env.DD_API_KEY) {
|
if (env.DD_API_KEY) {
|
||||||
init(
|
init(
|
||||||
{
|
{
|
||||||
// SOURCE_COMMIT is used by Docker Hub
|
version: env.VERSION,
|
||||||
// SOURCE_VERSION is used by Heroku
|
|
||||||
version: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION,
|
|
||||||
service: process.env.DD_SERVICE || "outline",
|
service: process.env.DD_SERVICE || "outline",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use strict";
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||||
|
await queryInterface.createTable(
|
||||||
|
"webhook_subscriptions",
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: "teams",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: "users",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.addIndex(
|
||||||
|
"webhook_subscriptions",
|
||||||
|
["teamId", "enabled"],
|
||||||
|
{
|
||||||
|
name: "webhook_subscriptions_team_id_enabled",
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
return queryInterface.dropTable("webhook_subscriptions");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use strict";
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||||
|
await queryInterface.createTable(
|
||||||
|
"webhook_deliveries",
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
webhookSubscriptionId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
onDelete: "cascade",
|
||||||
|
references: {
|
||||||
|
model: "webhook_subscriptions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
statusCode: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
requestBody: {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
requestHeaders: {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
responseBody: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
responseHeaders: {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
await queryInterface.addIndex(
|
||||||
|
"webhook_deliveries",
|
||||||
|
["webhookSubscriptionId"],
|
||||||
|
{
|
||||||
|
name: "webhook_deliveries_webhook_subscription_id",
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await queryInterface.addIndex("webhook_deliveries", ["createdAt"], {
|
||||||
|
name: "webhook_deliveries_createdAt",
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
return queryInterface.dropTable("webhook_deliveries");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
Table,
|
Table,
|
||||||
DataType,
|
DataType,
|
||||||
|
Scopes,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import Group from "./Group";
|
import Group from "./Group";
|
||||||
@@ -13,6 +14,22 @@ import User from "./User";
|
|||||||
import BaseModel from "./base/BaseModel";
|
import BaseModel from "./base/BaseModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
|
@Scopes(() => ({
|
||||||
|
withGroup: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "group",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
withCollection: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "collection",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
@Table({ tableName: "collection_groups", modelName: "collection_group" })
|
@Table({ tableName: "collection_groups", modelName: "collection_group" })
|
||||||
@Fix
|
@Fix
|
||||||
class CollectionGroup extends BaseModel {
|
class CollectionGroup extends BaseModel {
|
||||||
|
|||||||
@@ -6,12 +6,29 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
Table,
|
Table,
|
||||||
DataType,
|
DataType,
|
||||||
|
Scopes,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import BaseModel from "./base/BaseModel";
|
import BaseModel from "./base/BaseModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
|
@Scopes(() => ({
|
||||||
|
withUser: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
withCollection: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "collection",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
@Table({ tableName: "collection_users", modelName: "collection_user" })
|
@Table({ tableName: "collection_users", modelName: "collection_user" })
|
||||||
@Fix
|
@Fix
|
||||||
class CollectionUser extends BaseModel {
|
class CollectionUser extends BaseModel {
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ class Event extends IdModel {
|
|||||||
"users.suspend",
|
"users.suspend",
|
||||||
"users.activate",
|
"users.activate",
|
||||||
"users.delete",
|
"users.delete",
|
||||||
|
"webhook_subscriptions.create",
|
||||||
|
"webhook_subscriptions.delete",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Table,
|
Table,
|
||||||
DataType,
|
DataType,
|
||||||
|
Scopes,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import Group from "./Group";
|
import Group from "./Group";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
@@ -18,6 +19,22 @@ import Fix from "./decorators/Fix";
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
@Scopes(() => ({
|
||||||
|
withGroup: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "group",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
withUser: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}))
|
||||||
@Table({ tableName: "group_users", modelName: "group_user", paranoid: true })
|
@Table({ tableName: "group_users", modelName: "group_user", paranoid: true })
|
||||||
@Fix
|
@Fix
|
||||||
class GroupUser extends BaseModel {
|
class GroupUser extends BaseModel {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import Fix from "./decorators/Fix";
|
|||||||
const readFile = util.promisify(fs.readFile);
|
const readFile = util.promisify(fs.readFile);
|
||||||
|
|
||||||
@Scopes(() => ({
|
@Scopes(() => ({
|
||||||
|
withDomains: {
|
||||||
|
include: [{ model: TeamDomain }],
|
||||||
|
},
|
||||||
withAuthenticationProviders: {
|
withAuthenticationProviders: {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ForeignKey,
|
ForeignKey,
|
||||||
Table,
|
Table,
|
||||||
DataType,
|
DataType,
|
||||||
|
Scopes,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
|
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
@@ -14,6 +15,17 @@ import User from "./User";
|
|||||||
import IdModel from "./base/IdModel";
|
import IdModel from "./base/IdModel";
|
||||||
import Fix from "./decorators/Fix";
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
|
@Scopes(() => ({
|
||||||
|
withUser: () => ({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
required: true,
|
||||||
|
as: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@Table({ tableName: "views", modelName: "view" })
|
@Table({ tableName: "views", modelName: "view" })
|
||||||
@Fix
|
@Fix
|
||||||
class View extends IdModel {
|
class View extends IdModel {
|
||||||
|
|||||||
53
server/models/WebhookDelivery.ts
Normal file
53
server/models/WebhookDelivery.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Table,
|
||||||
|
BelongsTo,
|
||||||
|
ForeignKey,
|
||||||
|
NotEmpty,
|
||||||
|
DataType,
|
||||||
|
IsIn,
|
||||||
|
} from "sequelize-typescript";
|
||||||
|
import WebhookSubscription from "./WebhookSubscription";
|
||||||
|
import IdModel from "./base/IdModel";
|
||||||
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: "webhook_deliveries",
|
||||||
|
modelName: "webhook_delivery",
|
||||||
|
})
|
||||||
|
@Fix
|
||||||
|
class WebhookDelivery extends IdModel {
|
||||||
|
@NotEmpty
|
||||||
|
@IsIn([["pending", "success", "failed"]])
|
||||||
|
@Column(DataType.STRING)
|
||||||
|
status: "pending" | "success" | "failed";
|
||||||
|
|
||||||
|
@Column(DataType.INTEGER)
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@Column(DataType.JSONB)
|
||||||
|
requestBody: unknown;
|
||||||
|
|
||||||
|
@Column(DataType.JSONB)
|
||||||
|
requestHeaders: Record<string, string>;
|
||||||
|
|
||||||
|
@Column(DataType.TEXT)
|
||||||
|
responseBody: string;
|
||||||
|
|
||||||
|
@Column(DataType.JSONB)
|
||||||
|
responseHeaders: Record<string, string>;
|
||||||
|
|
||||||
|
@Column(DataType.DATE)
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// associations
|
||||||
|
|
||||||
|
@BelongsTo(() => WebhookSubscription, "webhookSubscriptionId")
|
||||||
|
webhookSubscription: WebhookSubscription;
|
||||||
|
|
||||||
|
@ForeignKey(() => WebhookSubscription)
|
||||||
|
@Column
|
||||||
|
webhookSubscriptionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookDelivery;
|
||||||
70
server/models/WebhookSubscription.ts
Normal file
70
server/models/WebhookSubscription.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { bool } from "aws-sdk/clients/signer";
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Table,
|
||||||
|
BelongsTo,
|
||||||
|
ForeignKey,
|
||||||
|
NotEmpty,
|
||||||
|
DataType,
|
||||||
|
IsUrl,
|
||||||
|
} from "sequelize-typescript";
|
||||||
|
import { Event } from "@server/types";
|
||||||
|
import Team from "./Team";
|
||||||
|
import User from "./User";
|
||||||
|
import IdModel from "./base/IdModel";
|
||||||
|
import Fix from "./decorators/Fix";
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: "webhook_subscriptions",
|
||||||
|
modelName: "webhook_subscription",
|
||||||
|
})
|
||||||
|
@Fix
|
||||||
|
class WebhookSubscription extends IdModel {
|
||||||
|
@NotEmpty
|
||||||
|
@Column
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsUrl
|
||||||
|
@NotEmpty
|
||||||
|
@Column
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@Column(DataType.ARRAY(DataType.STRING))
|
||||||
|
events: string[];
|
||||||
|
|
||||||
|
// associations
|
||||||
|
|
||||||
|
@BelongsTo(() => User, "createdById")
|
||||||
|
createdBy: User;
|
||||||
|
|
||||||
|
@ForeignKey(() => User)
|
||||||
|
@Column
|
||||||
|
createdById: string;
|
||||||
|
|
||||||
|
@BelongsTo(() => Team, "teamId")
|
||||||
|
team: Team;
|
||||||
|
|
||||||
|
@ForeignKey(() => Team)
|
||||||
|
@Column
|
||||||
|
teamId: string;
|
||||||
|
|
||||||
|
// methods
|
||||||
|
validForEvent = (event: Event): bool => {
|
||||||
|
if (this.events.length === 1 && this.events[0] === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of this.events) {
|
||||||
|
if (e === event.name || event.name.startsWith(e + ".")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookSubscription;
|
||||||
@@ -49,3 +49,7 @@ export { default as User } from "./User";
|
|||||||
export { default as UserAuthentication } from "./UserAuthentication";
|
export { default as UserAuthentication } from "./UserAuthentication";
|
||||||
|
|
||||||
export { default as View } from "./View";
|
export { default as View } from "./View";
|
||||||
|
|
||||||
|
export { default as WebhookSubscription } from "./WebhookSubscription";
|
||||||
|
|
||||||
|
export { default as WebhookDelivery } from "./WebhookDelivery";
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import "./star";
|
|||||||
import "./user";
|
import "./user";
|
||||||
import "./team";
|
import "./team";
|
||||||
import "./group";
|
import "./group";
|
||||||
|
import "./webhookSubscription";
|
||||||
|
|
||||||
type Policy = Record<string, boolean>;
|
type Policy = Record<string, boolean>;
|
||||||
|
|
||||||
|
|||||||
35
server/policies/webhookSubscription.ts
Normal file
35
server/policies/webhookSubscription.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { User, Team, WebhookSubscription } from "@server/models";
|
||||||
|
import { allow } from "./cancan";
|
||||||
|
|
||||||
|
allow(User, "listWebhookSubscription", Team, (user, team) => {
|
||||||
|
if (!team || user.isViewer || user.teamId !== team.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.isAdmin;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, "createWebhookSubscription", Team, (user, team) => {
|
||||||
|
if (!team || user.isViewer || user.teamId !== team.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.isAdmin;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(
|
||||||
|
User,
|
||||||
|
["read", "update", "delete"],
|
||||||
|
WebhookSubscription,
|
||||||
|
(user, webhook): boolean => {
|
||||||
|
if (!user || !webhook) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.teamId === webhook.teamId;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -5,14 +5,17 @@ type GroupMembership = {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
user: ReturnType<typeof presentUser>;
|
user?: ReturnType<typeof presentUser>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (membership: GroupUser): GroupMembership => {
|
export default (
|
||||||
|
membership: GroupUser,
|
||||||
|
options?: { includeUser: boolean }
|
||||||
|
): GroupMembership => {
|
||||||
return {
|
return {
|
||||||
id: `${membership.userId}-${membership.groupId}`,
|
id: `${membership.userId}-${membership.groupId}`,
|
||||||
userId: membership.userId,
|
userId: membership.userId,
|
||||||
groupId: membership.groupId,
|
groupId: membership.groupId,
|
||||||
user: presentUser(membership.user),
|
user: options?.includeUser ? presentUser(membership.user) : undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import presentStar from "./star";
|
|||||||
import presentTeam from "./team";
|
import presentTeam from "./team";
|
||||||
import presentUser from "./user";
|
import presentUser from "./user";
|
||||||
import presentView from "./view";
|
import presentView from "./view";
|
||||||
|
import presentWebhook from "./webhook";
|
||||||
|
import presentWebhookSubscription from "./webhookSubscription";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
presentApiKey,
|
presentApiKey,
|
||||||
@@ -44,4 +46,6 @@ export {
|
|||||||
presentPolicies,
|
presentPolicies,
|
||||||
presentGroupMembership,
|
presentGroupMembership,
|
||||||
presentCollectionGroupMembership,
|
presentCollectionGroupMembership,
|
||||||
|
presentWebhook,
|
||||||
|
presentWebhookSubscription,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ type UserPresentation = {
|
|||||||
language?: string;
|
language?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (
|
export default (user: User, options: Options = {}): UserPresentation => {
|
||||||
user: User,
|
|
||||||
options: Options = {}
|
|
||||||
): UserPresentation | null | undefined => {
|
|
||||||
const userData: UserPresentation = {
|
const userData: UserPresentation = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|||||||
38
server/presenters/webhook.ts
Normal file
38
server/presenters/webhook.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { WebhookDelivery } from "@server/models";
|
||||||
|
import { Event } from "@server/types";
|
||||||
|
|
||||||
|
export interface WebhookPayload {
|
||||||
|
model: Record<string, unknown> | null;
|
||||||
|
id: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookProps {
|
||||||
|
event: Event;
|
||||||
|
delivery: WebhookDelivery;
|
||||||
|
payload: WebhookPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookPresentation {
|
||||||
|
id: string;
|
||||||
|
actorId: string;
|
||||||
|
webhookSubscriptionId: string;
|
||||||
|
event: string;
|
||||||
|
payload: WebhookPayload;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function present({
|
||||||
|
event,
|
||||||
|
delivery,
|
||||||
|
payload,
|
||||||
|
}: WebhookProps): WebhookPresentation {
|
||||||
|
return {
|
||||||
|
id: delivery.id,
|
||||||
|
actorId: event.actorId,
|
||||||
|
webhookSubscriptionId: delivery.webhookSubscriptionId,
|
||||||
|
createdAt: delivery.createdAt,
|
||||||
|
event: event.name,
|
||||||
|
payload: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
server/presenters/webhookSubscription.ts
Normal file
13
server/presenters/webhookSubscription.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { WebhookSubscription } from "@server/models";
|
||||||
|
|
||||||
|
export default function present(webhook: WebhookSubscription) {
|
||||||
|
return {
|
||||||
|
id: webhook.id,
|
||||||
|
name: webhook.name,
|
||||||
|
url: webhook.url,
|
||||||
|
events: webhook.events,
|
||||||
|
enabled: webhook.enabled,
|
||||||
|
createdAt: webhook.createdAt,
|
||||||
|
updatedAt: webhook.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Event } from "@server/types";
|
import { Event } from "@server/types";
|
||||||
|
|
||||||
export default abstract class BaseProcessor {
|
export default abstract class BaseProcessor {
|
||||||
static applicableEvents: (Event["name"] | "*")[] = [];
|
static applicableEvents: Event["name"][] | ["*"] = [];
|
||||||
|
|
||||||
public abstract perform(event: Event): Promise<void>;
|
public abstract perform(event: Event): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ describe("revisions.create", () => {
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
collectionId: document.collectionId,
|
collectionId: document.collectionId,
|
||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
|
actorId: collaborator.id,
|
||||||
|
modelId: document.id,
|
||||||
|
ip,
|
||||||
});
|
});
|
||||||
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
|
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -142,6 +145,9 @@ describe("revisions.create", () => {
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
collectionId: document.collectionId,
|
collectionId: document.collectionId,
|
||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
|
actorId: collaborator.id,
|
||||||
|
modelId: document.id,
|
||||||
|
ip,
|
||||||
});
|
});
|
||||||
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
|
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -163,6 +169,9 @@ describe("revisions.create", () => {
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
collectionId: document.collectionId,
|
collectionId: document.collectionId,
|
||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
modelId: document.id,
|
||||||
|
ip,
|
||||||
});
|
});
|
||||||
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
|
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
96
server/queues/processors/WebhookProcessor.test.ts
Normal file
96
server/queues/processors/WebhookProcessor.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { buildUser, buildWebhookSubscription } from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
import { UserEvent } from "@server/types";
|
||||||
|
import DeliverWebhookTask from "../tasks/DeliverWebhookTask";
|
||||||
|
import WebhookProcessor from "./WebhookProcessor";
|
||||||
|
|
||||||
|
jest.mock("@server/queues/tasks/DeliverWebhookTask");
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
describe("WebhookProcessor", () => {
|
||||||
|
test("it schedules a delivery for the event", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
const processor = new WebhookProcessor();
|
||||||
|
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.signin",
|
||||||
|
userId: signedInUser.id,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
await processor.perform(event);
|
||||||
|
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||||
|
event,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not schedule a delivery when not subscribed to event", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["users.create"],
|
||||||
|
});
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
const processor = new WebhookProcessor();
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.signin",
|
||||||
|
userId: signedInUser.id,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
await processor.perform(event);
|
||||||
|
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it schedules a delivery for the event for each subscription", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
const subscriptionTwo = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
const processor = new WebhookProcessor();
|
||||||
|
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.signin",
|
||||||
|
userId: signedInUser.id,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
await processor.perform(event);
|
||||||
|
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalled();
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalledTimes(2);
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||||
|
event,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
});
|
||||||
|
expect(DeliverWebhookTask.schedule).toHaveBeenCalledWith({
|
||||||
|
event,
|
||||||
|
subscriptionId: subscriptionTwo.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
server/queues/processors/WebhookProcessor.ts
Normal file
27
server/queues/processors/WebhookProcessor.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { WebhookSubscription } from "@server/models";
|
||||||
|
import { Event } from "@server/types";
|
||||||
|
import DeliverWebhookTask from "../tasks/DeliverWebhookTask";
|
||||||
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
|
export default class WebhookProcessor extends BaseProcessor {
|
||||||
|
static applicableEvents: ["*"] = ["*"];
|
||||||
|
|
||||||
|
async perform(event: Event) {
|
||||||
|
const webhookSubscriptions = await WebhookSubscription.findAll({
|
||||||
|
where: {
|
||||||
|
enabled: true,
|
||||||
|
teamId: event.teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const applicableSubscriptions = webhookSubscriptions.filter((webhook) =>
|
||||||
|
webhook.validForEvent(event)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
applicableSubscriptions.map((subscription) =>
|
||||||
|
DeliverWebhookTask.schedule({ event, subscriptionId: subscription.id })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
server/queues/tasks/CleanupWebhookDeliveriesTask.test.ts
Normal file
33
server/queues/tasks/CleanupWebhookDeliveriesTask.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { subDays } from "date-fns";
|
||||||
|
import { WebhookDelivery } from "@server/models";
|
||||||
|
import { buildWebhookDelivery } from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
import CleanupWebhookDeliveriesTask from "./CleanupWebhookDeliveriesTask";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
const deliveryExists = async (delivery: WebhookDelivery) => {
|
||||||
|
const results = await WebhookDelivery.findOne({ where: { id: delivery.id } });
|
||||||
|
return !!results;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CleanupWebookDeliveriesTask", () => {
|
||||||
|
it("should delete Webhook Deliveries older than 1 week", async () => {
|
||||||
|
const brandNewWebhookDelivery = await buildWebhookDelivery({
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
const newishWebhookDelivery = await buildWebhookDelivery({
|
||||||
|
createdAt: subDays(new Date(), 5),
|
||||||
|
});
|
||||||
|
const oldWebhookDelivery = await buildWebhookDelivery({
|
||||||
|
createdAt: subDays(new Date(), 8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = new CleanupWebhookDeliveriesTask();
|
||||||
|
await task.perform();
|
||||||
|
|
||||||
|
expect(await deliveryExists(brandNewWebhookDelivery)).toBe(true);
|
||||||
|
expect(await deliveryExists(newishWebhookDelivery)).toBe(true);
|
||||||
|
expect(await deliveryExists(oldWebhookDelivery)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
server/queues/tasks/CleanupWebhookDeliveriesTask.ts
Normal file
28
server/queues/tasks/CleanupWebhookDeliveriesTask.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { subDays } from "date-fns";
|
||||||
|
import { Op } from "sequelize";
|
||||||
|
import Logger from "@server/logging/Logger";
|
||||||
|
import { WebhookDelivery } from "@server/models";
|
||||||
|
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||||
|
|
||||||
|
type Props = void;
|
||||||
|
|
||||||
|
export default class CleanupWebhookDeliveriesTask extends BaseTask<Props> {
|
||||||
|
public async perform(_: Props) {
|
||||||
|
Logger.info("task", `Deleting WebhookDeliveries older than one week…`);
|
||||||
|
const count = await WebhookDelivery.unscoped().destroy({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
[Op.lt]: subDays(new Date(), 7),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Logger.info("task", `${count} old WebhookDeliveries deleted.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get options() {
|
||||||
|
return {
|
||||||
|
attempts: 1,
|
||||||
|
priority: TaskPriority.Background,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
202
server/queues/tasks/DeliverWebhookTask.test.ts
Normal file
202
server/queues/tasks/DeliverWebhookTask.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import fetchMock from "jest-fetch-mock";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { WebhookDelivery } from "@server/models";
|
||||||
|
import {
|
||||||
|
buildUser,
|
||||||
|
buildWebhookDelivery,
|
||||||
|
buildWebhookSubscription,
|
||||||
|
} from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
import { UserEvent } from "@server/types";
|
||||||
|
import DeliverWebhookTask from "./DeliverWebhookTask";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
fetchMock.enableMocks();
|
||||||
|
|
||||||
|
describe("DeliverWebhookTask", () => {
|
||||||
|
test("should hit the subscription url and record a delivery", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
const processor = new DeliverWebhookTask();
|
||||||
|
|
||||||
|
fetchMock.mockResponse("SUCCESS", { status: 200 });
|
||||||
|
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.signin",
|
||||||
|
userId: signedInUser.id,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
await processor.perform({
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://example.com",
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
const parsedBody = JSON.parse(
|
||||||
|
fetchMock.mock.calls[0]![1]!.body!.toString()
|
||||||
|
);
|
||||||
|
expect(parsedBody.webhookSubscriptionId).toBe(subscription.id);
|
||||||
|
expect(parsedBody.event).toBe("users.signin");
|
||||||
|
expect(parsedBody.payload.id).toBe(signedInUser.id);
|
||||||
|
expect(parsedBody.payload.model).toBeDefined();
|
||||||
|
|
||||||
|
const deliveries = await WebhookDelivery.findAll({
|
||||||
|
where: { webhookSubscriptionId: subscription.id },
|
||||||
|
});
|
||||||
|
expect(deliveries.length).toBe(1);
|
||||||
|
|
||||||
|
const delivery = deliveries[0];
|
||||||
|
expect(delivery.status).toBe("success");
|
||||||
|
expect(delivery.statusCode).toBe(200);
|
||||||
|
expect(delivery.responseBody).toEqual("SUCCESS");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should hit the subscription url when the eventing model doesn't exist", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
const deletedUserId = uuidv4();
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
|
||||||
|
const task = new DeliverWebhookTask();
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.delete",
|
||||||
|
userId: deletedUserId,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
await task.perform({
|
||||||
|
event,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://example.com",
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
const parsedBody = JSON.parse(
|
||||||
|
fetchMock.mock.calls[0]![1]!.body!.toString()
|
||||||
|
);
|
||||||
|
expect(parsedBody.webhookSubscriptionId).toBe(subscription.id);
|
||||||
|
expect(parsedBody.event).toBe("users.delete");
|
||||||
|
expect(parsedBody.payload.id).toBe(deletedUserId);
|
||||||
|
|
||||||
|
const deliveries = await WebhookDelivery.findAll({
|
||||||
|
where: { webhookSubscriptionId: subscription.id },
|
||||||
|
});
|
||||||
|
expect(deliveries.length).toBe(1);
|
||||||
|
|
||||||
|
const delivery = deliveries[0];
|
||||||
|
expect(delivery.status).toBe("success");
|
||||||
|
expect(delivery.statusCode).toBe(200);
|
||||||
|
expect(delivery.responseBody).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should mark delivery as failed if post fails", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResponse("FAILED", { status: 500 });
|
||||||
|
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
const task = new DeliverWebhookTask();
|
||||||
|
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.signin",
|
||||||
|
userId: signedInUser.id,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
await task.perform({
|
||||||
|
event,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await subscription.reload();
|
||||||
|
|
||||||
|
expect(subscription.enabled).toBe(true);
|
||||||
|
|
||||||
|
const deliveries = await WebhookDelivery.findAll({
|
||||||
|
where: { webhookSubscriptionId: subscription.id },
|
||||||
|
});
|
||||||
|
expect(deliveries.length).toBe(1);
|
||||||
|
|
||||||
|
const delivery = deliveries[0];
|
||||||
|
expect(delivery.status).toBe("failed");
|
||||||
|
expect(delivery.statusCode).toBe(500);
|
||||||
|
expect(delivery.responseBody).toBeDefined();
|
||||||
|
expect(delivery.responseBody).toEqual("FAILED");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should disable the subscription if past deliveries failed", async () => {
|
||||||
|
const subscription = await buildWebhookSubscription({
|
||||||
|
url: "http://example.com",
|
||||||
|
events: ["*"],
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
await buildWebhookDelivery({
|
||||||
|
webhookSubscriptionId: subscription.id,
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchMock.mockResponse(JSON.stringify({ message: "Failure" }), {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedInUser = await buildUser({ teamId: subscription.teamId });
|
||||||
|
const task = new DeliverWebhookTask();
|
||||||
|
|
||||||
|
const event: UserEvent = {
|
||||||
|
name: "users.signin",
|
||||||
|
userId: signedInUser.id,
|
||||||
|
teamId: subscription.teamId,
|
||||||
|
actorId: signedInUser.id,
|
||||||
|
ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
await task.perform({
|
||||||
|
event,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await subscription.reload();
|
||||||
|
|
||||||
|
expect(subscription.enabled).toBe(false);
|
||||||
|
|
||||||
|
const deliveries = await WebhookDelivery.findAll({
|
||||||
|
where: { webhookSubscriptionId: subscription.id },
|
||||||
|
order: [["createdAt", "DESC"]],
|
||||||
|
});
|
||||||
|
expect(deliveries.length).toBe(26);
|
||||||
|
|
||||||
|
const delivery = deliveries[0];
|
||||||
|
expect(delivery.status).toBe("failed");
|
||||||
|
expect(delivery.statusCode).toBe(500);
|
||||||
|
expect(delivery.responseBody).toEqual('{"message":"Failure"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
560
server/queues/tasks/DeliverWebhookTask.ts
Normal file
560
server/queues/tasks/DeliverWebhookTask.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import invariant from "invariant";
|
||||||
|
import env from "@server/env";
|
||||||
|
import Logger from "@server/logging/Logger";
|
||||||
|
import {
|
||||||
|
Collection,
|
||||||
|
FileOperation,
|
||||||
|
Group,
|
||||||
|
Integration,
|
||||||
|
Pin,
|
||||||
|
Star,
|
||||||
|
Team,
|
||||||
|
WebhookDelivery,
|
||||||
|
WebhookSubscription,
|
||||||
|
Document,
|
||||||
|
User,
|
||||||
|
Revision,
|
||||||
|
View,
|
||||||
|
Share,
|
||||||
|
CollectionUser,
|
||||||
|
CollectionGroup,
|
||||||
|
GroupUser,
|
||||||
|
} from "@server/models";
|
||||||
|
import {
|
||||||
|
presentCollection,
|
||||||
|
presentDocument,
|
||||||
|
presentRevision,
|
||||||
|
presentFileOperation,
|
||||||
|
presentGroup,
|
||||||
|
presentIntegration,
|
||||||
|
presentPin,
|
||||||
|
presentStar,
|
||||||
|
presentTeam,
|
||||||
|
presentUser,
|
||||||
|
presentWebhook,
|
||||||
|
presentWebhookSubscription,
|
||||||
|
presentView,
|
||||||
|
presentShare,
|
||||||
|
presentMembership,
|
||||||
|
presentGroupMembership,
|
||||||
|
presentCollectionGroupMembership,
|
||||||
|
} from "@server/presenters";
|
||||||
|
import { WebhookPayload } from "@server/presenters/webhook";
|
||||||
|
import {
|
||||||
|
CollectionEvent,
|
||||||
|
CollectionGroupEvent,
|
||||||
|
CollectionUserEvent,
|
||||||
|
DocumentEvent,
|
||||||
|
Event,
|
||||||
|
FileOperationEvent,
|
||||||
|
GroupEvent,
|
||||||
|
GroupUserEvent,
|
||||||
|
IntegrationEvent,
|
||||||
|
PinEvent,
|
||||||
|
RevisionEvent,
|
||||||
|
ShareEvent,
|
||||||
|
StarEvent,
|
||||||
|
TeamEvent,
|
||||||
|
UserEvent,
|
||||||
|
ViewEvent,
|
||||||
|
WebhookSubscriptionEvent,
|
||||||
|
} from "@server/types";
|
||||||
|
import BaseTask from "./BaseTask";
|
||||||
|
|
||||||
|
function assertUnreachable(event: never) {
|
||||||
|
Logger.warn(`DeliverWebhookTask did not handle ${(event as any).name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
subscriptionId: string;
|
||||||
|
event: Event;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||||
|
public async perform({ subscriptionId, event }: Props) {
|
||||||
|
const subscription = await WebhookSubscription.findByPk(subscriptionId);
|
||||||
|
invariant(subscription, "Subscription not found");
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
"task",
|
||||||
|
`DeliverWebhookTask: ${event.name} for ${subscription.name}`
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (event.name) {
|
||||||
|
case "api_keys.create":
|
||||||
|
case "api_keys.delete":
|
||||||
|
// Ignored
|
||||||
|
return;
|
||||||
|
case "users.create":
|
||||||
|
case "users.signin":
|
||||||
|
case "users.signout":
|
||||||
|
case "users.update":
|
||||||
|
case "users.suspend":
|
||||||
|
case "users.activate":
|
||||||
|
case "users.delete":
|
||||||
|
case "users.invite":
|
||||||
|
await this.handleUserEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "documents.create":
|
||||||
|
case "documents.publish":
|
||||||
|
case "documents.unpublish":
|
||||||
|
case "documents.delete":
|
||||||
|
case "documents.permanent_delete":
|
||||||
|
case "documents.archive":
|
||||||
|
case "documents.unarchive":
|
||||||
|
case "documents.restore":
|
||||||
|
case "documents.star":
|
||||||
|
case "documents.unstar":
|
||||||
|
case "documents.move":
|
||||||
|
case "documents.update":
|
||||||
|
case "documents.title_change":
|
||||||
|
await this.handleDocumentEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "documents.update.delayed":
|
||||||
|
case "documents.update.debounced":
|
||||||
|
// Ignored
|
||||||
|
return;
|
||||||
|
case "revisions.create":
|
||||||
|
await this.handleRevisionEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "fileOperations.create":
|
||||||
|
case "fileOperations.update":
|
||||||
|
case "fileOperation.delete":
|
||||||
|
await this.handleFileOperationEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "collections.create":
|
||||||
|
case "collections.update":
|
||||||
|
case "collections.delete":
|
||||||
|
case "collections.move":
|
||||||
|
case "collections.permission_changed":
|
||||||
|
await this.handleCollectionEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "collections.add_user":
|
||||||
|
case "collections.remove_user":
|
||||||
|
await this.handleCollectionUserEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "collections.add_group":
|
||||||
|
case "collections.remove_group":
|
||||||
|
await this.handleCollectionGroupEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "groups.create":
|
||||||
|
case "groups.update":
|
||||||
|
case "groups.delete":
|
||||||
|
await this.handleGroupEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "groups.add_user":
|
||||||
|
case "groups.remove_user":
|
||||||
|
await this.handleGroupUserEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "integrations.create":
|
||||||
|
case "integrations.update":
|
||||||
|
await this.handleIntegrationEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "teams.update":
|
||||||
|
await this.handleTeamEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "pins.create":
|
||||||
|
case "pins.update":
|
||||||
|
case "pins.delete":
|
||||||
|
await this.handlePinEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "stars.create":
|
||||||
|
case "stars.update":
|
||||||
|
case "stars.delete":
|
||||||
|
await this.handleStarEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "shares.create":
|
||||||
|
case "shares.update":
|
||||||
|
case "shares.revoke":
|
||||||
|
await this.handleShareEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "webhook_subscriptions.create":
|
||||||
|
case "webhook_subscriptions.delete":
|
||||||
|
case "webhook_subscriptions.update":
|
||||||
|
await this.handleWebhookSubscriptionEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
case "views.create":
|
||||||
|
await this.handleViewEvent(subscription, event);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
assertUnreachable(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWebhookSubscriptionEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: WebhookSubscriptionEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await WebhookSubscription.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentWebhookSubscription(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleViewEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: ViewEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await View.scope("withUser").findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentView(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStarEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: StarEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Star.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentStar(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleShareEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: ShareEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Share.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentShare(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePinEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: PinEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Pin.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentPin(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTeamEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: TeamEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Team.scope("withDomains").findByPk(event.teamId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.teamId,
|
||||||
|
model: model && presentTeam(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleIntegrationEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: IntegrationEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Integration.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentIntegration(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGroupEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: GroupEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Group.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentGroup(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGroupUserEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: GroupUserEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await GroupUser.scope(["withUser", "withGroup"]).findOne({
|
||||||
|
where: {
|
||||||
|
groupId: event.modelId,
|
||||||
|
userId: event.userId,
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: `${event.userId}-${event.modelId}`,
|
||||||
|
model: model && presentGroupMembership(model),
|
||||||
|
group: model && presentGroup(model.group),
|
||||||
|
user: model && presentUser(model.user),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCollectionEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: CollectionEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Collection.findByPk(event.collectionId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.collectionId,
|
||||||
|
model: model && presentCollection(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCollectionUserEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: CollectionUserEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await CollectionUser.scope([
|
||||||
|
"withUser",
|
||||||
|
"withCollection",
|
||||||
|
]).findOne({
|
||||||
|
where: {
|
||||||
|
collectionId: event.collectionId,
|
||||||
|
userId: event.userId,
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: `${event.userId}-${event.collectionId}`,
|
||||||
|
model: model && presentMembership(model),
|
||||||
|
collection: model && presentCollection(model.collection),
|
||||||
|
user: model && presentUser(model.user),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCollectionGroupEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: CollectionGroupEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await CollectionGroup.scope([
|
||||||
|
"withGroup",
|
||||||
|
"withCollection",
|
||||||
|
]).findOne({
|
||||||
|
where: {
|
||||||
|
collectionId: event.collectionId,
|
||||||
|
groupId: event.modelId,
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: `${event.modelId}-${event.collectionId}`,
|
||||||
|
model: model && presentCollectionGroupMembership(model),
|
||||||
|
collection: model && presentCollection(model.collection),
|
||||||
|
group: model && presentGroup(model.group),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFileOperationEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: FileOperationEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await FileOperation.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && presentFileOperation(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDocumentEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: DocumentEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Document.findByPk(event.documentId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.documentId,
|
||||||
|
model: model && (await presentDocument(model)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRevisionEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: RevisionEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await Revision.findByPk(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.modelId,
|
||||||
|
model: model && (await presentRevision(model)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUserEvent(
|
||||||
|
subscription: WebhookSubscription,
|
||||||
|
event: UserEvent
|
||||||
|
): Promise<void> {
|
||||||
|
const model = await User.findByPk(event.userId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload: {
|
||||||
|
id: event.userId,
|
||||||
|
model: model && presentUser(model),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendWebhook({
|
||||||
|
event,
|
||||||
|
subscription,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
event: Event;
|
||||||
|
subscription: WebhookSubscription;
|
||||||
|
payload: WebhookPayload;
|
||||||
|
}) {
|
||||||
|
const delivery = await WebhookDelivery.create({
|
||||||
|
webhookSubscriptionId: subscription.id,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
let response, requestBody, requestHeaders, status;
|
||||||
|
try {
|
||||||
|
requestBody = presentWebhook({
|
||||||
|
event,
|
||||||
|
delivery,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
requestHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"user-agent": `Outline-Webhooks${env.VERSION ? `/${env.VERSION}` : ""}`,
|
||||||
|
};
|
||||||
|
response = await fetch(subscription.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
status = response.ok ? "success" : "failed";
|
||||||
|
} catch (err) {
|
||||||
|
status = "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
await delivery.update({
|
||||||
|
status,
|
||||||
|
statusCode: response ? response.status : null,
|
||||||
|
requestBody,
|
||||||
|
requestHeaders,
|
||||||
|
responseBody: response ? await response.text() : "",
|
||||||
|
responseHeaders: response
|
||||||
|
? Object.fromEntries(response.headers.entries())
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && !response.ok) {
|
||||||
|
const recentDeliveries = await WebhookDelivery.findAll({
|
||||||
|
where: {
|
||||||
|
webhookSubscriptionId: subscription.id,
|
||||||
|
},
|
||||||
|
order: [["createdAt", "DESC"]],
|
||||||
|
limit: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allFailed = recentDeliveries.every(
|
||||||
|
(delivery) => delivery.status === "failed"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentDeliveries.length === 25 && allFailed) {
|
||||||
|
await subscription.update({ enabled: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { parseDomain } from "@shared/utils/domains";
|
|||||||
import { sequelize } from "@server/database/sequelize";
|
import { sequelize } from "@server/database/sequelize";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { Event, Team, TeamDomain } from "@server/models";
|
import { Event, Team } from "@server/models";
|
||||||
import { presentUser, presentTeam, presentPolicies } from "@server/presenters";
|
import { presentUser, presentTeam, presentPolicies } from "@server/presenters";
|
||||||
import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask";
|
import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask";
|
||||||
import providers from "../auth/providers";
|
import providers from "../auth/providers";
|
||||||
@@ -108,9 +108,7 @@ router.post("auth.config", async (ctx) => {
|
|||||||
|
|
||||||
router.post("auth.info", auth(), async (ctx) => {
|
router.post("auth.info", auth(), async (ctx) => {
|
||||||
const { user } = ctx.state;
|
const { user } = ctx.state;
|
||||||
const team = await Team.findByPk(user.teamId, {
|
const team = await Team.scope("withDomains").findByPk(user.teamId);
|
||||||
include: [{ model: TeamDomain }],
|
|
||||||
});
|
|
||||||
invariant(team, "Team not found");
|
invariant(team, "Team not found");
|
||||||
|
|
||||||
await ValidateSSOAccessTask.schedule({ userId: user.id });
|
await ValidateSSOAccessTask.schedule({ userId: user.id });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthenticationError } from "@server/errors";
|
|||||||
import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask";
|
import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask";
|
||||||
import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask";
|
import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask";
|
||||||
import CleanupExpiredFileOperationsTask from "@server/queues/tasks/CleanupExpiredFileOperationsTask";
|
import CleanupExpiredFileOperationsTask from "@server/queues/tasks/CleanupExpiredFileOperationsTask";
|
||||||
|
import CleanupWebhookDeliveriesTask from "@server/queues/tasks/CleanupWebhookDeliveriesTask";
|
||||||
import InviteReminderTask from "@server/queues/tasks/InviteReminderTask";
|
import InviteReminderTask from "@server/queues/tasks/InviteReminderTask";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -22,6 +23,8 @@ const cronHandler = async (ctx: Context) => {
|
|||||||
|
|
||||||
await CleanupDeletedTeamsTask.schedule({ limit });
|
await CleanupDeletedTeamsTask.schedule({ limit });
|
||||||
|
|
||||||
|
await CleanupWebhookDeliveriesTask.schedule({ limit });
|
||||||
|
|
||||||
await InviteReminderTask.schedule();
|
await InviteReminderTask.schedule();
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ router.post("groups.list", auth(), pagination(), async (ctx) => {
|
|||||||
.slice(0, MAX_AVATAR_DISPLAY)
|
.slice(0, MAX_AVATAR_DISPLAY)
|
||||||
)
|
)
|
||||||
.flat()
|
.flat()
|
||||||
.map(presentGroupMembership),
|
.map((membership) =>
|
||||||
|
presentGroupMembership(membership, { includeUser: true })
|
||||||
|
),
|
||||||
},
|
},
|
||||||
policies: presentPolicies(user, groups),
|
policies: presentPolicies(user, groups),
|
||||||
};
|
};
|
||||||
@@ -191,7 +193,9 @@ router.post("groups.memberships", auth(), pagination(), async (ctx) => {
|
|||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data: {
|
data: {
|
||||||
groupMemberships: memberships.map(presentGroupMembership),
|
groupMemberships: memberships.map((membership) =>
|
||||||
|
presentGroupMembership(membership, { includeUser: true })
|
||||||
|
),
|
||||||
users: memberships.map((membership) => presentUser(membership.user)),
|
users: memberships.map((membership) => presentUser(membership.user)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -250,7 +254,9 @@ router.post("groups.add_user", auth(), async (ctx) => {
|
|||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: {
|
data: {
|
||||||
users: [presentUser(user)],
|
users: [presentUser(user)],
|
||||||
groupMemberships: [presentGroupMembership(membership)],
|
groupMemberships: [
|
||||||
|
presentGroupMembership(membership, { includeUser: true }),
|
||||||
|
],
|
||||||
groups: [presentGroup(group)],
|
groups: [presentGroup(group)],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import stars from "./stars";
|
|||||||
import team from "./team";
|
import team from "./team";
|
||||||
import users from "./users";
|
import users from "./users";
|
||||||
import views from "./views";
|
import views from "./views";
|
||||||
|
import webhookSubscriptions from "./webhookSubscriptions";
|
||||||
|
|
||||||
const api = new Koa();
|
const api = new Koa();
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -67,6 +68,7 @@ router.use("/", attachments.routes());
|
|||||||
router.use("/", utils.routes());
|
router.use("/", utils.routes());
|
||||||
router.use("/", groups.routes());
|
router.use("/", groups.routes());
|
||||||
router.use("/", fileOperationsRoute.routes());
|
router.use("/", fileOperationsRoute.routes());
|
||||||
|
router.use("/", webhookSubscriptions.routes());
|
||||||
|
|
||||||
router.post("*", (ctx) => {
|
router.post("*", (ctx) => {
|
||||||
ctx.throw(NotFoundError("Endpoint not found"));
|
ctx.throw(NotFoundError("Endpoint not found"));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ router.post("views.create", auth(), async (ctx) => {
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
collectionId: document.collectionId,
|
collectionId: document.collectionId,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
|
modelId: view.id,
|
||||||
data: {
|
data: {
|
||||||
title: document.title,
|
title: document.title,
|
||||||
},
|
},
|
||||||
|
|||||||
137
server/routes/api/webhookSubscriptions.ts
Normal file
137
server/routes/api/webhookSubscriptions.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import Router from "koa-router";
|
||||||
|
import { compact } from "lodash";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
|
import auth from "@server/middlewares/authentication";
|
||||||
|
import { WebhookSubscription, Event } from "@server/models";
|
||||||
|
import { authorize } from "@server/policies";
|
||||||
|
import { presentWebhookSubscription } from "@server/presenters";
|
||||||
|
import { WebhookSubscriptionEvent } from "@server/types";
|
||||||
|
import { assertArray, assertPresent, assertUuid } from "@server/validation";
|
||||||
|
import pagination from "./middlewares/pagination";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post("webhookSubscriptions.list", auth(), pagination(), async (ctx) => {
|
||||||
|
const { user } = ctx.state;
|
||||||
|
authorize(user, "listWebhookSubscription", user.team);
|
||||||
|
const webhooks = await WebhookSubscription.findAll({
|
||||||
|
where: {
|
||||||
|
teamId: user.teamId,
|
||||||
|
},
|
||||||
|
order: [["createdAt", "DESC"]],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data: webhooks.map(presentWebhookSubscription),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("webhookSubscriptions.create", auth(), async (ctx) => {
|
||||||
|
const { user } = ctx.state;
|
||||||
|
authorize(user, "createWebhookSubscription", user.team);
|
||||||
|
|
||||||
|
const { name, url } = ctx.request.body;
|
||||||
|
const events: string[] = compact(ctx.request.body.events);
|
||||||
|
assertPresent(name, "name is required");
|
||||||
|
assertPresent(url, "url is required");
|
||||||
|
assertArray(events, "events is required");
|
||||||
|
if (events.length === 0) {
|
||||||
|
throw ValidationError("events are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookSubscription = await WebhookSubscription.create({
|
||||||
|
name,
|
||||||
|
events,
|
||||||
|
createdById: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
url,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event: WebhookSubscriptionEvent = {
|
||||||
|
name: "webhook_subscriptions.create",
|
||||||
|
modelId: webhookSubscription.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
events,
|
||||||
|
},
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
};
|
||||||
|
await Event.create(event);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentWebhookSubscription(webhookSubscription),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("webhookSubscriptions.delete", auth(), async (ctx) => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
assertUuid(id, "id is required");
|
||||||
|
const { user } = ctx.state;
|
||||||
|
const webhookSubscription = await WebhookSubscription.findByPk(id);
|
||||||
|
|
||||||
|
authorize(user, "delete", webhookSubscription);
|
||||||
|
|
||||||
|
await webhookSubscription.destroy();
|
||||||
|
|
||||||
|
const event: WebhookSubscriptionEvent = {
|
||||||
|
name: "webhook_subscriptions.delete",
|
||||||
|
modelId: webhookSubscription.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
data: {
|
||||||
|
name: webhookSubscription.name,
|
||||||
|
url: webhookSubscription.url,
|
||||||
|
events: webhookSubscription.events,
|
||||||
|
},
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
};
|
||||||
|
await Event.create(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("webhookSubscriptions.update", auth(), async (ctx) => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
assertUuid(id, "id is required");
|
||||||
|
const { user } = ctx.state;
|
||||||
|
|
||||||
|
const { name, url } = ctx.request.body;
|
||||||
|
const events: string[] = compact(ctx.request.body.events);
|
||||||
|
assertPresent(name, "name is required");
|
||||||
|
assertPresent(url, "url is required");
|
||||||
|
assertArray(events, "events is required");
|
||||||
|
if (events.length === 0) {
|
||||||
|
throw ValidationError("events are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookSubscription = await WebhookSubscription.findByPk(id);
|
||||||
|
|
||||||
|
authorize(user, "update", webhookSubscription);
|
||||||
|
|
||||||
|
await webhookSubscription.update({ name, url, events, enabled: true });
|
||||||
|
|
||||||
|
const event: WebhookSubscriptionEvent = {
|
||||||
|
name: "webhook_subscriptions.update",
|
||||||
|
modelId: webhookSubscription.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
data: {
|
||||||
|
name: webhookSubscription.name,
|
||||||
|
url: webhookSubscription.url,
|
||||||
|
events: webhookSubscription.events,
|
||||||
|
},
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
};
|
||||||
|
await Event.create(event);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentWebhookSubscription(webhookSubscription),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
Integration,
|
Integration,
|
||||||
AuthenticationProvider,
|
AuthenticationProvider,
|
||||||
FileOperation,
|
FileOperation,
|
||||||
|
WebhookSubscription,
|
||||||
|
WebhookDelivery,
|
||||||
} from "@server/models";
|
} from "@server/models";
|
||||||
import {
|
import {
|
||||||
FileOperationState,
|
FileOperationState,
|
||||||
@@ -366,3 +368,58 @@ export async function buildAttachment(overrides: Partial<Attachment> = {}) {
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildWebhookSubscription(
|
||||||
|
overrides: Partial<WebhookSubscription> = {}
|
||||||
|
): Promise<WebhookSubscription> {
|
||||||
|
if (!overrides.teamId) {
|
||||||
|
const team = await buildTeam();
|
||||||
|
overrides.teamId = team.id;
|
||||||
|
}
|
||||||
|
if (!overrides.createdById) {
|
||||||
|
const user = await buildUser({
|
||||||
|
teamId: overrides.teamId,
|
||||||
|
});
|
||||||
|
overrides.createdById = user.id;
|
||||||
|
}
|
||||||
|
if (!overrides.name) {
|
||||||
|
overrides.name = "Test Webhook Subscription";
|
||||||
|
}
|
||||||
|
if (!overrides.url) {
|
||||||
|
overrides.url = "https://www.example.com/webhook";
|
||||||
|
}
|
||||||
|
if (!overrides.events) {
|
||||||
|
overrides.events = ["*"];
|
||||||
|
}
|
||||||
|
if (!overrides.enabled) {
|
||||||
|
overrides.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebhookSubscription.create(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildWebhookDelivery(
|
||||||
|
overrides: Partial<WebhookDelivery> = {}
|
||||||
|
): Promise<WebhookDelivery> {
|
||||||
|
if (!overrides.status) {
|
||||||
|
overrides.status = "success";
|
||||||
|
}
|
||||||
|
if (!overrides.statusCode) {
|
||||||
|
overrides.statusCode = 200;
|
||||||
|
}
|
||||||
|
if (!overrides.requestBody) {
|
||||||
|
overrides.requestBody = "{}";
|
||||||
|
}
|
||||||
|
if (!overrides.requestHeaders) {
|
||||||
|
overrides.requestHeaders = {};
|
||||||
|
}
|
||||||
|
if (!overrides.webhookSubscriptionId) {
|
||||||
|
const webhookSubscription = await buildWebhookSubscription();
|
||||||
|
overrides.webhookSubscriptionId = webhookSubscription.id;
|
||||||
|
}
|
||||||
|
if (!overrides.createdAt) {
|
||||||
|
overrides.createdAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebhookDelivery.create(overrides);
|
||||||
|
}
|
||||||
|
|||||||
205
server/types.ts
205
server/types.ts
@@ -1,5 +1,5 @@
|
|||||||
import { Context } from "koa";
|
import { Context } from "koa";
|
||||||
import { FileOperation, User } from "./models";
|
import { FileOperation, Team, User } from "./models";
|
||||||
|
|
||||||
export type ContextWithState = Context & {
|
export type ContextWithState = Context & {
|
||||||
state: {
|
state: {
|
||||||
@@ -9,9 +9,25 @@ export type ContextWithState = Context & {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserEvent =
|
type BaseEvent = {
|
||||||
|
teamId: string;
|
||||||
|
actorId: string;
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiKeyEvent = BaseEvent & {
|
||||||
|
name: "api_keys.create" | "api_keys.delete";
|
||||||
|
modelId: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserEvent = BaseEvent &
|
||||||
|
(
|
||||||
| {
|
| {
|
||||||
name: "users.create" // eslint-disable-line
|
name:
|
||||||
|
| "users.create"
|
||||||
| "users.signin"
|
| "users.signin"
|
||||||
| "users.signout"
|
| "users.signout"
|
||||||
| "users.update"
|
| "users.update"
|
||||||
@@ -19,24 +35,22 @@ export type UserEvent =
|
|||||||
| "users.activate"
|
| "users.activate"
|
||||||
| "users.delete";
|
| "users.delete";
|
||||||
userId: string;
|
userId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
ip: string;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: "users.invite";
|
name: "users.invite";
|
||||||
teamId: string;
|
userId: string;
|
||||||
actorId: string;
|
|
||||||
data: {
|
data: {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
ip: string;
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
export type DocumentEvent =
|
export type DocumentEvent = BaseEvent &
|
||||||
|
(
|
||||||
| {
|
| {
|
||||||
name: "documents.create" // eslint-disable-line
|
name:
|
||||||
|
| "documents.create"
|
||||||
| "documents.publish"
|
| "documents.publish"
|
||||||
| "documents.unpublish"
|
| "documents.unpublish"
|
||||||
| "documents.delete"
|
| "documents.delete"
|
||||||
@@ -48,9 +62,6 @@ export type DocumentEvent =
|
|||||||
| "documents.unstar";
|
| "documents.unstar";
|
||||||
documentId: string;
|
documentId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
ip: string;
|
|
||||||
data: {
|
data: {
|
||||||
title: string;
|
title: string;
|
||||||
source?: "import";
|
source?: "import";
|
||||||
@@ -60,176 +71,179 @@ export type DocumentEvent =
|
|||||||
name: "documents.move";
|
name: "documents.move";
|
||||||
documentId: string;
|
documentId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
data: {
|
data: {
|
||||||
collectionIds: string[];
|
collectionIds: string[];
|
||||||
documentIds: string[];
|
documentIds: string[];
|
||||||
};
|
};
|
||||||
ip: string;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: "documents.update" // eslint-disable-line
|
name:
|
||||||
|
| "documents.update"
|
||||||
| "documents.update.delayed"
|
| "documents.update.delayed"
|
||||||
| "documents.update.debounced";
|
| "documents.update.debounced";
|
||||||
documentId: string;
|
documentId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
data: {
|
data: {
|
||||||
title: string;
|
title: string;
|
||||||
autosave: boolean;
|
autosave: boolean;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
};
|
};
|
||||||
ip: string;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: "documents.title_change";
|
name: "documents.title_change";
|
||||||
documentId: string;
|
documentId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
data: {
|
data: {
|
||||||
title: string;
|
title: string;
|
||||||
previousTitle: string;
|
previousTitle: string;
|
||||||
};
|
};
|
||||||
ip: string;
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
export type RevisionEvent = {
|
export type RevisionEvent = BaseEvent & {
|
||||||
name: "revisions.create";
|
name: "revisions.create";
|
||||||
documentId: string;
|
documentId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
modelId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileOperationEvent = {
|
export type FileOperationEvent = BaseEvent & {
|
||||||
name:
|
name:
|
||||||
| "fileOperations.create"
|
| "fileOperations.create"
|
||||||
| "fileOperations.update"
|
| "fileOperations.update"
|
||||||
| "fileOperation.delete";
|
| "fileOperation.delete";
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
modelId: string;
|
modelId: string;
|
||||||
data: Partial<FileOperation>;
|
data: Partial<FileOperation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CollectionEvent =
|
export type CollectionUserEvent = BaseEvent & {
|
||||||
| {
|
|
||||||
name: "collections.create" // eslint-disable-line
|
|
||||||
| "collections.update"
|
|
||||||
| "collections.delete";
|
|
||||||
collectionId: string;
|
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
ip: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
name: "collections.add_user" | "collections.remove_user";
|
name: "collections.add_user" | "collections.remove_user";
|
||||||
userId: string;
|
userId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
};
|
||||||
actorId: string;
|
|
||||||
ip: string;
|
export type CollectionGroupEvent = BaseEvent & {
|
||||||
}
|
|
||||||
| {
|
|
||||||
name: "collections.add_group" | "collections.remove_group";
|
name: "collections.add_group" | "collections.remove_group";
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
modelId: string;
|
modelId: string;
|
||||||
data: {
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
ip: string;
|
};
|
||||||
|
|
||||||
|
export type CollectionEvent = BaseEvent &
|
||||||
|
(
|
||||||
|
| CollectionUserEvent
|
||||||
|
| CollectionGroupEvent
|
||||||
|
| {
|
||||||
|
name:
|
||||||
|
| "collections.create"
|
||||||
|
| "collections.update"
|
||||||
|
| "collections.delete";
|
||||||
|
collectionId: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: "collections.move";
|
name: "collections.move";
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
data: {
|
data: {
|
||||||
index: string;
|
index: string;
|
||||||
};
|
};
|
||||||
ip: string;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: "collections.permission_changed";
|
name: "collections.permission_changed";
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
data: {
|
data: {
|
||||||
privacyChanged: boolean;
|
privacyChanged: boolean;
|
||||||
sharingChanged: boolean;
|
sharingChanged: boolean;
|
||||||
};
|
};
|
||||||
ip: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GroupEvent =
|
|
||||||
| {
|
|
||||||
name: "groups.create" | "groups.delete" | "groups.update";
|
|
||||||
actorId: string;
|
|
||||||
modelId: string;
|
|
||||||
teamId: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
ip: string;
|
|
||||||
}
|
}
|
||||||
| {
|
);
|
||||||
|
|
||||||
|
export type GroupUserEvent = BaseEvent & {
|
||||||
name: "groups.add_user" | "groups.remove_user";
|
name: "groups.add_user" | "groups.remove_user";
|
||||||
actorId: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
teamId: string;
|
|
||||||
data: {
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
ip: string;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export type IntegrationEvent = {
|
export type GroupEvent = BaseEvent &
|
||||||
|
(
|
||||||
|
| GroupUserEvent
|
||||||
|
| {
|
||||||
|
name: "groups.create" | "groups.delete" | "groups.update";
|
||||||
|
modelId: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type IntegrationEvent = BaseEvent & {
|
||||||
name: "integrations.create" | "integrations.update";
|
name: "integrations.create" | "integrations.update";
|
||||||
modelId: string;
|
modelId: string;
|
||||||
teamId: string;
|
|
||||||
actorId: string;
|
|
||||||
ip: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TeamEvent = {
|
export type TeamEvent = BaseEvent & {
|
||||||
name: "teams.update";
|
name: "teams.update";
|
||||||
teamId: string;
|
data: Partial<Team>;
|
||||||
actorId: string;
|
|
||||||
data: Record<string, any>;
|
|
||||||
ip: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PinEvent = {
|
export type PinEvent = BaseEvent & {
|
||||||
name: "pins.create" | "pins.update" | "pins.delete";
|
name: "pins.create" | "pins.update" | "pins.delete";
|
||||||
teamId: string;
|
|
||||||
modelId: string;
|
modelId: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
actorId: string;
|
|
||||||
ip: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StarEvent = {
|
export type StarEvent = BaseEvent & {
|
||||||
name: "stars.create" | "stars.update" | "stars.delete";
|
name: "stars.create" | "stars.update" | "stars.delete";
|
||||||
teamId: string;
|
|
||||||
modelId: string;
|
modelId: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
actorId: string;
|
};
|
||||||
ip: string;
|
|
||||||
|
export type ShareEvent = BaseEvent & {
|
||||||
|
name: "shares.create" | "shares.update" | "shares.revoke";
|
||||||
|
modelId: string;
|
||||||
|
documentId: string;
|
||||||
|
collectionId?: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ViewEvent = BaseEvent & {
|
||||||
|
name: "views.create";
|
||||||
|
documentId: string;
|
||||||
|
collectionId: string;
|
||||||
|
modelId: string;
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookSubscriptionEvent = BaseEvent & {
|
||||||
|
name:
|
||||||
|
| "webhook_subscriptions.create"
|
||||||
|
| "webhook_subscriptions.delete"
|
||||||
|
| "webhook_subscriptions.update";
|
||||||
|
modelId: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
|
| ApiKeyEvent
|
||||||
| UserEvent
|
| UserEvent
|
||||||
| DocumentEvent
|
| DocumentEvent
|
||||||
| PinEvent
|
| PinEvent
|
||||||
@@ -239,4 +253,7 @@ export type Event =
|
|||||||
| IntegrationEvent
|
| IntegrationEvent
|
||||||
| GroupEvent
|
| GroupEvent
|
||||||
| RevisionEvent
|
| RevisionEvent
|
||||||
| TeamEvent;
|
| ShareEvent
|
||||||
|
| TeamEvent
|
||||||
|
| ViewEvent
|
||||||
|
| WebhookSubscriptionEvent;
|
||||||
|
|||||||
@@ -186,6 +186,7 @@
|
|||||||
"Share Links": "Share Links",
|
"Share Links": "Share Links",
|
||||||
"Import": "Import",
|
"Import": "Import",
|
||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
|
"Webhooks": "Webhooks",
|
||||||
"Integrations": "Integrations",
|
"Integrations": "Integrations",
|
||||||
"Insert column after": "Insert column after",
|
"Insert column after": "Insert column after",
|
||||||
"Insert column before": "Insert column before",
|
"Insert column before": "Insert column before",
|
||||||
@@ -586,6 +587,20 @@
|
|||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Everyone": "Everyone",
|
"Everyone": "Everyone",
|
||||||
"Admins": "Admins",
|
"Admins": "Admins",
|
||||||
|
"Are you sure you want to delete the {{ name }} webhook?": "Are you sure you want to delete the {{ name }} webhook?",
|
||||||
|
"Webhook updated": "Webhook updated",
|
||||||
|
"Update": "Update",
|
||||||
|
"Updating": "Updating",
|
||||||
|
"Provide a descriptive name for this webhook and the URL we should send a POST request to when matching events are created.": "Provide a descriptive name for this webhook and the URL we should send a POST request to when matching events are created.",
|
||||||
|
"Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.",
|
||||||
|
"URL": "URL",
|
||||||
|
"All events": "All events",
|
||||||
|
"All {{ groupName }} events": "All {{ groupName }} events",
|
||||||
|
"Delete webhook": "Delete webhook",
|
||||||
|
"Disabled": "Disabled",
|
||||||
|
"Subscribed events": "Subscribed events",
|
||||||
|
"Edit webhook": "Edit webhook",
|
||||||
|
"Webhook created": "Webhook created",
|
||||||
"Logo updated": "Logo updated",
|
"Logo updated": "Logo updated",
|
||||||
"Unable to upload new logo": "Unable to upload new logo",
|
"Unable to upload new logo": "Unable to upload new logo",
|
||||||
"These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.",
|
"These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.",
|
||||||
@@ -682,6 +697,9 @@
|
|||||||
"You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
"You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||||
"Tokens": "Tokens",
|
"Tokens": "Tokens",
|
||||||
"Create a token": "Create a token",
|
"Create a token": "Create a token",
|
||||||
|
"New webhook": "New webhook",
|
||||||
|
"Webhooks can be used to notify your application when events happen in Outline. Events are sent as a https request with a JSON payload in near real-time.": "Webhooks can be used to notify your application when events happen in Outline. Events are sent as a https request with a JSON payload in near real-time.",
|
||||||
|
"Create a webhook": "Create a webhook",
|
||||||
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
"Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'": "Zapier is a platform that allows Outline to easily integrate with thousands of other business tools. Head over to Zapier to setup a \"Zap\" and start programmatically interacting with Outline.'",
|
||||||
"Open Zapier": "Open Zapier",
|
"Open Zapier": "Open Zapier",
|
||||||
"Alphabetical": "Alphabetical",
|
"Alphabetical": "Alphabetical",
|
||||||
|
|||||||
13
yarn.lock
13
yarn.lock
@@ -11330,10 +11330,10 @@ os-browserify@^0.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
|
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
|
||||||
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
|
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
|
||||||
|
|
||||||
outline-icons@^1.42.0:
|
outline-icons@^1.43.1:
|
||||||
version "1.42.0"
|
version "1.43.1"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.42.0.tgz#76b02f57b9dcac79c1f6876e918d472fefaa4568"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.43.1.tgz#3193c4c659c66b34788db043bb2f843b9c437a48"
|
||||||
integrity sha512-px2wNGrzTDCU0pxUO2pKs5dgE3fdz3bQ3lpLA9CgzBpkcadpOQgRxxxyBQ12fyOXyKC0L4evAJeomoAtvMWISQ==
|
integrity sha512-REj+JsCFi2Jv5uG0/OrBsMVSBFAIsSROxynWbuO9r2eNT8wdqjni02Mk1gq1qFfTbwOvHJ+7ycadu6zlISAK2g==
|
||||||
|
|
||||||
oy-vey@^0.10.0:
|
oy-vey@^0.10.0:
|
||||||
version "0.10.0"
|
version "0.10.0"
|
||||||
@@ -12483,6 +12483,11 @@ react-helmet@^6.1.0:
|
|||||||
react-fast-compare "^3.1.1"
|
react-fast-compare "^3.1.1"
|
||||||
react-side-effect "^2.1.0"
|
react-side-effect "^2.1.0"
|
||||||
|
|
||||||
|
react-hook-form@^7.31.2:
|
||||||
|
version "7.31.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.31.2.tgz#efb7ac469810954488b7cf40be4e5017122c6e5e"
|
||||||
|
integrity sha512-oPudn3YuyzWg//IsT9z2cMEjWocAgHWX/bmueDT8cmsYQnGY5h7/njjvMDfLVv3mbdhYBjslTRnII2MIT7eNCA==
|
||||||
|
|
||||||
react-i18next@^11.16.6:
|
react-i18next@^11.16.6:
|
||||||
version "11.16.6"
|
version "11.16.6"
|
||||||
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.6.tgz#e8a07802c391a55e1528673201a2727994787641"
|
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.6.tgz#e8a07802c391a55e1528673201a2727994787641"
|
||||||
|
|||||||
Reference in New Issue
Block a user