feat: integrations

This commit is contained in:
Philipinho
2026-02-22 20:14:33 +00:00
parent c172d3bd5e
commit aeb30ad096
49 changed files with 2780 additions and 4 deletions
+2
View File
@@ -37,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import Integrations from "@/features/integration/pages/integrations.tsx";
export default function App() {
const { t } = useTranslation();
@@ -102,6 +103,7 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconPlug,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -116,6 +117,12 @@ const groupedData: DataGroup[] = [
path: "/settings/ai",
isAdmin: true,
},
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
isAdmin: true,
},
],
},
{
@@ -34,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label={t("Add link")} withArrow>
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
@@ -4,6 +4,7 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
export const handlePaste = (
editor: Editor,
@@ -13,6 +14,21 @@ export const handlePaste = (
) => {
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (integrationMatch && editor.state.selection.empty) {
event.preventDefault();
editor
.chain()
.focus()
.setIntegrationLink({
url: clipboardData.trim(),
provider: integrationMatch.provider,
status: "pending",
})
.run();
return true;
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
@@ -0,0 +1,10 @@
.card {
max-width: 100%;
cursor: pointer;
transition: border-color 150ms ease;
margin: 4px 0;
}
.card:hover {
border-color: var(--mantine-color-blue-4);
}
@@ -0,0 +1,144 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
} from "@mantine/core";
import { useEffect, useCallback, memo } from "react";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import classes from "./integration-link-view.module.css";
const providerIcons: Record<string, string> = {
github: "https://github.githubassets.com/favicons/favicon-dark.svg",
gitlab: "https://gitlab.com/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8571da3c2571592f63571e0c5571.png",
jira: "https://wac-cdn.atlassian.com/assets/img/favicons/atlassian/favicon.png",
linear: "https://linear.app/favicon.ico",
};
function IntegrationLinkView(props: any) {
const { node, updateAttributes, editor } = props;
const { url, provider, unfurlData, status } = node.attrs;
const doUnfurl = useCallback(async () => {
if (status !== "pending" || !url) return;
try {
const result = await unfurlUrl({ url });
if (result) {
updateAttributes({
unfurlData: result,
status: "loaded",
});
} else {
updateAttributes({ status: "error" });
}
} catch {
updateAttributes({ status: "error" });
}
}, [url, status, updateAttributes]);
useEffect(() => {
if (status === "pending") {
doUnfurl();
}
}, [status, doUnfurl]);
if (status === "pending") {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (status === "error" || !unfurlData) {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Anchor href={url} target="_blank" rel="noopener" size="sm">
{url}
</Anchor>
</Card>
</NodeViewWrapper>
);
}
const iconUrl = providerIcons[provider] ?? undefined;
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" />
) : iconUrl ? (
<Avatar src={iconUrl} size={28} radius="sm" />
) : null}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={unfurlData.statusColor ?? "gray"}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
<Group gap="xs">
{iconUrl && (
<Avatar src={iconUrl} size={14} radius="sm" />
)}
<Text size="xs" c="dimmed">
{unfurlData.provider}
</Text>
{unfurlData.author && (
<Text size="xs" c="dimmed">
· {unfurlData.author}
</Text>
)}
</Group>
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationLinkView);
@@ -27,7 +27,7 @@ export const LinkPreviewPanel = ({
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center">
<Tooltip label={url}>
<Tooltip label={url} withArrow withinPortal={false}>
<Anchor
href={url}
target="_blank"
@@ -43,6 +43,7 @@ import {
Highlight,
UniqueID,
SharedStorage,
IntegrationLink,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -60,6 +61,7 @@ import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
@@ -231,6 +233,9 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -0,0 +1,104 @@
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
import {
IconBrandGithub,
IconBrandSlack,
IconBrandGitlab,
IconPuzzle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
const iconMap: Record<string, React.ElementType> = {
github: IconBrandGithub,
slack: IconBrandSlack,
gitlab: IconBrandGitlab,
};
type IntegrationCardProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
onConfigure: (integration: Integration) => void;
onToggle: (integration: Integration, enabled: boolean) => void;
};
export default function IntegrationCard({
definition,
installation,
onInstall,
onUninstall,
onConfigure,
onToggle,
}: IntegrationCardProps) {
const { t } = useTranslation();
const Icon = iconMap[definition.icon] ?? IconPuzzle;
const isInstalled = !!installation;
return (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<Icon size={28} stroke={1.5} />
<div>
<Text fw={600} size="sm">
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
</Group>
<Group gap="xs" mb="md">
{definition.capabilities.map((cap) => (
<Badge key={cap} size="xs" variant="light">
{cap}
</Badge>
))}
</Group>
{isInstalled ? (
<Stack gap="xs">
<Group justify="space-between">
<Switch
label={t("Enabled")}
checked={installation.isEnabled}
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
size="sm"
/>
</Group>
<Group gap="xs">
<Button
size="xs"
variant="light"
onClick={() => onConfigure(installation)}
>
{t("Configure")}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
</Group>
</Stack>
) : (
<Button
size="xs"
variant="light"
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
)}
</Card>
);
}
@@ -0,0 +1,91 @@
import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { Integration, ConnectionStatus } from "../types/integration.types";
import {
useConnectionStatus,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
type IntegrationSettingsModalProps = {
integration: Integration | null;
opened: boolean;
onClose: () => void;
};
export default function IntegrationSettingsModal({
integration,
opened,
onClose,
}: IntegrationSettingsModalProps) {
const { t } = useTranslation();
const { data: connectionStatus } = useConnectionStatus(integration?.id);
const disconnectMutation = useDisconnectIntegration();
if (!integration) return null;
const handleConnect = async () => {
try {
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = async () => {
await disconnectMutation.mutateAsync({
integrationId: integration.id,
});
};
const hasOAuth = true;
return (
<Modal
opened={opened}
onClose={onClose}
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
size="md"
>
<Stack gap="md">
{hasOAuth && (
<div>
<Text size="sm" fw={600} mb="xs">
{t("Connection")}
</Text>
{connectionStatus?.connected ? (
<Group gap="sm">
<Text size="sm" c="green">
{t("Connected")}
{connectionStatus.providerUserId &&
` (${connectionStatus.providerUserId})`}
</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={handleDisconnect}
loading={disconnectMutation.isPending}
>
{t("Disconnect")}
</Button>
</Group>
) : (
<Button size="xs" variant="light" onClick={handleConnect}>
{t("Connect")} {integration.type}
</Button>
)}
</div>
)}
</Stack>
</Modal>
);
}
@@ -0,0 +1,111 @@
import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useState, useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationCard from "../components/integration-card";
import IntegrationSettingsModal from "../components/integration-settings-modal";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
useUpdateIntegrationSettings,
} from "../queries/integration-query";
import { Integration } from "../types/integration.types";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const updateMutation = useUpdateIntegrationSettings();
const [configuring, setConfiguring] = useState<Integration | null>(null);
const handleInstall = useCallback(
(type: string) => {
installMutation.mutate({ type });
},
[installMutation],
);
const handleUninstall = useCallback(
(integrationId: string) => {
uninstallMutation.mutate({ integrationId });
},
[uninstallMutation],
);
const handleConfigure = useCallback((integration: Integration) => {
setConfiguring(integration);
}, []);
const handleToggle = useCallback(
(integration: Integration, enabled: boolean) => {
updateMutation.mutate({
integrationId: integration.id,
isEnabled: enabled,
});
},
[updateMutation],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationCard
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
onConfigure={handleConfigure}
onToggle={handleToggle}
/>
);
})}
</SimpleGrid>
)}
<IntegrationSettingsModal
integration={configuring}
opened={!!configuring}
onClose={() => setConfiguring(null)}
/>
</>
);
}
@@ -0,0 +1,109 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations() {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
});
}
export function useInstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.installIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration installed successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to install integration"),
color: "red",
});
},
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useUpdateIntegrationSettings() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.updateIntegrationSettings,
onSuccess: () => {
notifications.show({ message: t("Integration updated successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update integration"),
color: "red",
});
},
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
queryFn: () =>
integrationService.getConnectionStatus({
integrationId: integrationId!,
}),
enabled: !!integrationId,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: (_data, variables) => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({
queryKey: ["integration-connection", variables.integrationId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}
@@ -0,0 +1,79 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UnfurlResult,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations/list");
return req.data;
}
export async function installIntegration(data: {
type: string;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/install", data);
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function updateIntegrationSettings(data: {
integrationId: string;
settings?: Record<string, any>;
isEnabled?: boolean;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/update", data);
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
const req = await api.post<ConnectionStatus>(
"/integrations/connection/status",
data,
);
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | null> {
const req = await api.post<{ data: UnfurlResult | null }>(
"/integrations/unfurl",
data,
);
return req.data.data;
}
@@ -0,0 +1,38 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
isEnabled: boolean;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
};
export type ConnectionStatus = {
connected: boolean;
providerUserId?: string;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
+1
View File
@@ -25,6 +25,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces",
BILLING: "/settings/billing",
SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
},
},
};