diff --git a/app/components/ButtonLink.tsx b/app/components/ButtonLink.tsx index 89302b628..4c14c1664 100644 --- a/app/components/ButtonLink.tsx +++ b/app/components/ButtonLink.tsx @@ -2,13 +2,15 @@ import * as React from "react"; import styled from "styled-components"; type Props = { - onClick: React.MouseEventHandler; + onClick?: React.MouseEventHandler; children: React.ReactNode; }; -export default function ButtonLink(props: Props) { - return - } + collection={collection} + integration={integration} /> ); } diff --git a/app/scenes/Settings/components/SlackButton.tsx b/app/scenes/Settings/components/SlackButton.tsx index 6c029ba33..f93668f82 100644 --- a/app/scenes/Settings/components/SlackButton.tsx +++ b/app/scenes/Settings/components/SlackButton.tsx @@ -2,17 +2,17 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { slackAuth } from "@shared/utils/routeHelpers"; import Button from "~/components/Button"; -import SlackIcon from "~/components/SlackIcon"; import env from "~/env"; type Props = { scopes?: string[]; redirectUri: string; + icon?: React.ReactNode; state?: string; label?: string; }; -function SlackButton({ state = "", scopes, redirectUri, label }: Props) { +function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) { const { t } = useTranslation(); const handleClick = () => @@ -24,11 +24,7 @@ function SlackButton({ state = "", scopes, redirectUri, label }: Props) { )); return ( - ); diff --git a/app/scenes/Settings/components/SlackListItem.tsx b/app/scenes/Settings/components/SlackListItem.tsx new file mode 100644 index 000000000..6ab242dbb --- /dev/null +++ b/app/scenes/Settings/components/SlackListItem.tsx @@ -0,0 +1,115 @@ +import { uniq } from "lodash"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; +import styled from "styled-components"; +import Collection from "~/models/Collection"; +import Integration from "~/models/Integration"; +import Button from "~/components/Button"; +import ButtonLink from "~/components/ButtonLink"; +import Checkbox from "~/components/Checkbox"; +import CollectionIcon from "~/components/CollectionIcon"; +import Flex from "~/components/Flex"; +import HelpText from "~/components/HelpText"; +import ListItem from "~/components/List/Item"; +import Popover from "~/components/Popover"; +import useToasts from "~/hooks/useToasts"; + +type Props = { + integration: Integration; + collection: Collection; +}; + +function SlackListItem({ integration, collection }: Props) { + const { t } = useTranslation(); + const { showToast } = useToasts(); + + const handleChange = async (ev: React.ChangeEvent) => { + if (ev.target.checked) { + integration.events = uniq([...integration.events, ev.target.name]); + } else { + integration.events = integration.events.filter( + (n) => n !== ev.target.name + ); + } + + await integration.save(); + + showToast(t("Settings saved"), { + type: "success", + }); + }; + + const mapping = { + "documents.publish": t("document published"), + "documents.update": t("document updated"), + }; + + const popover = usePopoverState({ + gutter: 0, + placement: "bottom-start", + }); + + return ( + + {collection.name} + + } + subtitle={ + <> + {{ channelName }} channel on`} + values={{ + channelName: integration.settings.channel, + events: integration.events.map((ev) => mapping[ev]).join(", "), + }} + components={{ + em: , + }} + />{" "} + + {(props) => ( + + {integration.events.map((ev) => mapping[ev]).join(", ")} + + )} + + + +

{t("Notifications")}

+ {t("These events should be posted to Slack")} + + +
+
+ + } + actions={ + + } + /> + ); +} + +const Events = styled.div` + color: ${(props) => props.theme.text}; + margin-top: -12px; +`; + +export default observer(SlackListItem); diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index 3a9978243..e1c05e18c 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -17,8 +17,7 @@ export default async function documentUpdater({ const document = await Document.findByPk(documentId); const state = Y.encodeStateAsUpdate(ydoc); const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default")); - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - const text = serializer.serialize(node); + const text = serializer.serialize(node, undefined); const isUnchanged = document.text === text; const hasMultiplayerState = !!document.state; diff --git a/server/migrations/20211217054419-integration-events.js b/server/migrations/20211217054419-integration-events.js new file mode 100644 index 000000000..f96072d85 --- /dev/null +++ b/server/migrations/20211217054419-integration-events.js @@ -0,0 +1,18 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + update integrations + set "events" = '{documents.update,documents.publish}' + where type = 'post' + `); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + update integrations + set "events" = NULL + where type = 'post' + `); + }, +}; diff --git a/server/queues/processors/slack.ts b/server/queues/processors/slack.ts index 46881db91..e21fc7ad0 100644 --- a/server/queues/processors/slack.ts +++ b/server/queues/processors/slack.ts @@ -1,4 +1,5 @@ import fetch from "fetch-with-proxy"; +import { Op } from "sequelize"; import { Document, Integration, Collection, Team } from "@server/models"; import { presentSlackAttachment } from "@server/presenters"; import { @@ -38,8 +39,10 @@ export default class SlackProcessor { ], }); if (!integration) return; + const collection = integration.collection; if (!collection) return; + await fetch(integration.settings.url, { method: "POST", headers: { @@ -68,14 +71,19 @@ export default class SlackProcessor { Team.findByPk(event.teamId), ]); if (!document) return; + // never send notifications for draft documents if (!document.publishedAt) return; + const integration = await Integration.findOne({ where: { teamId: document.teamId, collectionId: document.collectionId, service: "slack", type: "post", + events: { + [Op.contains]: [event.name], + }, }, }); if (!integration) return; diff --git a/server/routes/api/integrations.test.ts b/server/routes/api/integrations.test.ts new file mode 100644 index 000000000..a5b3b33d0 --- /dev/null +++ b/server/routes/api/integrations.test.ts @@ -0,0 +1,53 @@ +// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message +import TestServer from "fetch-test-server"; +import webService from "@server/services/web"; +import { + buildAdmin, + buildTeam, + buildUser, + buildIntegration, +} from "@server/test/factories"; +import { flushdb } from "@server/test/support"; + +const app = webService(); +const server = new TestServer(app.callback()); + +beforeEach(() => flushdb()); +afterAll(() => server.close()); + +describe("#integrations.update", () => { + it("should allow updating integration events", async () => { + const team = await buildTeam(); + const user = await buildAdmin({ teamId: team.id }); + const integration = await buildIntegration({ + userId: user.id, + teamId: team.id, + }); + + const res = await server.post("/api/integrations.update", { + body: { + events: ["documents.update"], + token: user.getJwtToken(), + id: integration.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(integration.id); + expect(body.data.events.length).toEqual(1); + }); + + it("should require authorization", async () => { + const user = await buildUser(); + const integration = await buildIntegration({ + userId: user.id, + }); + const res = await server.post("/api/integrations.update", { + body: { + token: user.getJwtToken(), + id: integration.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); diff --git a/server/routes/api/integrations.ts b/server/routes/api/integrations.ts index 6a43fa875..44216341d 100644 --- a/server/routes/api/integrations.ts +++ b/server/routes/api/integrations.ts @@ -4,7 +4,7 @@ import { Event } from "@server/models"; import Integration from "@server/models/Integration"; import policy from "@server/policies"; import { presentIntegration } from "@server/presenters"; -import { assertSort, assertUuid } from "@server/validation"; +import { assertSort, assertUuid, assertArray } from "@server/validation"; import pagination from "./middlewares/pagination"; const { authorize } = policy; @@ -31,13 +31,37 @@ router.post("integrations.list", auth(), pagination(), async (ctx) => { }; }); +router.post("integrations.update", auth(), async (ctx) => { + const { id, events } = ctx.body; + assertUuid(id, "id is required"); + + const { user } = ctx.state; + const integration = await Integration.findByPk(id); + authorize(user, "update", integration); + + assertArray(events, "events must be an array"); + + if (integration.type === "post") { + integration.events = events.filter((event: string) => + ["documents.update", "documents.publish"].includes(event) + ); + } + + await integration.save(); + + ctx.body = { + data: presentIntegration(integration), + }; +}); + router.post("integrations.delete", auth(), async (ctx) => { const { id } = ctx.body; assertUuid(id, "id is required"); - const user = ctx.state.user; + const { user } = ctx.state; const integration = await Integration.findByPk(id); authorize(user, "delete", integration); + await integration.destroy(); await Event.create({ name: "integrations.delete", @@ -46,6 +70,7 @@ router.post("integrations.delete", auth(), async (ctx) => { actorId: user.id, ip: ctx.request.ip, }); + ctx.body = { success: true, }; diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts index 5bf3848fe..5c9f3277f 100644 --- a/server/routes/auth/providers/slack.ts +++ b/server/routes/auth/providers/slack.ts @@ -153,6 +153,7 @@ if (SLACK_CLIENT_ID) { const { code, error, state } = ctx.request.query; const user = ctx.state.user; assertPresent(code || error, "code is required"); + const collectionId = state; assertUuid(collectionId, "collectionId must be an uuid"); @@ -179,8 +180,7 @@ if (SLACK_CLIENT_ID) { } const endpoint = `${process.env.URL || ""}/auth/slack.post`; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message - const data = await Slack.oauthAccess(code, endpoint); + const data = await Slack.oauthAccess(code as string, endpoint); const authentication = await IntegrationAuthentication.create({ service: "slack", userId: user.id, @@ -188,6 +188,7 @@ if (SLACK_CLIENT_ID) { token: data.access_token, scopes: data.scope.split(","), }); + await Integration.create({ service: "slack", type: "post", @@ -195,7 +196,7 @@ if (SLACK_CLIENT_ID) { teamId: user.teamId, authenticationId: authentication.id, collectionId, - events: [], + events: ["documents.update", "documents.publish"], settings: { url: data.incoming_webhook.url, channel: data.incoming_webhook.channel, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 13589deff..1e198970a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -520,10 +520,17 @@ "by {{ name }}": "by {{ name }}", "Last accessed": "Last accessed", "Add to Slack": "Add to Slack", + "Settings saved": "Settings saved", + "document published": "document published", + "document updated": "document updated", + "Posting to the {{ channelName }} channel on": "Posting to the {{ channelName }} channel on", + "These events should be posted to Slack": "These events should be posted to Slack", + "Document published": "Document published", + "Document updated": "Document updated", + "Disconnect": "Disconnect", "Active": "Active", "Everyone": "Everyone", "Admins": "Admins", - "Settings saved": "Settings saved", "Unable to upload new logo": "Unable to upload new logo", "These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.", "Logo": "Logo", @@ -553,9 +560,7 @@ "Requesting Export": "Requesting Export", "Export Data": "Export Data", "Recent exports": "Recent exports", - "Document published": "Document published", "Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published", - "Document updated": "Document updated", "Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited", "Collection created": "Collection created", "Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created", @@ -597,9 +602,7 @@ "Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?", "Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?", "Get rich previews of Outline links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of Outline links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.", - "Disconnect": "Disconnect", "Connect Outline collections to Slack channels and messages will be automatically posted to Slack when documents are published or updated.": "Connect Outline collections to Slack channels and messages will be automatically posted to Slack when documents are published or updated.", - "Connected to the {{ channelName }} channel": "Connected to the {{ channelName }} channel", "Connect": "Connect", "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.", "New token": "New token",