mirror of
https://github.com/outline/outline.git
synced 2025-12-20 01:59:56 -06:00
Add ability to link Slack <-> Outline accounts (#6682)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
80
app/scenes/Settings/components/ConnectedButton.tsx
Normal file
80
app/scenes/Settings/components/ConnectedButton.tsx
Normal file
@@ -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<HTMLButtonElement> & {
|
||||
confirmationMessage: React.ReactNode;
|
||||
onClick: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function ConnectedButton({
|
||||
onClick,
|
||||
confirmationMessage,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleClick = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Disconnect integration"),
|
||||
content: (
|
||||
<ConfirmDisconnectDialog
|
||||
confirmationMessage={confirmationMessage}
|
||||
onSubmit={onClick}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button icon={<ConnectedIcon />} neutral onClick={handleClick} {...rest}>
|
||||
{t("Connected")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDisconnectDialog({
|
||||
confirmationMessage,
|
||||
onSubmit,
|
||||
}: {
|
||||
confirmationMessage: React.ReactNode;
|
||||
onSubmit: () => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onSubmit}
|
||||
submitText={t("Disconnect")}
|
||||
savingText={`${t("Disconnecting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{confirmationMessage}
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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%);
|
||||
}
|
||||
`;
|
||||
@@ -5,6 +5,7 @@ interface Plugin {
|
||||
config: {
|
||||
name: string;
|
||||
description: string;
|
||||
adminOnly?: boolean;
|
||||
deployments?: string[];
|
||||
};
|
||||
settings: React.FC;
|
||||
|
||||
@@ -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,7 +86,45 @@ function Slack() {
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
|
||||
<SettingRow
|
||||
name="link"
|
||||
label={t("Personal account")}
|
||||
description={
|
||||
<Trans>
|
||||
Link your {{ appName }} account to Slack to enable searching the
|
||||
documents you have access to directly within chat.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Flex align="flex-end" column>
|
||||
{linkedAccountIntegration ? (
|
||||
<ConnectedButton
|
||||
onClick={linkedAccountIntegration.delete}
|
||||
confirmationMessage={t(
|
||||
"Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<SlackButton
|
||||
redirectUri={SlackUtils.connectUrl()}
|
||||
state={SlackUtils.createState(
|
||||
team.id,
|
||||
IntegrationType.LinkedAccount
|
||||
)}
|
||||
label={t("Connect")}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
|
||||
{can.update && (
|
||||
<>
|
||||
<SettingRow
|
||||
name="slash"
|
||||
border={false}
|
||||
label={t("Slash command")}
|
||||
description={
|
||||
<Trans
|
||||
defaults="Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat."
|
||||
values={{
|
||||
@@ -87,33 +135,31 @@ function Slack() {
|
||||
em: <Code />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{env.SLACK_CLIENT_ID ? (
|
||||
<>
|
||||
<p>
|
||||
}
|
||||
>
|
||||
<Flex align="flex-end" column>
|
||||
{commandIntegration ? (
|
||||
<Button onClick={() => commandIntegration.delete()}>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
<ConnectedButton
|
||||
onClick={commandIntegration.delete}
|
||||
confirmationMessage={t(
|
||||
"This will remove the Outline slash command from your Slack workspace. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<SlackButton
|
||||
scopes={[
|
||||
"commands",
|
||||
"links:read",
|
||||
"links:write",
|
||||
// TODO: Wait forever for Slack to approve these scopes.
|
||||
// "users:read",
|
||||
// "users:read.email",
|
||||
]}
|
||||
redirectUri={SlackUtils.commandsUrl()}
|
||||
state={team.id}
|
||||
scopes={["commands", "links:read", "links:write"]}
|
||||
redirectUri={SlackUtils.connectUrl()}
|
||||
state={SlackUtils.createState(
|
||||
team.id,
|
||||
IntegrationType.Command
|
||||
)}
|
||||
icon={<SlackIcon />}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<p> </p>
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
|
||||
<h2>{t("Collections")}</h2>
|
||||
<Heading as="h2">{t("Collections")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Connect {{ appName }} collections to Slack channels. Messages will
|
||||
@@ -144,8 +190,12 @@ function Slack() {
|
||||
actions={
|
||||
<SlackButton
|
||||
scopes={["incoming-webhook"]}
|
||||
redirectUri={`${env.URL}/auth/slack.post`}
|
||||
state={collection.id}
|
||||
redirectUri={SlackUtils.connectUrl()}
|
||||
state={SlackUtils.createState(
|
||||
team.id,
|
||||
IntegrationType.Post,
|
||||
{ collectionId: collection.id }
|
||||
)}
|
||||
label={t("Connect")}
|
||||
/>
|
||||
}
|
||||
@@ -154,14 +204,6 @@ function Slack() {
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The Slack integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
the integration.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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={
|
||||
<Button onClick={integration.delete} neutral>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
<ConnectedButton
|
||||
onClick={integration.delete}
|
||||
confirmationMessage={t(
|
||||
"This will prevent any future updates from being posted to this Slack channel. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 }}"`, {
|
||||
text: 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})…`,
|
||||
}),
|
||||
attachments,
|
||||
};
|
||||
} else {
|
||||
ctx.body = {
|
||||
text: user
|
||||
? t(`No results for "{{ term }}"`, {
|
||||
text: t(`No results for "{{ term }}"`, {
|
||||
...opts(user),
|
||||
term: text,
|
||||
})
|
||||
: t(`No results for "{{ term }}"`, { ...opts(user), term: text }) +
|
||||
` (${haventSignedIn})…`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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<Team | undefined> {
|
||||
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<User | undefined> {
|
||||
// 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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<typeof SlackCommandsSchema>;
|
||||
|
||||
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)), {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<T.SlackCommandsReq>) => {
|
||||
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<T.SlackPostReq>) => {
|
||||
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
|
||||
if (!user) {
|
||||
if (collectionId) {
|
||||
let parsedState;
|
||||
try {
|
||||
const collection = await Collection.findByPk(collectionId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const team = await Team.findByPk(collection.teamId, {
|
||||
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 (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
return redirectOnClient(
|
||||
ctx,
|
||||
SlackUtils.postUrl({
|
||||
SlackUtils.connectUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
@@ -232,17 +179,30 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
}
|
||||
}
|
||||
|
||||
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.postUrl());
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
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(","),
|
||||
});
|
||||
|
||||
await Integration.create({
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create(
|
||||
{
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
userId: user.id,
|
||||
@@ -255,7 +215,69 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
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");
|
||||
}
|
||||
|
||||
ctx.redirect(SlackUtils.url);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
plugins/slack/server/presenters/userNotLinkedBlocks.ts
Normal file
38
plugins/slack/server/presenters/userNotLinkedBlocks.ts
Normal file
@@ -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,
|
||||
}
|
||||
)),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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"][] = [
|
||||
|
||||
@@ -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<string, any>
|
||||
) {
|
||||
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<T>(
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T = unknown> extends IdModel<
|
||||
|
||||
@IsIn([Object.values(IntegrationService)])
|
||||
@Column(DataType.STRING)
|
||||
service: IntegrationService | UserCreatableIntegrationService;
|
||||
service: IntegrationService;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
settings: IntegrationSettings<T>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<T.IntegrationsListReq>) => {
|
||||
const { direction, type, sort } = ctx.input.body;
|
||||
const { direction, service, type, sort } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
let where: WhereOptions<Integration> = {
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (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,
|
||||
type,
|
||||
[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<T.IntegrationsDeleteReq>) => {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> 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 <em>{{ channelName }}</em> channel on": "Posting to the <em>{{ channelName }}</em> 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 <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> 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 <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> 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",
|
||||
|
||||
@@ -51,7 +51,7 @@ export enum MentionType {
|
||||
export type PublicEnv = {
|
||||
ROOT_SHARE_ID?: string;
|
||||
analytics: {
|
||||
service?: IntegrationService | UserCreatableIntegrationService;
|
||||
service?: IntegrationService;
|
||||
settings?: IntegrationSettings<IntegrationType.Analytics>;
|
||||
};
|
||||
};
|
||||
@@ -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> = T extends IntegrationType.Embed
|
||||
| { url: string; channel: string; channelId: string }
|
||||
| { serviceTeamId: string }
|
||||
| { measurementId: string }
|
||||
| { slack: { serviceTeamId: string; serviceUserId: string } }
|
||||
| undefined;
|
||||
|
||||
export enum UserPreference {
|
||||
|
||||
Reference in New Issue
Block a user