mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 183c8b99a6 | |||
| 96f98edc83 | |||
| fa33516f1a | |||
| cc24c2f569 | |||
| 97fe4021c0 | |||
| ec3899d9c1 | |||
| 00f80bdc53 | |||
| 6b80933852 | |||
| 4e8c5aadcf | |||
| f053b3adec | |||
| ac0d3173eb | |||
| 799cdd2d8a | |||
| d1af45c5bb | |||
| 664ece84d0 | |||
| 3df7b2b7fa | |||
| 2afcccabe1 | |||
| 2402cb4394 | |||
| b944e64566 | |||
| 120ed03811 | |||
| 2a0d500d56 | |||
| 385ce45731 | |||
| cf2e863dad | |||
| 7112839cde | |||
| e2cebd4836 |
@@ -7,6 +7,8 @@ type RequestResponse<T> = {
|
||||
error: unknown;
|
||||
/** Whether the request is currently in progress. */
|
||||
loading: boolean;
|
||||
/** Whether the request has successfully completed */
|
||||
loaded: boolean;
|
||||
/** Function to start the request. */
|
||||
request: () => Promise<T | undefined>;
|
||||
};
|
||||
@@ -22,6 +24,7 @@ export default function useRequest<T = unknown>(
|
||||
): RequestResponse<T> {
|
||||
const [data, setData] = React.useState<T>();
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [loaded, setLoaded] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState();
|
||||
|
||||
const request = React.useCallback(async () => {
|
||||
@@ -29,6 +32,7 @@ export default function useRequest<T = unknown>(
|
||||
try {
|
||||
const response = await requestFn();
|
||||
setData(response);
|
||||
setLoaded(true);
|
||||
return response;
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@@ -39,5 +43,5 @@ export default function useRequest<T = unknown>(
|
||||
return undefined;
|
||||
}, [requestFn]);
|
||||
|
||||
return { data, loading, error, request };
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { filter } from "lodash";
|
||||
import { computed } from "mobx";
|
||||
import { action, computed } from "mobx";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import naturalSort from "@shared/utils/naturalSort";
|
||||
import BaseStore from "~/stores/BaseStore";
|
||||
@@ -22,6 +22,14 @@ class IntegrationsStore extends BaseStore<Integration> {
|
||||
service: IntegrationService.Slack,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async create(
|
||||
params: Partial<Integration & { authToken?: string | null }>,
|
||||
options?: Record<string, string | boolean | number | undefined>
|
||||
) {
|
||||
return super.create(params, options);
|
||||
}
|
||||
}
|
||||
|
||||
export default IntegrationsStore;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path d="m2.58 12.1 8.66-7V0L0 10zm1.64 1.36 7 5.87v-5.09L7.16 11zm18-7.38L15.32 0v5.15l4.15 3.3zm1.57 1.29-8.51 6.94v5l11.2-9.65z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { find } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import IframelyIcon from "./Icon";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type FormData = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
function Iframely() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const [disconnecting, setDisconnecting] = React.useState<boolean>(false);
|
||||
|
||||
const { loading, loaded, request } = useRequest(() =>
|
||||
integrations.fetchPage({
|
||||
type: IntegrationType.Embed,
|
||||
})
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!loaded && !loading) {
|
||||
void request();
|
||||
}
|
||||
}, [loaded, loading, request]);
|
||||
|
||||
const integration = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Iframely,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
if (data.baseUrl) {
|
||||
await integrations.create({
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Iframely,
|
||||
authToken: data.apiKey ? data.apiKey : null,
|
||||
settings: data.baseUrl
|
||||
? {
|
||||
url: data.baseUrl,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[integrations, integration, t, showToast]
|
||||
);
|
||||
|
||||
const disconnect = React.useCallback(async () => {
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
await integration?.delete();
|
||||
showToast(t("Integration disconnected"), {
|
||||
type: "success",
|
||||
});
|
||||
reset();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
}, [integration]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<Scene title={t("Iframely")} icon={<IframelyIcon />}>
|
||||
<Heading>{t("Iframely")}</Heading>
|
||||
<Text type="secondary">
|
||||
{t("Get rich previews of links in documents")}
|
||||
</Text>
|
||||
{!integration ? (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<SettingRow
|
||||
label={t("Deployment url")}
|
||||
name="url"
|
||||
description={t(
|
||||
"Optionally add your self-hosted Iframely installation url here or leave blank to use the cloud hosted Iframely."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://iframe.ly"
|
||||
pattern="https?://.*"
|
||||
{...register("baseUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("API key")}
|
||||
name="key"
|
||||
description={t(
|
||||
"Add your Iframely API key to enable previewing of links."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input {...register("apiKey")} />
|
||||
</SettingRow>
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Button onClick={disconnect} disabled={disconnecting}>
|
||||
{disconnecting ? `${t("Disconnecting")}…` : t("Disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Iframely);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
name: string;
|
||||
visible?: boolean;
|
||||
border?: boolean;
|
||||
};
|
||||
|
||||
const Row = styled(Flex)<{ $border?: boolean }>`
|
||||
display: block;
|
||||
padding: 22px 0;
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false
|
||||
? "transparent"
|
||||
: transparentize(0.5, props.theme.divider)};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Column = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-basis: 100%;
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
min-width: 70%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Label = styled(Text)`
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const SettingRow: React.FC<Props> = ({
|
||||
visible,
|
||||
description,
|
||||
name,
|
||||
label,
|
||||
border,
|
||||
children,
|
||||
}) => {
|
||||
if (visible === false) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Row gap={32} $border={border}>
|
||||
<Column>
|
||||
<Label as="h3">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
</Label>
|
||||
{description && <Text type="secondary">{description}</Text>}
|
||||
</Column>
|
||||
<Column>{children}</Column>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingRow;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Iframely",
|
||||
"description": "Adds Iframely support"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { IntegrationAuthentication, SearchQuery } from "@server/models";
|
||||
import { SearchQuery } from "@server/models";
|
||||
import { buildDocument, buildIntegration } from "@server/test/factories";
|
||||
import { seed, getTestServer } from "@server/test/support";
|
||||
import * as Slack from "../slack";
|
||||
@@ -14,11 +14,13 @@ const server = getTestServer();
|
||||
describe("#hooks.unfurl", () => {
|
||||
it("should return documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
await IntegrationAuthentication.create({
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
await buildIntegration({
|
||||
teamId: user.teamId,
|
||||
token: "",
|
||||
userId: user.id,
|
||||
service: IntegrationService.Slack,
|
||||
authentication: {
|
||||
token: "",
|
||||
},
|
||||
});
|
||||
const res = await server.post("/api/hooks.unfurl", {
|
||||
body: {
|
||||
|
||||
@@ -6,16 +6,12 @@ import { Strategy as SlackStrategy } from "passport-slack-oauth2";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import integrationCreator from "@server/commands/integrationCreator";
|
||||
import env from "@server/env";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import {
|
||||
IntegrationAuthentication,
|
||||
Collection,
|
||||
Integration,
|
||||
Team,
|
||||
User,
|
||||
} from "@server/models";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import { Collection, Team, User } from "@server/models";
|
||||
import { AppContext, AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
getClientFromContext,
|
||||
@@ -132,6 +128,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: AppContext) => {
|
||||
const { code, state, error } = ctx.request.query;
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -169,22 +166,14 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
|
||||
const endpoint = `${env.URL}/auth/slack.commands`;
|
||||
const data = await Slack.oauthAccess(String(code), endpoint);
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
await integrationCreator({
|
||||
user,
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
scopes: data.scope.split(","),
|
||||
});
|
||||
await Integration.create({
|
||||
service: IntegrationService.Slack,
|
||||
authScopes: data.scope.split(","),
|
||||
type: IntegrationType.Command,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
serviceTeamId: data.team_id,
|
||||
},
|
||||
settings: { serviceTeamId: data.team_id },
|
||||
transaction: ctx.state.transaction,
|
||||
});
|
||||
ctx.redirect(integrationSettingsPath("slack"));
|
||||
}
|
||||
@@ -195,12 +184,13 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: AppContext) => {
|
||||
const { code, error, state } = ctx.request.query;
|
||||
const { user } = ctx.state.auth;
|
||||
assertPresent(code || error, "code is required");
|
||||
|
||||
const collectionId = state;
|
||||
const collectionId = state as string | undefined;
|
||||
assertUuid(collectionId, "collectionId must be an uuid");
|
||||
|
||||
if (error) {
|
||||
@@ -235,20 +225,12 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
|
||||
const endpoint = `${env.URL}/auth/slack.post`;
|
||||
const data = await Slack.oauthAccess(code as string, endpoint);
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
await integrationCreator({
|
||||
user,
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
scopes: data.scope.split(","),
|
||||
});
|
||||
|
||||
await Integration.create({
|
||||
service: IntegrationService.Slack,
|
||||
authScopes: data.scope.split(","),
|
||||
type: IntegrationType.Post,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
collectionId,
|
||||
events: ["documents.update", "documents.publish"],
|
||||
settings: {
|
||||
@@ -256,6 +238,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
channel: data.incoming_webhook.channel,
|
||||
channelId: data.incoming_webhook.channel_id,
|
||||
},
|
||||
transaction: ctx.state.transaction,
|
||||
});
|
||||
ctx.redirect(integrationSettingsPath("slack"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { User } from "@server/models";
|
||||
import Integration, {
|
||||
UserCreatableIntegrationService,
|
||||
} from "@server/models/Integration";
|
||||
import { buildAdmin, buildCollection } from "@server/test/factories";
|
||||
import { setupTestDatabase } from "@server/test/support";
|
||||
import integrationCreator from "./integrationCreator";
|
||||
|
||||
setupTestDatabase();
|
||||
|
||||
describe("#integrationCreator", () => {
|
||||
let integration: Integration;
|
||||
let user: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await buildAdmin();
|
||||
const intg = await Integration.findOne();
|
||||
expect(intg).toBeNull();
|
||||
});
|
||||
|
||||
it("should create and return integration", async () => {
|
||||
integration = await sequelize.transaction(async (transaction) =>
|
||||
integrationCreator({
|
||||
user,
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Diagrams,
|
||||
settings: { url: "https://example.com" },
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
const intg = await Integration.findOne();
|
||||
expect(intg).not.toBeNull();
|
||||
expect(intg?.id).toEqual(integration.id);
|
||||
});
|
||||
|
||||
it("should create an integration with its auth and return the integration", async () => {
|
||||
integration = await sequelize.transaction(async (transaction) =>
|
||||
integrationCreator({
|
||||
user,
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Diagrams,
|
||||
settings: { url: "https://example.com" },
|
||||
token: "token",
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
const intg = await Integration.scope("withAuthentication").findOne();
|
||||
expect(intg).not.toBeNull();
|
||||
expect(intg?.id).toEqual(integration.id);
|
||||
expect(intg?.authenticationId).not.toBeNull();
|
||||
expect(intg?.authentication.token).toEqual("token");
|
||||
});
|
||||
|
||||
it("should successfully create slack command integration", async () => {
|
||||
integration = await sequelize.transaction(async (transaction) =>
|
||||
integrationCreator({
|
||||
user,
|
||||
type: IntegrationType.Command,
|
||||
service: IntegrationService.Slack,
|
||||
token: "token",
|
||||
authScopes: ["scope1", "scope2"],
|
||||
settings: { serviceTeamId: "someid" },
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
const intg = await Integration.scope("withAuthentication").findOne();
|
||||
expect(intg).not.toBeNull();
|
||||
expect(intg?.id).toEqual(integration.id);
|
||||
expect(intg?.authenticationId).not.toBeNull();
|
||||
expect(intg?.authentication.token).toEqual("token");
|
||||
expect(intg?.authentication.scopes).toContain("scope1");
|
||||
expect(intg?.authentication.scopes).toContain("scope2");
|
||||
});
|
||||
|
||||
it("should successfully create slack post integration", async () => {
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
});
|
||||
integration = await sequelize.transaction(async (transaction) =>
|
||||
integrationCreator({
|
||||
user,
|
||||
type: IntegrationType.Post,
|
||||
service: IntegrationService.Slack,
|
||||
token: "token",
|
||||
events: ["documents.update", "documents.publish"],
|
||||
collectionId: collection.id,
|
||||
authScopes: ["scope1", "scope2"],
|
||||
settings: {
|
||||
url: "https://foo.bar",
|
||||
channel: "channel",
|
||||
channelId: "channelId",
|
||||
},
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
|
||||
const intg = (await Integration.scope(
|
||||
"withAuthentication"
|
||||
).findOne()) as Integration<IntegrationType.Post>;
|
||||
expect(intg).not.toBeNull();
|
||||
expect(intg?.id).toEqual(integration.id);
|
||||
expect(intg?.type).toEqual(IntegrationType.Post);
|
||||
expect(intg?.service).toEqual(IntegrationService.Slack);
|
||||
expect(intg?.collectionId).toEqual(integration.collectionId);
|
||||
expect(intg?.events).toContain("documents.update");
|
||||
expect(intg?.events).toContain("documents.publish");
|
||||
expect(intg?.settings).not.toBeNull();
|
||||
expect(intg?.settings?.url).toEqual("https://foo.bar");
|
||||
expect(intg?.settings.channel).toEqual("channel");
|
||||
expect(intg?.settings.channelId).toEqual("channelId");
|
||||
expect(intg?.authenticationId).not.toBeNull();
|
||||
expect(intg?.authentication.token).toEqual("token");
|
||||
expect(intg?.authentication.scopes).toContain("scope1");
|
||||
expect(intg?.authentication.scopes).toContain("scope2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
} from "@shared/types";
|
||||
import { User, Integration } from "@server/models";
|
||||
import { UserCreatableIntegrationService } from "@server/models/Integration";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
service: UserCreatableIntegrationService | IntegrationService;
|
||||
settings: IntegrationSettings<unknown>;
|
||||
type: IntegrationType;
|
||||
authScopes?: string[];
|
||||
token?: string | null;
|
||||
collectionId?: string;
|
||||
events?: string[];
|
||||
transaction: Transaction;
|
||||
};
|
||||
|
||||
export default async function integrationCreator({
|
||||
user,
|
||||
service,
|
||||
settings,
|
||||
type,
|
||||
authScopes,
|
||||
token,
|
||||
collectionId,
|
||||
events,
|
||||
transaction,
|
||||
}: Props) {
|
||||
return Integration.create(
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
service,
|
||||
settings,
|
||||
collectionId,
|
||||
events,
|
||||
type,
|
||||
authentication: token
|
||||
? {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
service,
|
||||
scopes: authScopes,
|
||||
token,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
include: token
|
||||
? [
|
||||
{
|
||||
association: "authentication",
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import Fix from "./decorators/Fix";
|
||||
export enum UserCreatableIntegrationService {
|
||||
Diagrams = "diagrams",
|
||||
GoogleAnalytics = "google-analytics",
|
||||
Iframely = "iframely",
|
||||
}
|
||||
|
||||
@Scopes(() => ({
|
||||
@@ -27,7 +28,6 @@ export enum UserCreatableIntegrationService {
|
||||
{
|
||||
model: IntegrationAuthentication,
|
||||
as: "authentication",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -77,7 +77,7 @@ class Integration<T = unknown> extends IdModel {
|
||||
|
||||
@ForeignKey(() => IntegrationAuthentication)
|
||||
@Column(DataType.UUID)
|
||||
authenticationId: string;
|
||||
authenticationId?: string | null;
|
||||
}
|
||||
|
||||
export default Integration;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
buildAdmin,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
buildIntegration,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#integrations.update", () => {
|
||||
it("should allow updating integration events", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const integration = await buildIntegration({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/integrations.update", {
|
||||
body: {
|
||||
events: ["documents.update"],
|
||||
token: user.getJwtToken(),
|
||||
id: integration.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(integration.id);
|
||||
expect(body.data.events.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const user = await buildUser();
|
||||
const integration = await buildIntegration({
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/integrations.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: integration.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import Router from "koa-router";
|
||||
import { has } from "lodash";
|
||||
import { WhereOptions } from "sequelize";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Event } from "@server/models";
|
||||
import Integration, {
|
||||
UserCreatableIntegrationService,
|
||||
} from "@server/models/Integration";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentIntegration } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import {
|
||||
assertSort,
|
||||
assertUuid,
|
||||
assertArray,
|
||||
assertIn,
|
||||
assertUrl,
|
||||
} from "@server/validation";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"integrations.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
async (ctx: APIContext) => {
|
||||
let { direction } = ctx.request.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { type, sort = "updatedAt" } = ctx.request.body;
|
||||
if (direction !== "ASC") {
|
||||
direction = "DESC";
|
||||
}
|
||||
assertSort(sort, Integration);
|
||||
|
||||
let where: WhereOptions<Integration> = {
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (type) {
|
||||
assertIn(type, Object.values(IntegrationType));
|
||||
where = {
|
||||
...where,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
const integrations = await Integration.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: integrations.map(presentIntegration),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"integrations.create",
|
||||
auth({ admin: true }),
|
||||
async (ctx: APIContext) => {
|
||||
const { type, service, settings } = ctx.request.body;
|
||||
|
||||
assertIn(type, Object.values(IntegrationType));
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "createIntegration", user.team);
|
||||
|
||||
assertIn(service, Object.values(UserCreatableIntegrationService));
|
||||
|
||||
if (has(settings, "url")) {
|
||||
assertUrl(settings.url);
|
||||
}
|
||||
|
||||
const integration = await Integration.create({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
service,
|
||||
settings,
|
||||
type,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentIntegration(integration),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"integrations.update",
|
||||
auth({ admin: true }),
|
||||
async (ctx: APIContext) => {
|
||||
const { id, events = [], settings } = ctx.request.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const integration = await Integration.findByPk(id);
|
||||
authorize(user, "update", integration);
|
||||
|
||||
assertArray(events, "events must be an array");
|
||||
|
||||
if (has(settings, "url")) {
|
||||
assertUrl(settings.url);
|
||||
}
|
||||
|
||||
if (integration.type === IntegrationType.Post) {
|
||||
integration.events = events.filter((event: string) =>
|
||||
["documents.update", "documents.publish"].includes(event)
|
||||
);
|
||||
}
|
||||
|
||||
integration.settings = settings;
|
||||
|
||||
await integration.save();
|
||||
|
||||
ctx.body = {
|
||||
data: presentIntegration(integration),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"integrations.delete",
|
||||
auth({ admin: true }),
|
||||
async (ctx: APIContext) => {
|
||||
const { id } = ctx.request.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const integration = await Integration.findByPk(id);
|
||||
authorize(user, "delete", integration);
|
||||
|
||||
await integration.destroy();
|
||||
await Event.create({
|
||||
name: "integrations.delete",
|
||||
modelId: integration.id,
|
||||
teamId: integration.teamId,
|
||||
actorId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./integrations";
|
||||
@@ -0,0 +1,361 @@
|
||||
import { isUndefined, uniq } from "lodash";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { IntegrationAuthentication, User } from "@server/models";
|
||||
import Integration, {
|
||||
UserCreatableIntegrationService,
|
||||
} from "@server/models/Integration";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
buildIntegration,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#integrations.list", () => {
|
||||
let admin: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await buildAdmin();
|
||||
const anotherAdmin = await buildAdmin();
|
||||
await Promise.all([
|
||||
buildIntegration({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
type: IntegrationType.Embed,
|
||||
}),
|
||||
buildIntegration({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
authentication: {
|
||||
token: "token",
|
||||
},
|
||||
}),
|
||||
buildIntegration({
|
||||
userId: anotherAdmin.id,
|
||||
teamId: anotherAdmin.teamId,
|
||||
}),
|
||||
buildIntegration({
|
||||
userId: anotherAdmin.id,
|
||||
teamId: anotherAdmin.teamId,
|
||||
authentication: {
|
||||
token: "token",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request for an invalid sort param value", async () => {
|
||||
const res = await server.post("/api/integrations.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
sort: "anysort",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.message).toBe("sort: Invalid sort parameter");
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request for an invalid type param value", async () => {
|
||||
const res = await server.post("/api/integrations.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: "anytype",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.message).toContain("type: Invalid enum value");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok but not return authToken in response when the user is not an admin", async () => {
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/integrations.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(
|
||||
body.data.filter((d: any) => !isUndefined(d.authToken))
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok but not return authToken in response when the user is an admin", async () => {
|
||||
const res = await server.post("/api/integrations.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
const integrations = body.data.filter(
|
||||
(d: any) => !isUndefined(d.authToken)
|
||||
);
|
||||
expect(integrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok and only return integrations belonging to user's team", async () => {
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/integrations.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
const teamIds = uniq(body.data.map((d: any) => d.teamId));
|
||||
expect(teamIds).toHaveLength(1);
|
||||
expect(teamIds[0]).toBe(user.teamId);
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok and only return integrations of the requested type", async () => {
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/integrations.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
type: IntegrationType.Embed,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].type).toBe(IntegrationType.Embed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#integrations.create", () => {
|
||||
it("should fail with status 400 bad request for an invalid url value supplied in settings param", async () => {
|
||||
const admin = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/integrations.create", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Diagrams,
|
||||
settings: { url: "not a url" },
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("url: Invalid url");
|
||||
});
|
||||
|
||||
it("should fail with status 403 unauthorized when the user is not an admin", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/integrations.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Diagrams,
|
||||
settings: { url: "https://example.com" },
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body.message).toEqual("Admin role required");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when integration authToken is not supplied", async () => {
|
||||
const admin = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/integrations.create", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Diagrams,
|
||||
settings: { url: "https://example.com" },
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.userId).toEqual(admin.id);
|
||||
expect(body.data.teamId).toEqual(admin.teamId);
|
||||
expect(body.data.type).toEqual(IntegrationType.Embed);
|
||||
expect(body.data.service).toEqual(UserCreatableIntegrationService.Diagrams);
|
||||
expect(body.data.authenticationId).toBeNull();
|
||||
expect(body.data.authToken).toBeUndefined();
|
||||
expect(body.data.settings.url).toEqual("https://example.com");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when integration authToken is supplied", async () => {
|
||||
const admin = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/integrations.create", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Diagrams,
|
||||
settings: { url: "https://example.com" },
|
||||
authToken: "token",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.userId).toEqual(admin.id);
|
||||
expect(body.data.teamId).toEqual(admin.teamId);
|
||||
expect(body.data.type).toEqual(IntegrationType.Embed);
|
||||
expect(body.data.service).toEqual(UserCreatableIntegrationService.Diagrams);
|
||||
expect(body.data.authenticationId).not.toBeNull();
|
||||
expect(body.data.authToken).toBeUndefined();
|
||||
expect(body.data.settings.url).toEqual("https://example.com");
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request when authToken is supplied without url", async () => {
|
||||
const admin = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/integrations.create", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: IntegrationType.Embed,
|
||||
service: UserCreatableIntegrationService.Iframely,
|
||||
authToken: "token",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("url not provided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#integrations.update", () => {
|
||||
it("should allow updating integration events", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const integration = await buildIntegration({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/integrations.update", {
|
||||
body: {
|
||||
events: ["documents.update"],
|
||||
token: user.getJwtToken(),
|
||||
id: integration.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(integration.id);
|
||||
expect(body.data.events.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const user = await buildUser();
|
||||
const integration = await buildIntegration({
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/integrations.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: integration.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#integrations.delete", () => {
|
||||
let admin: User;
|
||||
let integration: Integration;
|
||||
let integrationWithAuth: Integration;
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await buildAdmin();
|
||||
|
||||
integration = await buildIntegration({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
service: IntegrationService.Diagrams,
|
||||
type: IntegrationType.Embed,
|
||||
settings: { url: "https://example.com" },
|
||||
});
|
||||
|
||||
integrationWithAuth = await buildIntegration({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
authentication: {
|
||||
token: "token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail with status 403 unauthorized when the user is not an admin", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/integrations.delete", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
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 () => {
|
||||
const res = await server.post("/api/integrations.delete", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("id: Required");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 when an integration without auth is deleted", async () => {
|
||||
const res = await server.post("/api/integrations.delete", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: integration.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
const intg = await Integration.findByPk(integration.id);
|
||||
expect(intg).toBeNull();
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when integration with auth is deleted", async () => {
|
||||
const res = await server.post("/api/integrations.delete", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: integrationWithAuth.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
const intg = await Integration.findByPk(integrationWithAuth.id);
|
||||
expect(intg).toBeNull();
|
||||
|
||||
const auth = await IntegrationAuthentication.findByPk(
|
||||
integrationWithAuth.authenticationId!
|
||||
);
|
||||
expect(auth).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import Router from "koa-router";
|
||||
import { WhereOptions } from "sequelize";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import integrationCreator from "@server/commands/integrationCreator";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Event, Integration, IntegrationAuthentication } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentIntegration } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"integrations.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.IntegrationsListSchema),
|
||||
async (ctx: APIContext<T.IntegrationsListReq>) => {
|
||||
const { direction, type, sort } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
let where: WhereOptions<Integration> = {
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (type) {
|
||||
where = {
|
||||
...where,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
const integrations = await Integration.scope([
|
||||
"withAuthentication",
|
||||
]).findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: integrations.map(presentIntegration),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"integrations.create",
|
||||
transaction(),
|
||||
auth({ admin: true }),
|
||||
validate(T.IntegrationsCreateSchema),
|
||||
async (ctx: APIContext<T.IntegrationsCreateReq>) => {
|
||||
const { type, service, settings, authToken: token } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "createIntegration", user.team);
|
||||
|
||||
const integration = await integrationCreator({
|
||||
user,
|
||||
type,
|
||||
service,
|
||||
settings,
|
||||
token,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentIntegration(integration),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"integrations.update",
|
||||
transaction(),
|
||||
auth({ admin: true }),
|
||||
validate(T.IntegrationsUpdateSchema),
|
||||
async (ctx: APIContext<T.IntegrationsUpdateReq>) => {
|
||||
const { id, events, settings } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const integration = await Integration.findByPk(id, { transaction });
|
||||
authorize(user, "update", integration);
|
||||
|
||||
if (integration.type === IntegrationType.Post) {
|
||||
integration.events = events.filter((event: string) =>
|
||||
["documents.update", "documents.publish"].includes(event)
|
||||
);
|
||||
}
|
||||
|
||||
integration.settings = settings;
|
||||
|
||||
await integration.save({ transaction });
|
||||
|
||||
ctx.body = {
|
||||
data: presentIntegration(integration),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"integrations.delete",
|
||||
auth({ admin: true }),
|
||||
transaction(),
|
||||
validate(T.IntegrationsDeleteSchema),
|
||||
async (ctx: APIContext<T.IntegrationsDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const integration = await Integration.findByPk(id, { transaction });
|
||||
authorize(user, "delete", integration);
|
||||
|
||||
await integration.destroy({ transaction });
|
||||
// also remove the corresponding authentication if it exists
|
||||
if (integration.authenticationId) {
|
||||
await IntegrationAuthentication.destroy({
|
||||
where: {
|
||||
id: integration.authenticationId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "integrations.delete",
|
||||
modelId: integration.id,
|
||||
teamId: integration.teamId,
|
||||
actorId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { Integration } from "@server/models";
|
||||
import { UserCreatableIntegrationService } from "@server/models/Integration";
|
||||
import BaseSchema from "../BaseSchema";
|
||||
|
||||
export const IntegrationsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Integrations sorting direction */
|
||||
direction: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||
|
||||
/** Integrations sorting column */
|
||||
sort: z
|
||||
.string()
|
||||
.refine((val) => Object.keys(Integration.getAttributes()).includes(val), {
|
||||
message: "Invalid sort parameter",
|
||||
})
|
||||
.default("updatedAt"),
|
||||
|
||||
/** Integration type */
|
||||
type: z.nativeEnum(IntegrationType).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type IntegrationsListReq = z.infer<typeof IntegrationsListSchema>;
|
||||
|
||||
export const IntegrationsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Integration type */
|
||||
type: z.nativeEnum(IntegrationType),
|
||||
|
||||
/** Integration service */
|
||||
service: z.nativeEnum(UserCreatableIntegrationService),
|
||||
|
||||
/** Integration config/settings */
|
||||
settings: z
|
||||
.object({ url: z.string().url() })
|
||||
.or(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
channel: z.string(),
|
||||
channelId: z.string(),
|
||||
})
|
||||
)
|
||||
.or(z.object({ measurementId: z.string() }))
|
||||
.or(z.object({ serviceTeamId: z.string() }))
|
||||
.optional(),
|
||||
|
||||
/** Integration token */
|
||||
authToken: z.string().nullish(),
|
||||
}),
|
||||
}).refine(
|
||||
(req) =>
|
||||
!(
|
||||
req.body.authToken &&
|
||||
!(req.body.settings && (req.body.settings as any).url)
|
||||
),
|
||||
{
|
||||
message: "url not provided",
|
||||
}
|
||||
);
|
||||
|
||||
export type IntegrationsCreateReq = z.infer<typeof IntegrationsCreateSchema>;
|
||||
|
||||
export const IntegrationsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Id of integration that needs update */
|
||||
id: z.string().uuid(),
|
||||
|
||||
/** Integration config/settings */
|
||||
settings: z
|
||||
.object({ url: z.string().url() })
|
||||
.or(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
channel: z.string(),
|
||||
channelId: z.string(),
|
||||
})
|
||||
)
|
||||
.or(z.object({ measurementId: z.string() }))
|
||||
.or(z.object({ serviceTeamId: z.string() }))
|
||||
.optional(),
|
||||
|
||||
/** Integration events */
|
||||
events: z.array(z.string()).optional().default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
export type IntegrationsUpdateReq = z.infer<typeof IntegrationsUpdateSchema>;
|
||||
|
||||
export const IntegrationsDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Id of integration to be deleted */
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type IntegrationsDeleteReq = z.infer<typeof IntegrationsDeleteSchema>;
|
||||
+47
-20
@@ -235,32 +235,59 @@ export async function buildInvite(overrides: Partial<User> = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildIntegration(overrides: Partial<Integration> = {}) {
|
||||
export async function buildIntegration(
|
||||
overrides: Partial<
|
||||
Omit<Integration, "authentication"> & {
|
||||
authentication: Partial<IntegrationAuthentication>;
|
||||
}
|
||||
> = {}
|
||||
) {
|
||||
if (!overrides.teamId) {
|
||||
const team = await buildTeam();
|
||||
overrides.teamId = team.id;
|
||||
}
|
||||
|
||||
const user = await buildUser({
|
||||
teamId: overrides.teamId,
|
||||
});
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: "fake-access-token",
|
||||
scopes: ["example", "scopes", "here"],
|
||||
});
|
||||
return Integration.create({
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
events: ["documents.update", "documents.publish"],
|
||||
settings: {
|
||||
serviceTeamId: "slack_team_id",
|
||||
if (!overrides.userId) {
|
||||
const user = await buildUser({
|
||||
teamId: overrides.teamId,
|
||||
});
|
||||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
return Integration.create(
|
||||
{
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
events: ["documents.update", "documents.publish"],
|
||||
settings: {
|
||||
serviceTeamId: "slack_team_id",
|
||||
},
|
||||
...overrides,
|
||||
authentication: overrides.authentication
|
||||
? {
|
||||
service:
|
||||
overrides.authentication.service ?? IntegrationService.Slack,
|
||||
userId: overrides.userId,
|
||||
teamId: overrides.teamId,
|
||||
token: overrides.authentication.token ?? "fake-access-token",
|
||||
scopes: overrides.authentication.scopes ?? [
|
||||
"example",
|
||||
"scopes",
|
||||
"here",
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
authenticationId: authentication.id,
|
||||
...overrides,
|
||||
});
|
||||
{
|
||||
include: overrides.authentication
|
||||
? [
|
||||
{
|
||||
association: "authentication",
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildCollection(
|
||||
|
||||
@@ -67,6 +67,7 @@ export default class Mention extends Suggestion {
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorId": node.attrs.actorId,
|
||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
},
|
||||
node.attrs.label,
|
||||
],
|
||||
|
||||
@@ -861,12 +861,20 @@
|
||||
"This month": "This month",
|
||||
"Last month": "Last month",
|
||||
"This year": "This year",
|
||||
"Integration disconnected": "Integration disconnected",
|
||||
"Iframely": "Iframely",
|
||||
"Get rich previews of links in documents": "Get rich previews of links in documents",
|
||||
"Deployment url": "Deployment url",
|
||||
"Optionally add your self-hosted Iframely installation url here or leave blank to use the cloud hosted Iframely.": "Optionally add your self-hosted Iframely installation url here or leave blank to use the cloud hosted Iframely.",
|
||||
"API key": "API key",
|
||||
"Add your Iframely API key to enable previewing of links.": "Add your Iframely API key to enable previewing of links.",
|
||||
"Disconnecting": "Disconnecting",
|
||||
"Disconnect": "Disconnect",
|
||||
"Add to Slack": "Add to Slack",
|
||||
"document published": "document published",
|
||||
"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",
|
||||
"Whoops, you need to accept the permissions in Slack to connect {{appName}} to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your team. Try again?",
|
||||
"Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?",
|
||||
"Get rich previews of {{ 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.",
|
||||
|
||||
+4
-2
@@ -81,6 +81,7 @@ export enum IntegrationService {
|
||||
Diagrams = "diagrams",
|
||||
Slack = "slack",
|
||||
GoogleAnalytics = "google-analytics",
|
||||
Iframely = "iframely",
|
||||
}
|
||||
|
||||
export enum CollectionPermission {
|
||||
@@ -95,13 +96,14 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
? { measurementId: string }
|
||||
: T extends IntegrationType.Post
|
||||
? { url: string; channel: string; channelId: string }
|
||||
: T extends IntegrationType.Post
|
||||
: T extends IntegrationType.Command
|
||||
? { serviceTeamId: string }
|
||||
:
|
||||
| { url: string }
|
||||
| { url: string; channel: string; channelId: string }
|
||||
| { serviceTeamId: string }
|
||||
| { measurementId: string };
|
||||
| { measurementId: string }
|
||||
| undefined;
|
||||
|
||||
export enum UserPreference {
|
||||
/** Whether reopening the app should redirect to the last viewed document. */
|
||||
|
||||
Reference in New Issue
Block a user