From 2c3466d3780ad00b4dedbcf495f08db123c874eb Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 15 Jul 2025 06:05:03 +0000 Subject: [PATCH] feat: Add Integrations page with cards and authentication flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Created Integrations page with grid view of integration cards 2. Implemented category filter dropdown 3. Added integration details modal dialog 4. Implemented API key and OAuth authentication flows 5. Created API endpoint for direct integration account creation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../app/routes/api.v1.integration_account.tsx | 87 +++++ apps/webapp/app/routes/home.integrations.tsx | 343 ++++++++++++++++++ .../app/services/integrationAccount.server.ts | 37 ++ packages/types/src/integration.ts | 1 + 4 files changed, 468 insertions(+) create mode 100644 apps/webapp/app/routes/api.v1.integration_account.tsx create mode 100644 apps/webapp/app/routes/home.integrations.tsx 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[];