diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 38725098f..b228bbffb 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -208,7 +208,10 @@ const useSettingsConfig = () => { // TODO: Remove hardcoding of plugin id here group: plugin.id === "collections" ? t("Workspace") : t("Integrations"), component: plugin.settings, - enabled: enabledInDeployment && hasSettings && can.update, + enabled: + enabledInDeployment && + hasSettings && + (plugin.config.adminOnly === false || can.update), icon: plugin.icon, } as ConfigItem; diff --git a/app/scenes/Settings/components/ConnectedButton.tsx b/app/scenes/Settings/components/ConnectedButton.tsx new file mode 100644 index 000000000..1116cca03 --- /dev/null +++ b/app/scenes/Settings/components/ConnectedButton.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { s } from "@shared/styles"; +import Button, { Props as ButtonProps } from "~/components/Button"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import Text from "~/components/Text"; +import useStores from "~/hooks/useStores"; + +type Props = ButtonProps & { + confirmationMessage: React.ReactNode; + onClick: () => Promise | void; +}; + +export function ConnectedButton({ + onClick, + confirmationMessage, + ...rest +}: Props) { + const { t } = useTranslation(); + const { dialogs } = useStores(); + + const handleClick = () => { + dialogs.openModal({ + title: t("Disconnect integration"), + content: ( + + ), + }); + }; + + return ( + + ); +} + +function ConfirmDisconnectDialog({ + confirmationMessage, + onSubmit, +}: { + confirmationMessage: React.ReactNode; + onSubmit: () => Promise | void; +}) { + const { t } = useTranslation(); + return ( + + + {confirmationMessage} + + + ); +} + +const ConnectedIcon = styled.div` + width: 24px; + height: 24px; + position: relative; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 8px; + background-color: ${s("accent")}; + border-radius: 50%; + transform: translate(-50%, -50%); + } +`; diff --git a/app/utils/PluginLoader.ts b/app/utils/PluginLoader.ts index 54bb6e133..9e7a541b2 100644 --- a/app/utils/PluginLoader.ts +++ b/app/utils/PluginLoader.ts @@ -5,6 +5,7 @@ interface Plugin { config: { name: string; description: string; + adminOnly?: boolean; deployments?: string[]; }; settings: React.FC; diff --git a/plugins/slack/client/Settings.tsx b/plugins/slack/client/Settings.tsx index 15423e95b..24f796bac 100644 --- a/plugins/slack/client/Settings.tsx +++ b/plugins/slack/client/Settings.tsx @@ -5,7 +5,9 @@ import styled from "styled-components"; import { IntegrationService, IntegrationType } from "@shared/types"; import Collection from "~/models/Collection"; import Integration from "~/models/Integration"; -import Button from "~/components/Button"; +import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton"; +import SettingRow from "~/scenes/Settings/components/SettingRow"; +import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import List from "~/components/List"; @@ -15,6 +17,7 @@ import Scene from "~/components/Scene"; import Text from "~/components/Text"; import env from "~/env"; import useCurrentTeam from "~/hooks/useCurrentTeam"; +import usePolicy from "~/hooks/usePolicy"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; import { SlackUtils } from "../shared/SlackUtils"; @@ -27,6 +30,7 @@ function Slack() { const { collections, integrations } = useStores(); const { t } = useTranslation(); const query = useQuery(); + const can = usePolicy(team); const error = query.get("error"); React.useEffect(() => { @@ -34,6 +38,7 @@ function Slack() { limit: 100, }); void integrations.fetchPage({ + service: IntegrationService.Slack, limit: 100, }); }, [collections, integrations]); @@ -43,6 +48,11 @@ function Slack() { service: IntegrationService.Slack, }); + const linkedAccountIntegration = integrations.find({ + type: IntegrationType.LinkedAccount, + service: IntegrationService.Slack, + }); + const groupedCollections = collections.orderedData .map<[Collection, Integration | undefined]>((collection) => { const integration = integrations.find({ @@ -76,44 +86,80 @@ function Slack() { )} - - , - }} - /> - - {env.SLACK_CLIENT_ID ? ( - <> -

- {commandIntegration ? ( - - ) : ( - } - /> - )} -

-

 

-

{t("Collections")}

