mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08:00
feat: integrations
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user