diff --git a/apps/webapp/app/routes/api.v1.integration_account.tsx b/apps/webapp/app/routes/api.v1.integration_account.tsx new file mode 100644 index 0000000..d25df9e --- /dev/null +++ b/apps/webapp/app/routes/api.v1.integration_account.tsx @@ -0,0 +1,87 @@ +import { json } from "@remix-run/node"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { createIntegrationAccount } from "~/services/integrationAccount.server"; +import { IntegrationEventType } from "@core/types"; +import { runIntegrationTrigger } from "~/services/integration.server"; +import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server"; +import { logger } from "~/services/logger.service"; + +// Schema for creating an integration account with API key +const IntegrationAccountBodySchema = z.object({ + integrationDefinitionId: z.string(), + apiKey: z.string(), +}); + +// Route for creating an integration account directly with an API key +const { action, loader } = createActionApiRoute( + { + body: IntegrationAccountBodySchema, + allowJWT: true, + authorization: { + action: "create", + subject: "IntegrationAccount", + }, + corsStrategy: "all", + }, + async ({ body, authentication }) => { + const { integrationDefinitionId, apiKey } = body; + const { userId } = authentication; + + try { + // Get the integration definition + const integrationDefinition = await getIntegrationDefinitionWithId( + integrationDefinitionId + ); + + if (!integrationDefinition) { + return json( + { error: "Integration definition not found" }, + { status: 404 } + ); + } + + // Trigger the SETUP event for the integration + const setupResult = await runIntegrationTrigger( + integrationDefinition, + { + event: IntegrationEventType.SETUP, + eventBody: { + apiKey, + }, + }, + userId + ); + + if (!setupResult || !setupResult.accountId) { + return json( + { error: "Failed to setup integration with the provided API key" }, + { status: 400 } + ); + } + + // Create the integration account + const integrationAccount = await createIntegrationAccount({ + accountId: setupResult.accountId, + integrationDefinitionId, + userId, + config: setupResult.config || {}, + settings: setupResult.settings || {}, + }); + + return json({ success: true, integrationAccount }); + } catch (error) { + logger.error("Error creating integration account", { + error, + userId, + integrationDefinitionId, + }); + return json( + { error: "Failed to create integration account" }, + { status: 500 } + ); + } + } +); + +export { action, loader }; \ No newline at end of file diff --git a/apps/webapp/app/routes/home.integrations.tsx b/apps/webapp/app/routes/home.integrations.tsx new file mode 100644 index 0000000..f509e8a --- /dev/null +++ b/apps/webapp/app/routes/home.integrations.tsx @@ -0,0 +1,343 @@ +import { useState } from "react"; +import { json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { requireUserId, requireWorkpace } from "~/services/session.server"; +import { getIntegrationDefinitions } from "~/services/integrationDefinition.server"; +import { getIntegrationAccounts } from "~/services/integrationAccount.server"; + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { FormButtons } from "~/components/ui/FormButtons"; +import { Plus, Search } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; + +// Loader to fetch integration definitions and existing accounts +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const workspace = await requireWorkpace(request); + + const [integrationDefinitions, integrationAccounts] = await Promise.all([ + getIntegrationDefinitions(workspace.id), + getIntegrationAccounts(userId), + ]); + + return json({ + integrationDefinitions, + integrationAccounts, + userId, + }); +} + +export default function Integrations() { + const { integrationDefinitions, integrationAccounts, userId } = useLoaderData(); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedIntegration, setSelectedIntegration] = useState(null); + const [apiKey, setApiKey] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + + // Extract categories from integration definitions + const categories = Array.from( + new Set(integrationDefinitions.map((integration) => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.category || "Uncategorized"; + })) + ); + + // Filter integrations by selected category + const filteredIntegrations = selectedCategory === "all" + ? integrationDefinitions + : integrationDefinitions.filter( + (integration) => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.category === selectedCategory; + } + ); + + // Check if user has an active account for an integration + const hasActiveAccount = (integrationDefinitionId: string) => { + return integrationAccounts.some( + (account) => account.integrationDefinitionId === integrationDefinitionId && account.isActive + ); + }; + + // Handle connection with API key + const handleApiKeyConnect = async () => { + if (!selectedIntegration || !apiKey.trim()) return; + + setIsLoading(true); + try { + const response = await fetch("/api/v1/integration_account", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + integrationDefinitionId: selectedIntegration.id, + apiKey, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to connect integration"); + } + + // Refresh the page to show the new integration account + window.location.reload(); + } catch (error) { + console.error("Error connecting integration:", error); + // Handle error (could add error state and display message) + } finally { + setIsLoading(false); + } + }; + + // Handle OAuth connection + const handleOAuthConnect = async () => { + if (!selectedIntegration) return; + + setIsConnecting(true); + try { + const response = await fetch("/api/v1/oauth", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + integrationDefinitionId: selectedIntegration.id, + userId, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to start OAuth flow"); + } + + const { url } = await response.json(); + // Redirect to OAuth authorization URL + window.location.href = url; + } catch (error) { + console.error("Error starting OAuth flow:", error); + // Handle error + } finally { + setIsConnecting(false); + } + }; + + return ( +
+
+
+

