Compare commits

...

24 Commits

Author SHA1 Message Date
Apoorv Mishra 183c8b99a6 fix: restore integrations.update 2023-07-04 21:29:21 +05:30
Apoorv Mishra 96f98edc83 fix: buildIntegration 2023-07-04 20:50:07 +05:30
Apoorv Mishra fa33516f1a fix: creation and deletion 2023-07-04 18:57:42 +05:30
Apoorv Mishra cc24c2f569 fix: lint 2023-07-03 13:10:17 +05:30
Apoorv Mishra 97fe4021c0 feat: iframely integration page 2023-07-03 12:53:34 +05:30
Apoorv Mishra ec3899d9c1 fix: token should not be added without url 2023-07-03 12:53:34 +05:30
Apoorv Mishra 00f80bdc53 fix: integrationUpdater 2023-07-03 12:53:34 +05:30
Apoorv Mishra 6b80933852 fix: use transaction 2023-07-03 12:53:34 +05:30
Apoorv Mishra 4e8c5aadcf fix: reuse integrationCreator in auth/slack 2023-07-03 12:53:34 +05:30
Apoorv Mishra f053b3adec fix: add integrationCreator and integrate with api/integrations 2023-07-03 12:53:34 +05:30
Apoorv Mishra ac0d3173eb fix: tests for integrations.delete 2023-07-03 12:53:34 +05:30
Apoorv Mishra 799cdd2d8a fix: tests for integrations.update 2023-07-03 12:53:34 +05:30
Apoorv Mishra d1af45c5bb trigger ci 2023-07-03 12:53:34 +05:30
Apoorv Mishra 664ece84d0 fix: tests for integrations.create 2023-07-03 12:53:34 +05:30
Apoorv Mishra 3df7b2b7fa fix: tests for integrations.list 2023-07-03 12:53:34 +05:30
Apoorv Mishra 2afcccabe1 fix: allow admins to view token 2023-07-03 12:53:34 +05:30
Apoorv Mishra 2402cb4394 fix: support adding token for integrations 2023-07-03 12:53:34 +05:30
Apoorv Mishra b944e64566 fix: improve buildIntegration to support auth as well 2023-07-03 12:53:34 +05:30
Apoorv Mishra 120ed03811 fix: tests 2023-07-03 12:53:34 +05:30
Apoorv Mishra 2a0d500d56 fix: remove on delete cascade 2023-07-03 12:53:34 +05:30
Apoorv Mishra 385ce45731 fix: model changes 2023-07-03 12:53:34 +05:30
Apoorv Mishra cf2e863dad trigger ci 2023-07-03 12:53:34 +05:30
Apoorv Mishra 7112839cde feat: migration 2023-07-03 12:53:34 +05:30
Apoorv Mishra e2cebd4836 fix: add data-url to mention span 2023-07-03 12:53:34 +05:30
21 changed files with 1159 additions and 263 deletions
+5 -1
View File
@@ -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 };
}
+9 -1
View File
@@ -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;
+22
View File
@@ -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>
);
}
+153
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
{
"name": "Iframely",
"description": "Adds Iframely support"
}
+7 -5
View File
@@ -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: {
+15 -32
View File
@@ -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"));
}
+122
View File
@@ -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");
});
});
+63
View File
@@ -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,
}
);
}
+2 -2
View File
@@ -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;
-46
View File
@@ -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);
});
});
-153
View File
@@ -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;
+1
View File
@@ -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;
+101
View File
@@ -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
View File
@@ -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(
+1
View File
@@ -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,
],
+9 -1
View File
@@ -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
View File
@@ -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. */