+ + Link your {{ appName }} account to Slack to enable searching the + documents you have access to directly within chat. + + } + > + + {linkedAccountIntegration ? ( + + ) : ( + + )} + + + + {can.update && ( + <> + , + }} + /> + } + > + + {commandIntegration ? ( + + ) : ( + } + /> + )} + + + + {t("Collections")} Connect {{ appName }} collections to Slack channels. Messages will @@ -144,8 +190,12 @@ function Slack() { actions={ } @@ -154,14 +204,6 @@ function Slack() { })} - ) : ( - - - The Slack integration is currently disabled. Please set the - associated environment variables and restart the server to enable - the integration. - - )} ); @@ -172,6 +214,7 @@ const Code = styled.code` margin: 0 2px; background: ${(props) => props.theme.codeBackground}; border-radius: 4px; + font-size: 80%; `; export default observer(Slack); diff --git a/plugins/slack/client/components/SlackListItem.tsx b/plugins/slack/client/components/SlackListItem.tsx index 649d0b9bc..5b3ab1120 100644 --- a/plugins/slack/client/components/SlackListItem.tsx +++ b/plugins/slack/client/components/SlackListItem.tsx @@ -9,7 +9,7 @@ import { s } from "@shared/styles"; import { IntegrationType } from "@shared/types"; import Collection from "~/models/Collection"; import Integration from "~/models/Integration"; -import Button from "~/components/Button"; +import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton"; import ButtonLink from "~/components/ButtonLink"; import Flex from "~/components/Flex"; import CollectionIcon from "~/components/Icons/CollectionIcon"; @@ -100,9 +100,12 @@ function SlackListItem({ integration, collection }: Props) { } actions={ - + } /> ); diff --git a/plugins/slack/plugin.json b/plugins/slack/plugin.json index 0bee50894..5288a0d71 100644 --- a/plugins/slack/plugin.json +++ b/plugins/slack/plugin.json @@ -2,5 +2,6 @@ "id": "slack", "name": "Slack", "priority": 40, + "adminOnly": false, "description": "Adds a Slack authentication provider, support for the /outline slash command, and link unfurling." } diff --git a/plugins/slack/server/api/hooks.ts b/plugins/slack/server/api/hooks.ts index 917e51717..bdafc20eb 100644 --- a/plugins/slack/server/api/hooks.ts +++ b/plugins/slack/server/api/hooks.ts @@ -1,9 +1,8 @@ import { t } from "i18next"; import Router from "koa-router"; import escapeRegExp from "lodash/escapeRegExp"; -import { Op } from "sequelize"; import { z } from "zod"; -import { IntegrationService } from "@shared/types"; +import { IntegrationService, IntegrationType } from "@shared/types"; import { AuthenticationError, InvalidRequestError, @@ -13,37 +12,26 @@ import Logger from "@server/logging/Logger"; import validate from "@server/middlewares/validate"; import { UserAuthentication, - AuthenticationProvider, Document, User, Team, SearchQuery, Integration, IntegrationAuthentication, + AuthenticationProvider, } from "@server/models"; import SearchHelper from "@server/models/helpers/SearchHelper"; import { APIContext } from "@server/types"; import { safeEqual } from "@server/utils/crypto"; import { opts } from "@server/utils/i18n"; import env from "../env"; -import presentMessageAttachment from "../presenters/messageAttachment"; +import { presentMessageAttachment } from "../presenters/messageAttachment"; +import { presentUserNotLinkedBlocks } from "../presenters/userNotLinkedBlocks"; import * as Slack from "../slack"; import * as T from "./schema"; const router = new Router(); -function verifySlackToken(token: string) { - if (!env.SLACK_VERIFICATION_TOKEN) { - throw AuthenticationError( - "SLACK_VERIFICATION_TOKEN is not present in environment" - ); - } - - if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) { - throw AuthenticationError("Invalid token"); - } -} - // triggered by a user posting a getoutline.com link in Slack router.post( "hooks.unfurl", @@ -58,21 +46,10 @@ router.post( return; } - const { token, event } = ctx.input.body; + const { token, team_id, event } = ctx.input.body; verifySlackToken(token); - const user = await User.findOne({ - include: [ - { - where: { - providerId: event.user, - }, - model: UserAuthentication, - as: "authentications", - required: true, - }, - ], - }); + const user = await findUserForRequest(team_id, event.user); if (!user) { Logger.debug("plugins", "No user found for Slack user ID", { providerId: event.user, @@ -192,67 +169,7 @@ router.post( const { token, team_id, user_id, text } = ctx.input.body; verifySlackToken(token); - let user, team; - - // attempt to find the corresponding team for this request based on the team_id - team = await Team.findOne({ - include: [ - { - where: { - name: "slack", - providerId: team_id, - enabled: true, - }, - as: "authenticationProviders", - model: AuthenticationProvider, - required: true, - }, - ], - }); - - if (team) { - const authentication = await UserAuthentication.findOne({ - where: { - providerId: user_id, - }, - include: [ - { - where: { - teamId: team.id, - }, - model: User, - as: "user", - required: true, - }, - ], - }); - - if (authentication) { - user = authentication.user; - } - } else { - // If we couldn't find a team it's still possible that the request is from - // a team that authenticated with a different service, but connected Slack - // via integration - const integration = await Integration.findOne({ - where: { - service: IntegrationService.Slack, - settings: { - serviceTeamId: team_id, - }, - }, - include: [ - { - model: Team, - as: "team", - }, - ], - }); - - if (integration) { - team = integration.team; - } - } + const user = await findUserForRequest(team_id, user_id); // Handle "help" command or no input if (text.trim() === "help" || !text.trim()) { @@ -268,7 +185,7 @@ router.post( "To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.", { command: `/outline keyword`, - command2: `/outline help`, + command2: `/outline`, ...opts(user), } ), @@ -278,90 +195,34 @@ router.post( return; } - // This should be super rare, how does someone end up being able to make a valid - // request from Slack that connects to no teams in Outline. - if (!team) { - ctx.body = { - response_type: "ephemeral", - text: t( - `Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.`, - { - ...opts(user), - appName: env.APP_NAME, - } - ), - }; - return; - } - - // Try to find the user by matching the email address if it is confirmed on - // Slack's side. It's always trusted on our side as it is only updatable - // through the authentication provider. - if (!user) { - const auth = await IntegrationAuthentication.findOne({ - where: { - scopes: { [Op.contains]: ["identity.email"] }, - service: IntegrationService.Slack, - teamId: team.id, - }, - }); - - if (auth) { - try { - const response = await Slack.request("users.info", { - token: auth.token, - user: user_id, - }); - - if (response.user.is_email_confirmed && response.user.profile.email) { - user = await User.findOne({ - where: { - email: response.user.profile.email, - teamId: team.id, - }, - }); - } - } catch (err) { - // Old connections do not have the correct permissions to access user info - // so errors here are expected. - Logger.info( - "utils", - "Failed requesting users.info from Slack, the Slack integration should be reconnected.", - { - teamId: auth.teamId, - } - ); - } - } - } - const options = { limit: 5, }; - // If we were able to map the request to a user then we can use their permissions - // to load more documents based on the collections they have access to. Otherwise - // just a generic search against team-visible documents is allowed. - const { results, totalCount } = user - ? await SearchHelper.searchForUser(user, text, options) - : await SearchHelper.searchForTeam(team, text, options); + if (!user) { + const team = await findTeamForRequest(team_id); + + ctx.body = { + response_type: "ephemeral", + blocks: presentUserNotLinkedBlocks(team), + }; + return; + } + + const { results, totalCount } = await SearchHelper.searchForUser( + user, + text, + options + ); await SearchQuery.create({ userId: user ? user.id : null, - teamId: team.id, + teamId: user.teamId, source: "slack", query: text, results: totalCount, }); - const haventSignedIn = t( - `It looks like you haven’t signed in to {{ appName }} yet, so results may be limited`, - { - ...opts(user), - appName: env.APP_NAME, - } - ); - // Map search results to the format expected by the Slack API if (results.length) { const attachments = []; @@ -373,7 +234,7 @@ router.post( attachments.push( presentMessageAttachment( result.document, - team, + user.team, result.document.collection, queryIsInTitle ? undefined : result.context, env.SLACK_MESSAGE_ACTIONS @@ -391,29 +252,155 @@ router.post( } ctx.body = { - text: user - ? t(`This is what we found for "{{ term }}"`, { - ...opts(user), - term: text, - }) - : t(`This is what we found for "{{ term }}"`, { - ...opts(user), - term: text, - }) + ` (${haventSignedIn})…`, + text: t(`This is what we found for "{{ term }}"`, { + ...opts(user), + term: text, + }), attachments, }; } else { ctx.body = { - text: user - ? t(`No results for "{{ term }}"`, { - ...opts(user), - term: text, - }) - : t(`No results for "{{ term }}"`, { ...opts(user), term: text }) + - ` (${haventSignedIn})…`, + text: t(`No results for "{{ term }}"`, { + ...opts(user), + term: text, + }), }; } } ); +function verifySlackToken(token: string) { + if (!env.SLACK_VERIFICATION_TOKEN) { + throw AuthenticationError( + "SLACK_VERIFICATION_TOKEN is not present in environment" + ); + } + + if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) { + throw AuthenticationError("Invalid token"); + } +} + +/** + * Find a matching team for the given Slack team ID + * + * @param serviceTeamId The Slack team ID + * @returns A promise resolving to a matching team, if found + */ +async function findTeamForRequest( + serviceTeamId: string +): Promise { + const authenticationProvider = await AuthenticationProvider.findOne({ + where: { + name: "slack", + providerId: serviceTeamId, + }, + include: [ + { + required: true, + model: Team, + as: "team", + }, + ], + }); + + if (authenticationProvider) { + return authenticationProvider.team; + } + + const integration = await Integration.findOne({ + where: { + service: IntegrationService.Slack, + type: IntegrationType.LinkedAccount, + settings: { + slack: { + serviceTeamId, + }, + }, + }, + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], + }); + + if (integration) { + return integration.team; + } + + return; +} + +/** + * Find a matching user for the given Slack team and user ID + * + * @param serviceTeamId The Slack team ID + * @param serviceUserId The Slack user ID + * @returns A promise resolving to a matching user, if found + */ +async function findUserForRequest( + serviceTeamId: string, + serviceUserId: string +): Promise { + // Prefer explicit linked account + const integration = await Integration.findOne({ + where: { + service: IntegrationService.Slack, + type: IntegrationType.LinkedAccount, + settings: { + slack: { + serviceTeamId, + serviceUserId, + }, + }, + }, + include: [ + { + model: User, + as: "user", + required: true, + }, + { + model: Team, + as: "team", + required: true, + }, + ], + order: [["createdAt", "DESC"]], + }); + + if (integration) { + integration.user.team = integration.team; + return integration.user; + } + + // Fallback to authentication provider if the user has Slack sign-in + const user = await User.findOne({ + include: [ + { + where: { + providerId: serviceUserId, + }, + order: [["createdAt", "DESC"]], + model: UserAuthentication, + as: "authentications", + required: true, + }, + { + model: Team, + as: "team", + }, + ], + }); + + if (user) { + return user; + } + + return; +} + export default router; diff --git a/plugins/slack/server/api/schema.ts b/plugins/slack/server/api/schema.ts index acea75cca..bed68e757 100644 --- a/plugins/slack/server/api/schema.ts +++ b/plugins/slack/server/api/schema.ts @@ -8,6 +8,7 @@ export const HooksUnfurlSchema = z.object({ .or( z.object({ token: z.string(), + team_id: z.string(), event: z.object({ channel: z.string(), message_ts: z.string(), diff --git a/plugins/slack/server/auth/schema.ts b/plugins/slack/server/auth/schema.ts index 5b5223f17..1a30edc08 100644 --- a/plugins/slack/server/auth/schema.ts +++ b/plugins/slack/server/auth/schema.ts @@ -2,25 +2,11 @@ import isEmpty from "lodash/isEmpty"; import { z } from "zod"; import { BaseSchema } from "@server/routes/api/schema"; -export const SlackCommandsSchema = BaseSchema.extend({ - query: z - .object({ - code: z.string().nullish(), - state: z.string().uuid().nullish(), - error: z.string().nullish(), - }) - .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { - message: "one of code or error is required", - }), -}); - -export type SlackCommandsReq = z.infer; - export const SlackPostSchema = BaseSchema.extend({ query: z .object({ code: z.string().nullish(), - state: z.string().uuid().nullish(), + state: z.string(), error: z.string().nullish(), }) .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { diff --git a/plugins/slack/server/auth/slack.test.ts b/plugins/slack/server/auth/slack.test.ts index 87da48cff..edea76b49 100644 --- a/plugins/slack/server/auth/slack.test.ts +++ b/plugins/slack/server/auth/slack.test.ts @@ -2,24 +2,6 @@ import { getTestServer } from "@server/test/support"; const server = getTestServer(); -describe("#slack.commands", () => { - it("should fail with status 400 bad request if query param state is not a uuid", async () => { - const res = await server.get("/auth/slack.commands?state=123"); - const body = await res.json(); - expect(res.status).toEqual(400); - expect(body.message).toEqual("state: Invalid uuid"); - }); - - it("should fail with status 400 bad request when both code and error are missing in query params", async () => { - const res = await server.get( - "/auth/slack.commands?state=182d14d5-0dbd-4521-ac52-25484c25c96e" - ); - const body = await res.json(); - expect(res.status).toEqual(400); - expect(body.message).toEqual("query: one of code or error is required"); - }); -}); - describe("#slack.post", () => { it("should fail with status 400 bad request if query param state is not a uuid", async () => { const res = await server.get("/auth/slack.post?state=123"); diff --git a/plugins/slack/server/auth/slack.ts b/plugins/slack/server/auth/slack.ts index 51d8778b6..66f4cb743 100644 --- a/plugins/slack/server/auth/slack.ts +++ b/plugins/slack/server/auth/slack.ts @@ -5,16 +5,19 @@ import { Profile } from "passport"; import { Strategy as SlackStrategy } from "passport-slack-oauth2"; import { IntegrationService, IntegrationType } from "@shared/types"; import accountProvisioner from "@server/commands/accountProvisioner"; +import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import passportMiddleware from "@server/middlewares/passport"; import validate from "@server/middlewares/validate"; import { IntegrationAuthentication, - Collection, Integration, Team, User, + Collection, } from "@server/models"; +import { authorize } from "@server/policies"; +import { sequelize } from "@server/storage/database"; import { APIContext, AuthenticationResult } from "@server/types"; import { getClientFromContext, @@ -125,71 +128,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { passport.use(strategy); router.get("slack", passport.authenticate(providerName)); - router.get("slack.callback", passportMiddleware(providerName)); - router.get( - "slack.commands", - auth({ - optional: true, - }), - validate(T.SlackCommandsSchema), - async (ctx: APIContext) => { - const { code, state: teamId, error } = ctx.input.query; - const { user } = ctx.state.auth; - - if (error) { - ctx.redirect(SlackUtils.errorUrl(error)); - return; - } - - // this code block accounts for the root domain being unable to - // access authentication for subdomains. We must forward to the appropriate - // subdomain to complete the oauth flow - if (!user) { - if (teamId) { - try { - const team = await Team.findByPk(teamId, { - rejectOnEmpty: true, - }); - return redirectOnClient( - ctx, - SlackUtils.commandsUrl({ - baseUrl: team.url, - params: ctx.request.querystring, - }) - ); - } catch (err) { - return ctx.redirect(SlackUtils.errorUrl("unauthenticated")); - } - } else { - return ctx.redirect(SlackUtils.errorUrl("unauthenticated")); - } - } - - // validation middleware ensures that code is non-null at this point - const data = await Slack.oauthAccess(code!, SlackUtils.commandsUrl()); - const authentication = await IntegrationAuthentication.create({ - service: IntegrationService.Slack, - userId: user.id, - teamId: user.teamId, - token: data.access_token, - scopes: data.scope.split(","), - }); - await Integration.create({ - service: IntegrationService.Slack, - type: IntegrationType.Command, - userId: user.id, - teamId: user.teamId, - authenticationId: authentication.id, - settings: { - serviceTeamId: data.team_id, - }, - }); - ctx.redirect(SlackUtils.url); - } - ); - router.get( "slack.post", auth({ @@ -197,7 +137,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { }), validate(T.SlackPostSchema), async (ctx: APIContext) => { - const { code, error, state: collectionId } = ctx.input.query; + const { code, error, state } = ctx.input.query; const { user } = ctx.state.auth; if (error) { @@ -205,21 +145,28 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { return; } - // this code block accounts for the root domain being unable to - // access authentication for subdomains. We must forward to the - // appropriate subdomain to complete the oauth flow + let parsedState; + try { + parsedState = SlackUtils.parseState<{ + collectionId: string; + }>(state); + } catch (err) { + throw ValidationError("Invalid state"); + } + + const { teamId, collectionId, type } = parsedState; + + // This code block accounts for the root domain being unable to access authentication for + // subdomains. We must forward to the appropriate subdomain to complete the OAuth flow. if (!user) { - if (collectionId) { + if (teamId) { try { - const collection = await Collection.findByPk(collectionId, { - rejectOnEmpty: true, - }); - const team = await Team.findByPk(collection.teamId, { + const team = await Team.findByPk(teamId, { rejectOnEmpty: true, }); return redirectOnClient( ctx, - SlackUtils.postUrl({ + SlackUtils.connectUrl({ baseUrl: team.url, params: ctx.request.querystring, }) @@ -232,30 +179,105 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { } } - // validation middleware ensures that code is non-null at this point - const data = await Slack.oauthAccess(code!, SlackUtils.postUrl()); - const authentication = await IntegrationAuthentication.create({ - service: IntegrationService.Slack, - userId: user.id, - teamId: user.teamId, - token: data.access_token, - scopes: data.scope.split(","), - }); + switch (type) { + case IntegrationType.Post: { + const collection = await Collection.findByPk(collectionId, { + rejectOnEmpty: true, + }); + authorize(user, "read", collection); + authorize(user, "update", user.team); + + // validation middleware ensures that code is non-null at this point + const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl()); + + await sequelize.transaction(async (transaction) => { + const authentication = await IntegrationAuthentication.create( + { + service: IntegrationService.Slack, + userId: user.id, + teamId: user.teamId, + token: data.access_token, + scopes: data.scope.split(","), + }, + { transaction } + ); + await Integration.create( + { + service: IntegrationService.Slack, + type: IntegrationType.Post, + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + collectionId, + events: ["documents.update", "documents.publish"], + settings: { + url: data.incoming_webhook.url, + channel: data.incoming_webhook.channel, + channelId: data.incoming_webhook.channel_id, + }, + }, + { transaction } + ); + }); + break; + } + + case IntegrationType.Command: { + authorize(user, "update", user.team); + + // validation middleware ensures that code is non-null at this point + const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl()); + + await sequelize.transaction(async (transaction) => { + const authentication = await IntegrationAuthentication.create( + { + service: IntegrationService.Slack, + userId: user.id, + teamId: user.teamId, + token: data.access_token, + scopes: data.scope.split(","), + }, + { transaction } + ); + await Integration.create( + { + service: IntegrationService.Slack, + type: IntegrationType.Command, + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + settings: { + serviceTeamId: data.team_id, + }, + }, + { transaction } + ); + }); + break; + } + + case IntegrationType.LinkedAccount: { + // validation middleware ensures that code is non-null at this point + const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl()); + await Integration.create({ + service: IntegrationService.Slack, + type: IntegrationType.LinkedAccount, + userId: user.id, + teamId: user.teamId, + settings: { + slack: { + serviceUserId: data.user_id, + serviceTeamId: data.team_id, + }, + }, + }); + break; + } + + default: + throw ValidationError("Invalid integration type"); + } - await Integration.create({ - service: IntegrationService.Slack, - type: IntegrationType.Post, - userId: user.id, - teamId: user.teamId, - authenticationId: authentication.id, - collectionId, - events: ["documents.update", "documents.publish"], - settings: { - url: data.incoming_webhook.url, - channel: data.incoming_webhook.channel, - channelId: data.incoming_webhook.channel_id, - }, - }); ctx.redirect(SlackUtils.url); } ); diff --git a/plugins/slack/server/presenters/messageAttachment.ts b/plugins/slack/server/presenters/messageAttachment.ts index 49c37e94f..cf4ceebe1 100644 --- a/plugins/slack/server/presenters/messageAttachment.ts +++ b/plugins/slack/server/presenters/messageAttachment.ts @@ -1,4 +1,3 @@ -import { traceFunction } from "@server/logging/tracing"; import { Document, Collection, Team } from "@server/models"; type Action = { @@ -8,7 +7,7 @@ type Action = { value: string; }; -function presentMessageAttachment( +export function presentMessageAttachment( document: Document, team: Team, collection?: Collection | null, @@ -32,7 +31,3 @@ function presentMessageAttachment( actions, }; } - -export default traceFunction({ - spanName: "presenters", -})(presentMessageAttachment); diff --git a/plugins/slack/server/presenters/userNotLinkedBlocks.ts b/plugins/slack/server/presenters/userNotLinkedBlocks.ts new file mode 100644 index 000000000..baf33fbe0 --- /dev/null +++ b/plugins/slack/server/presenters/userNotLinkedBlocks.ts @@ -0,0 +1,38 @@ +import { t } from "i18next"; +import { Team } from "@server/models"; +import { opts } from "@server/utils/i18n"; +import env from "../env"; + +export function presentUserNotLinkedBlocks(team?: Team) { + const appName = env.APP_NAME; + + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: + t( + `It looks like you haven’t linked your {{ appName }} account to Slack yet`, + { + ...opts(), + appName, + } + ) + + ". " + + (team + ? `<${team.url}/settings/integrations/slack|${t( + "Link your account", + opts() + )}>` + : t( + "Link your account in {{ appName }} settings to search from Slack", + { + ...opts(), + appName, + } + )), + }, + }, + ]; +} diff --git a/plugins/slack/server/processors/SlackProcessor.ts b/plugins/slack/server/processors/SlackProcessor.ts index 9f980f710..8c3776c29 100644 --- a/plugins/slack/server/processors/SlackProcessor.ts +++ b/plugins/slack/server/processors/SlackProcessor.ts @@ -13,7 +13,7 @@ import { import fetch from "@server/utils/fetch"; import { sleep } from "@server/utils/timers"; import env from "../env"; -import presentMessageAttachment from "../presenters/messageAttachment"; +import { presentMessageAttachment } from "../presenters/messageAttachment"; export default class SlackProcessor extends BaseProcessor { static applicableEvents: Event["name"][] = [ diff --git a/plugins/slack/shared/SlackUtils.ts b/plugins/slack/shared/SlackUtils.ts index 16b917fe4..8a1442497 100644 --- a/plugins/slack/shared/SlackUtils.ts +++ b/plugins/slack/shared/SlackUtils.ts @@ -1,18 +1,36 @@ import env from "@shared/env"; +import { IntegrationType } from "@shared/types"; import { integrationSettingsPath } from "@shared/utils/routeHelpers"; export class SlackUtils { private static authBaseUrl = "https://slack.com/oauth/authorize"; - static commandsUrl( - { baseUrl, params }: { baseUrl: string; params?: string } = { - baseUrl: `${env.URL}`, - params: undefined, - } + /** + * Create a state string for use in OAuth flows + * + * @param teamId The team ID + * @param type The integration type + * @param data Additional data to include in the state + * @returns A state string + */ + static createState( + teamId: string, + type: IntegrationType, + data?: Record ) { - return params - ? `${baseUrl}/auth/slack.commands?${params}` - : `${baseUrl}/auth/slack.commands`; + return JSON.stringify({ type, teamId, ...data }); + } + + /** + * Parse a state string from an OAuth flow + * + * @param state The state string + * @returns The parsed state + */ + static parseState( + state: string + ): { teamId: string; type: IntegrationType } & T { + return JSON.parse(state); } static callbackUrl( @@ -26,7 +44,7 @@ export class SlackUtils { : `${baseUrl}/auth/slack.callback`; } - static postUrl( + static connectUrl( { baseUrl, params }: { baseUrl: string; params?: string } = { baseUrl: `${env.URL}`, params: undefined, diff --git a/server/models/AuthenticationProvider.ts b/server/models/AuthenticationProvider.ts index 40531e3dc..58de38201 100644 --- a/server/models/AuthenticationProvider.ts +++ b/server/models/AuthenticationProvider.ts @@ -15,6 +15,7 @@ import { Table, IsUUID, PrimaryKey, + Scopes, } from "sequelize-typescript"; import Model from "@server/models/base/Model"; import { ValidationError } from "../errors"; @@ -28,6 +29,20 @@ import AzureClient from "plugins/azure/server/azure"; import GoogleClient from "plugins/google/server/google"; import OIDCClient from "plugins/oidc/server/oidc"; +@Scopes(() => ({ + withUserAuthentication: (userId: string) => ({ + include: [ + { + model: UserAuthentication, + as: "userAuthentications", + required: true, + where: { + userId, + }, + }, + ], + }), +})) @Table({ tableName: "authentication_providers", modelName: "authentication_provider", diff --git a/server/models/Integration.ts b/server/models/Integration.ts index 05254250f..81e41ab9a 100644 --- a/server/models/Integration.ts +++ b/server/models/Integration.ts @@ -9,10 +9,7 @@ import { IsIn, } from "sequelize-typescript"; import { IntegrationType, IntegrationService } from "@shared/types"; -import type { - IntegrationSettings, - UserCreatableIntegrationService, -} from "@shared/types"; +import type { IntegrationSettings } from "@shared/types"; import Collection from "./Collection"; import IntegrationAuthentication from "./IntegrationAuthentication"; import Team from "./Team"; @@ -43,7 +40,7 @@ class Integration extends IdModel< @IsIn([Object.values(IntegrationService)]) @Column(DataType.STRING) - service: IntegrationService | UserCreatableIntegrationService; + service: IntegrationService; @Column(DataType.JSONB) settings: IntegrationSettings; diff --git a/server/policies/integration.ts b/server/policies/integration.ts index 893af6afb..1409f6091 100644 --- a/server/policies/integration.ts +++ b/server/policies/integration.ts @@ -1,3 +1,4 @@ +import { IntegrationType } from "@shared/types"; import { Integration, User, Team } from "@server/models"; import { AdminRequiredError } from "../errors"; import { allow } from "./cancan"; @@ -21,10 +22,16 @@ allow( ); allow(User, ["update", "delete"], Integration, (user, integration) => { - if (user.isViewer) { + if (!integration || user.teamId !== integration.teamId) { return false; } - if (!integration || user.teamId !== integration.teamId) { + if ( + integration.userId === user.id && + integration.type === IntegrationType.LinkedAccount + ) { + return true; + } + if (user.isViewer) { return false; } if (user.isAdmin) { diff --git a/server/routes/api/integrations/integrations.test.ts b/server/routes/api/integrations/integrations.test.ts index 9a86b0024..6a72eb132 100644 --- a/server/routes/api/integrations/integrations.test.ts +++ b/server/routes/api/integrations/integrations.test.ts @@ -1,8 +1,4 @@ -import { - IntegrationService, - UserCreatableIntegrationService, - IntegrationType, -} from "@shared/types"; +import { IntegrationService, IntegrationType } from "@shared/types"; import { IntegrationAuthentication, User } from "@server/models"; import Integration from "@server/models/Integration"; import { @@ -108,7 +104,7 @@ describe("#integrations.create", () => { body: { token: admin.getJwtToken(), type: IntegrationType.Embed, - service: UserCreatableIntegrationService.Diagrams, + service: IntegrationService.Diagrams, settings: { url: "not a url" }, }, }); @@ -124,16 +120,14 @@ describe("#integrations.create", () => { body: { token: admin.getJwtToken(), type: IntegrationType.Analytics, - service: UserCreatableIntegrationService.GoogleAnalytics, + service: IntegrationService.GoogleAnalytics, settings: { measurementId: "123" }, }, }); const body = await res.json(); expect(res.status).toEqual(200); expect(body.data.type).toEqual(IntegrationType.Analytics); - expect(body.data.service).toEqual( - UserCreatableIntegrationService.GoogleAnalytics - ); + expect(body.data.service).toEqual(IntegrationService.GoogleAnalytics); expect(body.data.settings).not.toBeFalsy(); expect(body.data.settings.measurementId).toEqual("123"); }); @@ -145,14 +139,14 @@ describe("#integrations.create", () => { body: { token: admin.getJwtToken(), type: IntegrationType.Embed, - service: UserCreatableIntegrationService.Grist, + service: IntegrationService.Grist, settings: { url: "https://grist.example.com" }, }, }); const body = await res.json(); expect(res.status).toEqual(200); expect(body.data.type).toEqual(IntegrationType.Embed); - expect(body.data.service).toEqual(UserCreatableIntegrationService.Grist); + expect(body.data.service).toEqual(IntegrationService.Grist); expect(body.data.settings).not.toBeFalsy(); expect(body.data.settings.url).toEqual("https://grist.example.com"); }); @@ -183,9 +177,7 @@ describe("#integrations.delete", () => { id: integration.id, }, }); - const body = await res.json(); expect(res.status).toEqual(403); - expect(body.message).toEqual("Admin role required"); }); it("should fail with status 400 bad request when id is not sent", async () => { @@ -200,6 +192,23 @@ describe("#integrations.delete", () => { expect(body.message).toEqual("id: Required"); }); + it("should succeed as user deleting own linked account integration", async () => { + const user = await buildUser(); + const linkedAccount = await buildIntegration({ + userId: user.id, + teamId: user.teamId, + service: IntegrationService.Slack, + type: IntegrationType.LinkedAccount, + }); + const res = await server.post("/api/integrations.delete", { + body: { + token: user.getJwtToken(), + id: linkedAccount.id, + }, + }); + expect(res.status).toEqual(200); + }); + it("should succeed with status 200 ok when integration is deleted", async () => { const res = await server.post("/api/integrations.delete", { body: { diff --git a/server/routes/api/integrations/integrations.ts b/server/routes/api/integrations/integrations.ts index 5a2546d56..2da5821c1 100644 --- a/server/routes/api/integrations/integrations.ts +++ b/server/routes/api/integrations/integrations.ts @@ -1,5 +1,5 @@ import Router from "koa-router"; -import { WhereOptions } from "sequelize"; +import { WhereOptions, Op } from "sequelize"; import { IntegrationType } from "@shared/types"; import auth from "@server/middlewares/authentication"; import { transaction } from "@server/middlewares/transaction"; @@ -20,19 +20,31 @@ router.post( pagination(), validate(T.IntegrationsListSchema), async (ctx: APIContext) => { - const { direction, type, sort } = ctx.input.body; + const { direction, service, type, sort } = ctx.input.body; const { user } = ctx.state.auth; let where: WhereOptions = { teamId: user.teamId, }; - if (type) { - where = { - ...where, - type, - }; + where = { ...where, type }; } + if (service) { + where = { ...where, service }; + } + + // Linked account is special as these are user-specific, other integrations are workspace-wide. + where = { + ...where, + [Op.or]: [ + { userId: user.id, type: IntegrationType.LinkedAccount }, + { + type: { + [Op.not]: IntegrationType.LinkedAccount, + }, + }, + ], + }; const integrations = await Integration.findAll({ where, @@ -121,7 +133,7 @@ router.post( router.post( "integrations.delete", - auth({ admin: true }), + auth(), validate(T.IntegrationsDeleteSchema), transaction(), async (ctx: APIContext) => { diff --git a/server/routes/api/integrations/schema.ts b/server/routes/api/integrations/schema.ts index 47ffc645f..30a4981a6 100644 --- a/server/routes/api/integrations/schema.ts +++ b/server/routes/api/integrations/schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { IntegrationType, + IntegrationService, UserCreatableIntegrationService, } from "@shared/types"; import { Integration } from "@server/models"; @@ -24,6 +25,9 @@ export const IntegrationsListSchema = BaseSchema.extend({ /** Integration type */ type: z.nativeEnum(IntegrationType).optional(), + + /** Integration service */ + service: z.nativeEnum(IntegrationService).optional(), }), }); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index d9129e04c..b09a86100 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -760,6 +760,10 @@ "Copied": "Copied", "Revoking": "Revoking", "Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?", + "Disconnect integration": "Disconnect integration", + "Connected": "Connected", + "Disconnect": "Disconnect", + "Disconnecting": "Disconnecting", "Allowed domains": "Allowed domains", "The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts.": "The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts.", "Remove domain": "Remove domain", @@ -897,7 +901,6 @@ "New users will first need to be invited to create an account. Default role and Allowed domains will no longer apply.": "New users will first need to be invited to create an account. Default role and Allowed domains will no longer apply.", "Settings that impact the access, security, and content of your workspace.": "Settings that impact the access, security, and content of your workspace.", "Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}", - "Connected": "Connected", "Disabled": "Disabled", "Allow members to sign-in using their email address": "Allow members to sign-in using their email address", "The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting", @@ -948,20 +951,25 @@ "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", - "Disconnect": "Disconnect", + "This will prevent any future updates from being posted to this Slack channel. Are you sure?": "This will prevent any future updates from being posted to this Slack channel. Are you sure?", "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. 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 {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.", - "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.", + "Personal account": "Personal account", + "Link your {{appName}} account to Slack to enable searching the documents you have access to directly within chat.": "Link your {{appName}} account to Slack to enable searching the documents you have access to directly within chat.", + "Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?": "Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?", "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.", + "Slash command": "Slash command", + "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.", + "This will remove the Outline slash command from your Slack workspace. Are you sure?": "This will remove the Outline slash command from your Slack workspace. Are you sure?", + "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.", "How to use {{ command }}": "How to use {{ command }}", "To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.": "To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.", - "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.", - "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited": "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited", "Post to Channel": "Post to Channel", "This is what we found for \"{{ term }}\"": "This is what we found for \"{{ term }}\"", "No results for \"{{ term }}\"": "No results for \"{{ term }}\"", + "It looks like you haven’t linked your {{ appName }} account to Slack yet": "It looks like you haven’t linked your {{ appName }} account to Slack yet", + "Link your account": "Link your account", + "Link your account in {{ appName }} settings to search from Slack": "Link your account in {{ appName }} settings to search from Slack", "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", diff --git a/shared/types.ts b/shared/types.ts index 5d8a13912..320327a56 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -51,7 +51,7 @@ export enum MentionType { export type PublicEnv = { ROOT_SHARE_ID?: string; analytics: { - service?: IntegrationService | UserCreatableIntegrationService; + service?: IntegrationService; settings?: IntegrationSettings; }; }; @@ -64,10 +64,16 @@ export enum AttachmentPreset { } export enum IntegrationType { + /** An integration that posts updates to an external system. */ Post = "post", + /** An integration that listens for commands from an external system. */ Command = "command", + /** An integration that embeds content from an external system. */ Embed = "embed", + /** An integration that captures analytics data. */ Analytics = "analytics", + /** An integration that maps an Outline user to an external service. */ + LinkedAccount = "linkedAccount", } export enum IntegrationService { @@ -77,11 +83,18 @@ export enum IntegrationService { GoogleAnalytics = "google-analytics", } -export enum UserCreatableIntegrationService { - Diagrams = "diagrams", - Grist = "grist", - GoogleAnalytics = "google-analytics", -} +export type UserCreatableIntegrationService = Extract< + IntegrationService, + | IntegrationService.Diagrams + | IntegrationService.Grist + | IntegrationService.GoogleAnalytics +>; + +export const UserCreatableIntegrationService = { + Diagrams: IntegrationService.Diagrams, + Grist: IntegrationService.Grist, + GoogleAnalytics: IntegrationService.GoogleAnalytics, +} as const; export enum CollectionPermission { Read = "read", @@ -107,6 +120,7 @@ export type IntegrationSettings = T extends IntegrationType.Embed | { url: string; channel: string; channelId: string } | { serviceTeamId: string } | { measurementId: string } + | { slack: { serviceTeamId: string; serviceUserId: string } } | undefined; export enum UserPreference {