Integrations

+

Connect your tools and services

+
+
+ {/* Category filter */} + + + {/* Add integration button */} + +
+
+ + {/* Integration cards grid */} +
+ {filteredIntegrations.map((integration) => { + const isConnected = hasActiveAccount(integration.id); + const authType = integration.spec?.auth?.type || "unknown"; + + return ( + { + if (open) { + setSelectedIntegration(integration); + setApiKey(""); + } else { + setSelectedIntegration(null); + } + }}> + + + +
+ {integration.icon ? ( + {integration.name} + ) : ( +
+ )} +
+ {integration.name} + + {integration.description || "Connect to " + integration.name} + + + +
+ + {(() => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.category || "Uncategorized"; + })()} + + {isConnected ? ( + + Connected + + ) : ( + Not connected + )} +
+
+ + + + + + Connect to {integration.name} + + {integration.description || `Connect your ${integration.name} account to enable integration.`} + + + + {/* API Key Authentication */} + {(() => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.auth?.api_key; + })() && ( +
+
+ + setApiKey(e.target.value)} + /> + {(() => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.auth?.api_key?.description; + })() && ( +

+ {(() => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.auth?.api_key?.description; + })()} +

+ )} +
+ + + + +
+ )} + + {/* OAuth Authentication */} + {(() => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return specData?.auth?.oauth2; + })() && ( +
+ +
+ )} + + {/* No authentication method found */} + {(() => { + const specData = typeof integration.spec === 'string' + ? JSON.parse(integration.spec) + : integration.spec; + return !specData?.auth?.api_key && !specData?.auth?.oauth2; + })() && ( +
+ This integration doesn't specify an authentication method. +
+ )} + + +
+ By connecting, you agree to the {integration.name} terms of service. +
+
+
+
+ ); + })} +
+ + {/* Empty state */} + {filteredIntegrations.length === 0 && ( +
+ +

No integrations found

+

+ {selectedCategory === "all" + ? "No integrations are available at this time." + : `No integrations found in the "${selectedCategory}" category.`} +

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/services/integrationAccount.server.ts b/apps/webapp/app/services/integrationAccount.server.ts index 2dbef5a..7e36843 100644 --- a/apps/webapp/app/services/integrationAccount.server.ts +++ b/apps/webapp/app/services/integrationAccount.server.ts @@ -12,3 +12,40 @@ export const getIntegrationAccount = async ( }, }); }; + +export const createIntegrationAccount = async ({ + integrationDefinitionId, + userId, + accountId, + config, + settings, +}: { + integrationDefinitionId: string; + userId: string; + accountId: string; + config?: Record; + settings?: Record; +}) => { + return prisma.integrationAccount.create({ + data: { + accountId, + integrationDefinitionId, + integratedById: userId, + config: config || {}, + settings: settings || {}, + isActive: true, + }, + }); +}; + +export const getIntegrationAccounts = async (userId: string) => { + return prisma.integrationAccount.findMany({ + where: { + integratedById: userId, + isActive: true, + }, + include: { + integrationDefinition: true, + }, + }); +}; diff --git a/packages/types/src/integration.ts b/packages/types/src/integration.ts index 2848217..0e9a844 100644 --- a/packages/types/src/integration.ts +++ b/packages/types/src/integration.ts @@ -37,6 +37,7 @@ export class Spec { key: string; description: string; icon: string; + category?: string; mcp?: { command: string; args: string[];