mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9924a89d3a | |||
| 992ae61432 | |||
| 5244dcd901 | |||
| 6ed6b8711e | |||
| 55088fb122 | |||
| 97e4c2dfd6 | |||
| a9612f1e61 | |||
| 8da6b3fc8b | |||
| 38cd2f67eb |
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
|
||||
@@ -23,7 +23,10 @@ function Dialogs() {
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
|
||||
@@ -15,16 +15,90 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import FileOperationListItem from "./components/FileOperationListItem";
|
||||
import ImportJSONDialog from "./components/ImportJSONDialog";
|
||||
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
|
||||
import ImportNotionDialog from "./components/ImportNotionDialog";
|
||||
|
||||
type Config = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ReactElement;
|
||||
action: React.ReactElement;
|
||||
};
|
||||
|
||||
function Import() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs, fileOperations } = useStores();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
const configs = React.useMemo(() => {
|
||||
const items: Config[] = [
|
||||
{
|
||||
title: t("Markdown"),
|
||||
subtitle: t(
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
|
||||
),
|
||||
icon: <MarkdownIcon size={28} />,
|
||||
action: (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportMarkdownDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "JSON",
|
||||
subtitle: t(
|
||||
"Import a JSON data file exported from another {{ appName }} instance",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
),
|
||||
icon: <OutlineIcon size={28} cover />,
|
||||
action: (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportJSONDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
|
||||
items.push({ ...plugin.value });
|
||||
});
|
||||
|
||||
items.push({
|
||||
title: "Confluence",
|
||||
subtitle: t("Import pages from a Confluence instance"),
|
||||
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
|
||||
action: (
|
||||
<Button type="submit" disabled neutral>
|
||||
{t("Enterprise")}
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [t, dialogs, appName]);
|
||||
|
||||
return (
|
||||
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
|
||||
<Heading>{t("Import")}</Heading>
|
||||
@@ -38,84 +112,16 @@ function Import() {
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<Item
|
||||
border={false}
|
||||
image={<MarkdownIcon size={28} />}
|
||||
title={t("Markdown")}
|
||||
subtitle={t(
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportMarkdownDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<OutlineIcon size={28} cover />}
|
||||
title="JSON"
|
||||
subtitle={t(
|
||||
"Import a JSON data file exported from another {{ appName }} instance",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportJSONDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<img src={cdnPath("/images/notion.png")} width={28} />}
|
||||
title="Notion"
|
||||
subtitle={t("Import pages exported from Notion")}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportNotionDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
|
||||
title="Confluence"
|
||||
subtitle={t("Import pages from a Confluence instance")}
|
||||
actions={
|
||||
<Button type="submit" disabled neutral>
|
||||
{t("Enterprise")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{configs.map((config) => (
|
||||
<Item
|
||||
key={config.title}
|
||||
title={config.title}
|
||||
subtitle={config.subtitle}
|
||||
image={config.icon}
|
||||
actions={config.action}
|
||||
border={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
<PaginatedList
|
||||
|
||||
@@ -8,6 +8,7 @@ type DialogDefinition = {
|
||||
isOpen: boolean;
|
||||
fullscreen?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default class DialogsStore {
|
||||
@@ -50,6 +51,7 @@ export default class DialogsStore {
|
||||
fullscreen,
|
||||
replace,
|
||||
style,
|
||||
onClose,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -57,6 +59,7 @@ export default class DialogsStore {
|
||||
content: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
replace?: boolean;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
setTimeout(
|
||||
action(() => {
|
||||
@@ -70,6 +73,7 @@ export default class DialogsStore {
|
||||
fullscreen,
|
||||
style,
|
||||
isOpen: true,
|
||||
onClose,
|
||||
});
|
||||
}),
|
||||
0
|
||||
|
||||
@@ -12,6 +12,7 @@ import isCloudHosted from "./isCloudHosted";
|
||||
*/
|
||||
export enum Hook {
|
||||
Settings = "settings",
|
||||
Imports = "imports",
|
||||
Icon = "icon",
|
||||
}
|
||||
|
||||
@@ -31,6 +32,12 @@ type PluginValueMap = {
|
||||
/** Whether the plugin is enabled in the current context. */
|
||||
enabled?: (team: Team, user: User) => boolean;
|
||||
};
|
||||
[Hook.Imports]: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ReactElement;
|
||||
action: React.ReactElement;
|
||||
};
|
||||
[Hook.Icon]: React.ElementType;
|
||||
};
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@notionhq/client": "^2.2.16",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import env from "@shared/env";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { ImportDialog } from "./components/ImportDialog";
|
||||
|
||||
export const Notion = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const queryParams = useQuery();
|
||||
|
||||
const appName = env.APP_NAME;
|
||||
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
|
||||
|
||||
const service = queryParams.get("service");
|
||||
const oauthSuccess = queryParams.get("success") === "";
|
||||
const oauthError = queryParams.get("error");
|
||||
const integrationId = queryParams.get("integrationId");
|
||||
|
||||
const clearQueryParams = React.useCallback(() => {
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: "",
|
||||
});
|
||||
}, [history, location]);
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
dialogs.closeAllModals();
|
||||
clearQueryParams();
|
||||
}, [dialogs, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
integrationId &&
|
||||
oauthSuccess &&
|
||||
service === IntegrationService.Notion
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: (
|
||||
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
|
||||
),
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthError === "access_denied") {
|
||||
toast.error(
|
||||
t(
|
||||
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"Something went wrong while authenticating your request. Please try logging in again."
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [t, appName, oauthError]);
|
||||
|
||||
return (
|
||||
<Button type="submit" onClick={() => redirectTo(authUrl)} neutral>
|
||||
{t("Import")}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
CollectionPermission,
|
||||
ImportData,
|
||||
IntegrationService,
|
||||
} from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Text from "~/components/Text";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { Page } from "plugins/notion/shared/types";
|
||||
|
||||
type PageWithPermission = Page & {
|
||||
permission?: CollectionPermission;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [pagesWithPermission, setPagesWithPermission] =
|
||||
React.useState<PageWithPermission[]>();
|
||||
|
||||
const loadRootPages = React.useCallback(async () => {
|
||||
const res = await client.post("/notion.search", {
|
||||
integrationId,
|
||||
});
|
||||
return res.data;
|
||||
}, [integrationId]);
|
||||
|
||||
const {
|
||||
data: pages,
|
||||
error,
|
||||
loading,
|
||||
} = useRequest<Page[]>(loadRootPages, true);
|
||||
|
||||
const handlePermissionChange = React.useCallback(
|
||||
(id: string, permission?: CollectionPermission) => {
|
||||
setPagesWithPermission((prev) =>
|
||||
prev?.map((page) => {
|
||||
if (page.id === id) {
|
||||
page.permission = permission;
|
||||
}
|
||||
return page;
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleStartImport = React.useCallback(async () => {
|
||||
const data: ImportData = {
|
||||
collection: pagesWithPermission!.map((page) => ({
|
||||
externalId: page.id,
|
||||
permission: page.permission,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
await client.post("/imports.create", {
|
||||
integrationId,
|
||||
service: IntegrationService.Notion,
|
||||
data,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t("Your import is being processed, you can safely leave this page")
|
||||
);
|
||||
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}, [pagesWithPermission, onSubmit]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pages?.length) {
|
||||
setPagesWithPermission(pages);
|
||||
} else {
|
||||
setPagesWithPermission([]);
|
||||
}
|
||||
}, [pages]);
|
||||
|
||||
if (error) {
|
||||
toast.error("Error while fetching page info from Notion");
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (loading || !pagesWithPermission) {
|
||||
return <div>Loading data</div>;
|
||||
}
|
||||
|
||||
if (pagesWithPermission.length === 0) {
|
||||
return <div>No pages available for import</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column gap={8}>
|
||||
<div>
|
||||
{pagesWithPermission.map((page) => (
|
||||
<PageItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Flex justify="flex-end">
|
||||
<Button onClick={handleStartImport}>{t("Start import")}</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function PageItem({
|
||||
page,
|
||||
onPermissionChange,
|
||||
}: {
|
||||
page: PageWithPermission;
|
||||
onPermissionChange: (id: string, permission?: CollectionPermission) => void;
|
||||
}) {
|
||||
const handlePermissionChange = React.useCallback(
|
||||
(value: CollectionPermission | typeof EmptySelectValue) =>
|
||||
onPermissionChange(
|
||||
page.id,
|
||||
value === EmptySelectValue ? undefined : value
|
||||
),
|
||||
[onPermissionChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Row align="center" justify="space-between" gap={4}>
|
||||
<Column $width="75%" $border>
|
||||
<Flex gap={6} align="center">
|
||||
{page.emoji && <Emoji>{page.emoji}</Emoji>}
|
||||
<Text size="small">{page.name}</Text>
|
||||
</Flex>
|
||||
</Column>
|
||||
<Column>
|
||||
<InputSelectPermission
|
||||
onChange={handlePermissionChange}
|
||||
value={page.permission}
|
||||
labelHidden
|
||||
nude
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = styled(Flex)`
|
||||
border: 1px solid ${s("divider")};
|
||||
border-collapse: collapse;
|
||||
margin-bottom: auto;
|
||||
`;
|
||||
|
||||
const Column = styled.div<{ $width?: string; $border?: boolean }>`
|
||||
width: ${({ $width }) => $width || "auto"};
|
||||
padding: 4px 6px;
|
||||
border-right: 1px solid
|
||||
${({ $border }) => ($border ? s("divider") : "transparent")};
|
||||
`;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { t } from "i18next";
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { Notion } from "./Imports";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Imports,
|
||||
value: {
|
||||
title: "Notion",
|
||||
subtitle: t("Import pages from Notion"),
|
||||
icon: <img src={cdnPath("/images/notion.png")} width={28} />,
|
||||
action: <Notion />,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"description": "Adds a Notion integration for importing data."
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType, UserRole } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { InvalidRequestError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration, IntegrationAuthentication, Team } from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { APIContext } from "@server/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import { NotionOAuth } from "../oauth";
|
||||
import * as T from "./schema";
|
||||
import { NotionUtils } from "plugins/notion/shared/NotionUtils";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"notion.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.NotionCallbackSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.NotionCallbackReq>) => {
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = NotionUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for subdomains.
|
||||
// We must forward to the appropriate subdomain to complete the oauth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(NotionUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const data = await NotionOAuth.oauthAccess(code!);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.Notion,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
const integration = await Integration.create(
|
||||
{
|
||||
service: IntegrationService.Notion,
|
||||
type: IntegrationType.Import,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
externalWorkspace: {
|
||||
id: data.workspace_id,
|
||||
name: data.workspace_name ?? undefined,
|
||||
iconUrl: data.workspace_icon ?? undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.redirect(NotionUtils.successUrl(integration.id));
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"notion.search",
|
||||
auth({ role: UserRole.Admin }),
|
||||
validate(T.NotionSearchSchema),
|
||||
async (ctx: APIContext<T.NotionSearchReq>) => {
|
||||
const { integrationId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const integration = await Integration.scope("withAuthentication").findByPk(
|
||||
integrationId
|
||||
);
|
||||
can(user, "read", integration);
|
||||
|
||||
if (
|
||||
integration?.service !== IntegrationService.Notion ||
|
||||
integration.userId !== user.id
|
||||
) {
|
||||
throw InvalidRequestError("Invalid integrationId");
|
||||
}
|
||||
|
||||
const notionClient = new NotionClient(integration.authentication.token);
|
||||
const rootPages = await notionClient.fetchRootPages();
|
||||
|
||||
ctx.body = {
|
||||
data: rootPages,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,25 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const NotionCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotionCallbackReq = z.infer<typeof NotionCallbackSchema>;
|
||||
|
||||
export const NotionSearchSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
integrationId: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotionSearchReq = z.infer<typeof NotionSearchSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Environment } from "@server/env";
|
||||
import { Public } from "@server/utils/decorators/Public";
|
||||
import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
|
||||
class NotionPluginEnvironment extends Environment {
|
||||
@Public
|
||||
public NOTION_CLIENT_ID = environment.NOTION_CLIENT_ID;
|
||||
|
||||
@CannotUseWithout("NOTION_CLIENT_ID")
|
||||
public NOTION_CLIENT_SECRET = environment.NOTION_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
export default new NotionPluginEnvironment();
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./api/notion";
|
||||
import env from "./env";
|
||||
|
||||
const enabled = !!env.NOTION_CLIENT_ID && !!env.NOTION_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Client,
|
||||
isFullPageOrDatabase,
|
||||
iteratePaginatedAPI,
|
||||
} from "@notionhq/client";
|
||||
import { Page, PageTitle } from "../shared/types";
|
||||
|
||||
export class NotionClient {
|
||||
private client: Client;
|
||||
private pageSize = 25;
|
||||
|
||||
constructor(accessToken: string) {
|
||||
this.client = new Client({
|
||||
auth: accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchRootPages() {
|
||||
const pages: Page[] = [];
|
||||
|
||||
for await (const item of iteratePaginatedAPI(this.client.search, {
|
||||
page_size: this.pageSize,
|
||||
})) {
|
||||
if (!isFullPageOrDatabase(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.parent.type === "workspace") {
|
||||
let titleProp;
|
||||
|
||||
if (item.object === "page") {
|
||||
titleProp = (item.properties["title"] as PageTitle).title;
|
||||
} else {
|
||||
titleProp = item.title;
|
||||
}
|
||||
|
||||
pages.push({
|
||||
id: item.id,
|
||||
name: titleProp.at(0)?.plain_text ?? "",
|
||||
emoji: item.icon?.type === "emoji" ? item.icon.emoji : undefined, // other icon types return a url to download from, which we don't support.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import env from "./env";
|
||||
|
||||
export class NotionOAuth {
|
||||
private static tokenUrl = "https://api.notion.com/v1/oauth/token";
|
||||
private static credentials = Buffer.from(
|
||||
`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`
|
||||
).toString("base64");
|
||||
|
||||
private static resSchema = z.object({
|
||||
access_token: z.string(),
|
||||
bot_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string().nullish(),
|
||||
workspace_icon: z.string().url().nullish(),
|
||||
});
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${this.credentials}`,
|
||||
};
|
||||
const body = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: NotionUtils.callbackUrl(),
|
||||
};
|
||||
|
||||
const res = await fetch(this.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return this.resSchema.parse(await res.json());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
export type OAuthState = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export class NotionUtils {
|
||||
private static authBaseUrl = "https://api.notion.com/v1/oauth/authorize";
|
||||
private static settingsUrl = settingsPath("import");
|
||||
|
||||
static parseState(state: string): OAuthState {
|
||||
return JSON.parse(state);
|
||||
}
|
||||
|
||||
static successUrl(integrationId: string) {
|
||||
const params = {
|
||||
success: "",
|
||||
service: IntegrationService.Notion,
|
||||
integrationId,
|
||||
};
|
||||
return `${this.settingsUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
static errorUrl(error: string) {
|
||||
const params = {
|
||||
error,
|
||||
service: IntegrationService.Notion,
|
||||
};
|
||||
return `${this.settingsUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
return params
|
||||
? `${baseUrl}/api/notion.callback?${params}`
|
||||
: `${baseUrl}/api/notion.callback`;
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.NOTION_CLIENT_ID,
|
||||
redirect_uri: this.callbackUrl(),
|
||||
state: JSON.stringify(state),
|
||||
response_type: "code",
|
||||
owner: "user",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
|
||||
|
||||
export type Page = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
export type PageTitle = {
|
||||
type: "title";
|
||||
title: Array<RichTextItemResponse>;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable("imports", {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
service: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
state: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
data: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: false,
|
||||
},
|
||||
pendingTaskCount: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
},
|
||||
integrationId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "integrations",
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable("imports");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
Default,
|
||||
ForeignKey,
|
||||
IsIn,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import {
|
||||
type ImportData,
|
||||
ImportState,
|
||||
IntegrationService,
|
||||
} from "@shared/types";
|
||||
import Integration from "./Integration";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@Table({ tableName: "imports", modelName: "import" })
|
||||
@Fix
|
||||
class Import<T = unknown> extends IdModel<
|
||||
InferAttributes<Import<T>>,
|
||||
Partial<InferCreationAttributes<Import<T>>>
|
||||
> {
|
||||
@IsIn([Object.values(IntegrationService)])
|
||||
@Column(DataType.STRING)
|
||||
service: IntegrationService;
|
||||
|
||||
@IsIn([Object.values(ImportState)])
|
||||
@Column(DataType.STRING)
|
||||
state: ImportState;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
data: ImportData;
|
||||
|
||||
@Default(0)
|
||||
@Column(DataType.INTEGER)
|
||||
pendingTaskCount: number;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@BelongsTo(() => Integration, "integrationId")
|
||||
integration: Integration;
|
||||
|
||||
@ForeignKey(() => Integration)
|
||||
@Column(DataType.UUID)
|
||||
integrationId: string;
|
||||
}
|
||||
|
||||
export default Import;
|
||||
@@ -24,6 +24,8 @@ export { default as Group } from "./Group";
|
||||
|
||||
export { default as GroupUser } from "./GroupUser";
|
||||
|
||||
export { default as Import } from "./Import";
|
||||
|
||||
export { default as Integration } from "./Integration";
|
||||
|
||||
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Router from "koa-router";
|
||||
import { ImportState, UserRole } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import Import from "@server/models/Import";
|
||||
import { authorize } from "@server/policies";
|
||||
import { APIContext } from "@server/types";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"imports.create",
|
||||
auth({ role: UserRole.Admin }),
|
||||
validate(T.ImportsCreateSchema),
|
||||
async (ctx: APIContext<T.ImportsCreateReq>) => {
|
||||
const { integrationId, service, data } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "createImport", user.team);
|
||||
|
||||
await Import.create({
|
||||
service,
|
||||
state: ImportState.Created,
|
||||
data,
|
||||
createdById: user.id,
|
||||
integrationId,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./imports";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { CollectionPermission, IntegrationService } from "@shared/types";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
export const ImportsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
integrationId: z.string().uuid(),
|
||||
service: z.nativeEnum(IntegrationService),
|
||||
data: z.object({
|
||||
collection: z.array(
|
||||
z.object({
|
||||
externalId: z.string(),
|
||||
permission: z.nativeEnum(CollectionPermission).optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ImportsCreateReq = z.infer<typeof ImportsCreateSchema>;
|
||||
@@ -20,6 +20,7 @@ import events from "./events";
|
||||
import fileOperationsRoute from "./fileOperations";
|
||||
import groupMemberships from "./groupMemberships";
|
||||
import groups from "./groups";
|
||||
import imports from "./imports";
|
||||
import installation from "./installation";
|
||||
import integrations from "./integrations";
|
||||
import apiErrorHandler from "./middlewares/apiErrorHandler";
|
||||
@@ -97,6 +98,7 @@ router.use("/", fileOperationsRoute.routes());
|
||||
router.use("/", urls.routes());
|
||||
router.use("/", userMemberships.routes());
|
||||
router.use("/", reactions.routes());
|
||||
router.use("/", imports.routes());
|
||||
|
||||
if (!env.isCloudHosted) {
|
||||
router.use("/", installation.routes());
|
||||
|
||||
@@ -946,13 +946,12 @@
|
||||
"New group": "New group",
|
||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||
"No groups have been created yet": "No groups have been created yet",
|
||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)",
|
||||
"Import data": "Import data",
|
||||
"Import a JSON data file exported from another {{ appName }} instance": "Import a JSON data file exported from another {{ appName }} instance",
|
||||
"Import pages exported from Notion": "Import pages exported from Notion",
|
||||
"Import pages from a Confluence instance": "Import pages from a Confluence instance",
|
||||
"Enterprise": "Enterprise",
|
||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
|
||||
"Recent imports": "Recent imports",
|
||||
"Could not load members": "Could not load members",
|
||||
"Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.",
|
||||
@@ -1087,6 +1086,8 @@
|
||||
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/",
|
||||
"Site ID": "Site ID",
|
||||
"An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.",
|
||||
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?": "Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
|
||||
"Import pages from Notion": "Import pages from Notion",
|
||||
"Add to Slack": "Add to Slack",
|
||||
"document published": "document published",
|
||||
"document updated": "document updated",
|
||||
|
||||
@@ -54,6 +54,18 @@ export enum FileOperationState {
|
||||
Expired = "expired",
|
||||
}
|
||||
|
||||
export enum ImportState {
|
||||
Created = "created",
|
||||
InProgress = "in_progress",
|
||||
Processed = "processed",
|
||||
Completed = "completed",
|
||||
Errored = "errored",
|
||||
}
|
||||
|
||||
export type ImportData = {
|
||||
collection: { externalId: string; permission?: CollectionPermission }[];
|
||||
};
|
||||
|
||||
export enum MentionType {
|
||||
User = "user",
|
||||
Document = "document",
|
||||
@@ -86,6 +98,7 @@ export enum IntegrationType {
|
||||
Analytics = "analytics",
|
||||
/** An integration that maps an Outline user to an external service. */
|
||||
LinkedAccount = "linkedAccount",
|
||||
Import = "import",
|
||||
}
|
||||
|
||||
export enum IntegrationService {
|
||||
@@ -96,6 +109,7 @@ export enum IntegrationService {
|
||||
Matomo = "matomo",
|
||||
Umami = "umami",
|
||||
GitHub = "github",
|
||||
Notion = "notion",
|
||||
}
|
||||
|
||||
export type UserCreatableIntegrationService = Extract<
|
||||
@@ -157,6 +171,7 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
| { serviceTeamId: string }
|
||||
| { measurementId: string }
|
||||
| { slack: { serviceTeamId: string; serviceUserId: string } }
|
||||
| { externalWorkspace: { id: string; name?: string; iconUrl?: string } }
|
||||
| undefined;
|
||||
|
||||
export enum UserPreference {
|
||||
|
||||
@@ -2812,6 +2812,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
|
||||
integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
|
||||
|
||||
"@notionhq/client@^2.2.16":
|
||||
version "2.2.16"
|
||||
resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-2.2.16.tgz#6564cd77bc12e7dc68e4f5c4c6adef8384a33027"
|
||||
integrity sha512-3GlkfhLw8+Jw8U2iFEmHA6WfCgYhZCXLxgPdqDJkYMFotELNpQO+yGSy2QWURsG8ndu21sLt+FEOfDbNcCtFMg==
|
||||
dependencies:
|
||||
"@types/node-fetch" "^2.5.10"
|
||||
node-fetch "^2.6.1"
|
||||
|
||||
"@octokit/app@^14.0.2":
|
||||
version "14.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/app/-/app-14.0.2.tgz#b47c52020221351fb58640f113eb38b2ad3998fe"
|
||||
@@ -5002,6 +5010,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/natural-sort/-/natural-sort-0.0.24.tgz#9a89fcbabd963937fab9cc4ca527635c2a7c0cef"
|
||||
integrity "sha1-mon8ur2WOTf6ucxMpSdjXCp8DO8= sha512-+/F8JDyT0QUi2cE51S4Xsy4yuHVBiFsU5bq0g2XnTKkOj0Jz69o3TLYd1gnVTOrKOvg0+FUJqE9BA24FgnpuGg=="
|
||||
|
||||
"@types/node-fetch@^2.5.10":
|
||||
version "2.6.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03"
|
||||
integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
form-data "^4.0.0"
|
||||
|
||||
"@types/node-fetch@^2.6.9":
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.9.tgz#15f529d247f1ede1824f7e7acdaa192d5f28071e"
|
||||
|
||||
Reference in New Issue
Block a user