From 161f80380f2530691bede8024f439334c95bcd4e Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Mon, 16 Feb 2026 14:51:52 -0600 Subject: [PATCH 1/5] feat: AI sandbox admin UI with integrations management (#603) Add integration management UI across settings, drive settings, and agent panels. Includes provider connections, audit logging, OpenAPI import, tool grants, and JSON schema builder. Code review fixes applied: proper count query for audit pagination, ConnectionStatus type safety, error state UI on all SWR consumers, useMemo for computed values, aria-labels for accessibility, file size validation on OpenAPI upload, and shadcn Button consistency. Co-Authored-By: Claude Opus 4.6 --- .../[driveId]/integrations/audit/route.ts | 92 ++++++ .../providers/import-openapi/route.ts | 53 +++ .../app/dashboard/[driveId]/settings/page.tsx | 4 + .../src/app/settings/integrations/page.tsx | 284 ++++++++++++++++ apps/web/src/app/settings/page.tsx | 45 +-- .../ai/page-agents/AgentIntegrationsPanel.tsx | 200 +++++++++++ .../integrations/ConnectIntegrationDialog.tsx | 180 ++++++++++ .../integrations/DisconnectConfirmDialog.tsx | 49 +++ .../integrations/IntegrationStatusBadge.tsx | 21 ++ .../integrations/JsonSchemaBuilder.tsx | 147 +++++++++ .../integrations/OpenAPIImportDialog.tsx | 311 ++++++++++++++++++ .../components/integrations/ToolBuilder.tsx | 194 +++++++++++ apps/web/src/components/integrations/types.ts | 80 +++++ .../page-views/ai-page/AiChatView.tsx | 2 + .../components/settings/DriveIntegrations.tsx | 213 ++++++++++++ .../settings/IntegrationAuditLog.tsx | 206 ++++++++++++ apps/web/src/hooks/useIntegrations.ts | 72 ++++ 17 files changed, 2133 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/api/drives/[driveId]/integrations/audit/route.ts create mode 100644 apps/web/src/app/api/integrations/providers/import-openapi/route.ts create mode 100644 apps/web/src/app/settings/integrations/page.tsx create mode 100644 apps/web/src/components/ai/page-agents/AgentIntegrationsPanel.tsx create mode 100644 apps/web/src/components/integrations/ConnectIntegrationDialog.tsx create mode 100644 apps/web/src/components/integrations/DisconnectConfirmDialog.tsx create mode 100644 apps/web/src/components/integrations/IntegrationStatusBadge.tsx create mode 100644 apps/web/src/components/integrations/JsonSchemaBuilder.tsx create mode 100644 apps/web/src/components/integrations/OpenAPIImportDialog.tsx create mode 100644 apps/web/src/components/integrations/ToolBuilder.tsx create mode 100644 apps/web/src/components/integrations/types.ts create mode 100644 apps/web/src/components/settings/DriveIntegrations.tsx create mode 100644 apps/web/src/components/settings/IntegrationAuditLog.tsx create mode 100644 apps/web/src/hooks/useIntegrations.ts diff --git a/apps/web/src/app/api/drives/[driveId]/integrations/audit/route.ts b/apps/web/src/app/api/drives/[driveId]/integrations/audit/route.ts new file mode 100644 index 000000000..4c056aa36 --- /dev/null +++ b/apps/web/src/app/api/drives/[driveId]/integrations/audit/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { db, count, eq, and, integrationAuditLog } from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { getDriveAccess } from '@pagespace/lib/services/drive-service'; +import { + getAuditLogsByDrive, + getAuditLogsByConnection, + getAuditLogsBySuccess, +} from '@pagespace/lib/integrations'; + +const AUTH_OPTIONS = { allow: ['session'] as const }; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * GET /api/drives/[driveId]/integrations/audit + * List integration audit logs for a drive. + * Query params: limit, offset, connectionId, success + */ +export async function GET( + request: Request, + context: { params: Promise<{ driveId: string }> } +) { + const { driveId } = await context.params; + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) return auth.error; + + try { + // Require OWNER or ADMIN + const access = await getDriveAccess(driveId, auth.userId); + if (!access.isOwner && !access.isAdmin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 200); + const offset = parseInt(searchParams.get('offset') ?? '0', 10); + const connectionId = searchParams.get('connectionId'); + const successParam = searchParams.get('success'); + + if (connectionId && !UUID_RE.test(connectionId)) { + return NextResponse.json({ error: 'Invalid connectionId format' }, { status: 400 }); + } + + // Build where clause for count query (same filters, no limit/offset) + let whereClause; + if (connectionId) { + whereClause = eq(integrationAuditLog.connectionId, connectionId); + } else if (successParam !== null) { + whereClause = and( + eq(integrationAuditLog.driveId, driveId), + eq(integrationAuditLog.success, successParam === 'true') + ); + } else { + whereClause = eq(integrationAuditLog.driveId, driveId); + } + + // Get total count and paginated logs in parallel + const [countResult, logs] = await Promise.all([ + db.select({ count: count() }).from(integrationAuditLog).where(whereClause), + connectionId + ? getAuditLogsByConnection(db, connectionId, { limit, offset }) + : successParam !== null + ? getAuditLogsBySuccess(db, driveId, successParam === 'true', { limit, offset }) + : getAuditLogsByDrive(db, driveId, { limit, offset }), + ]); + + const total = Number(countResult[0]?.count ?? 0); + + return NextResponse.json({ + logs: logs.map((log) => ({ + id: log.id, + driveId: log.driveId, + agentId: log.agentId, + userId: log.userId, + connectionId: log.connectionId, + toolName: log.toolName, + inputSummary: log.inputSummary, + success: log.success, + responseCode: log.responseCode, + errorType: log.errorType, + errorMessage: log.errorMessage, + durationMs: log.durationMs, + createdAt: log.createdAt, + })), + total, + }); + } catch (error) { + loggers.api.error('Error fetching integration audit logs:', error as Error); + return NextResponse.json({ error: 'Failed to fetch audit logs' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/integrations/providers/import-openapi/route.ts b/apps/web/src/app/api/integrations/providers/import-openapi/route.ts new file mode 100644 index 000000000..4b1b3bc78 --- /dev/null +++ b/apps/web/src/app/api/integrations/providers/import-openapi/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { authenticateRequestWithOptions, isAuthError, verifyAdminAuth } from '@/lib/auth'; +import { loggers } from '@pagespace/lib/server'; +import { importOpenAPISpec } from '@pagespace/lib/integrations'; + +const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true }; + +const importSchema = z.object({ + spec: z.string().min(1, 'Spec content is required'), + selectedOperations: z.array(z.string()).optional(), + baseUrlOverride: z.string().url().optional(), +}); + +/** + * POST /api/integrations/providers/import-openapi + * Parse an OpenAPI spec and return the generated provider config. + * Admin only. + */ +export async function POST(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) return auth.error; + + const adminAuth = await verifyAdminAuth(request); + if (adminAuth instanceof NextResponse) { + return adminAuth; + } + + try { + const body = await request.json(); + const validation = importSchema.safeParse(body); + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const { spec, selectedOperations, baseUrlOverride } = validation.data; + + const result = await importOpenAPISpec(spec, { + selectedOperations, + baseUrlOverride, + }); + + return NextResponse.json({ result }); + } catch (error) { + loggers.api.error('Error importing OpenAPI spec:', error as Error); + const message = error instanceof Error ? error.message : 'Failed to import OpenAPI spec'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/apps/web/src/app/dashboard/[driveId]/settings/page.tsx b/apps/web/src/app/dashboard/[driveId]/settings/page.tsx index 1c3d9b74d..8ab9a59a7 100644 --- a/apps/web/src/app/dashboard/[driveId]/settings/page.tsx +++ b/apps/web/src/app/dashboard/[driveId]/settings/page.tsx @@ -9,6 +9,8 @@ import { useDriveStore } from '@/hooks/useDrive'; import { RolesManager } from '@/components/settings/RolesManager'; import { DriveAISettings } from '@/components/settings/DriveAISettings'; import { DriveDeleteSection } from '@/components/settings/DriveDeleteSection'; +import { DriveIntegrations } from '@/components/settings/DriveIntegrations'; +import { IntegrationAuditLog } from '@/components/settings/IntegrationAuditLog'; export default function DriveSettingsPage() { const params = useParams(); @@ -89,6 +91,8 @@ export default function DriveSettingsPage() {
+ + {/* Danger Zone - Only show to owners */} {drive.isOwned && ( diff --git a/apps/web/src/app/settings/integrations/page.tsx b/apps/web/src/app/settings/integrations/page.tsx new file mode 100644 index 000000000..c45a1729c --- /dev/null +++ b/apps/web/src/app/settings/integrations/page.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ArrowLeft, Cable, Plug2, Loader2, ExternalLink, AlertCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { useProviders, useUserConnections } from '@/hooks/useIntegrations'; +import { IntegrationStatusBadge } from '@/components/integrations/IntegrationStatusBadge'; +import { ConnectIntegrationDialog } from '@/components/integrations/ConnectIntegrationDialog'; +import { DisconnectConfirmDialog } from '@/components/integrations/DisconnectConfirmDialog'; +import { del, patch } from '@/lib/auth/auth-fetch'; +import type { SafeProvider, SafeConnection } from '@/components/integrations/types'; + +const visibilityLabels: Record = { + private: 'Private', + owned_drives: 'Your drives', + all_drives: 'All drives', +}; + +export default function IntegrationsSettingsPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { providers, isLoading: loadingProviders } = useProviders(); + const { connections, isLoading: loadingConnections, mutate: mutateConnections } = useUserConnections(); + + const [connectProvider, setConnectProvider] = useState(null); + const [disconnectConnection, setDisconnectConnection] = useState(null); + const [updatingVisibility, setUpdatingVisibility] = useState(null); + + // Handle OAuth callback redirect params + useEffect(() => { + if (searchParams.get('connected') === 'true') { + toast.success('Integration connected successfully'); + mutateConnections(); + // Clean URL + router.replace('/settings/integrations'); + } else if (searchParams.get('error')) { + toast.error(`Connection failed: ${searchParams.get('error')}`); + router.replace('/settings/integrations'); + } + }, [searchParams, mutateConnections, router]); + + const connectedProviderIds = useMemo( + () => new Set(connections.map((c) => c.providerId)), + [connections] + ); + const availableProviders = useMemo( + () => providers.filter((p) => !connectedProviderIds.has(p.id)), + [providers, connectedProviderIds] + ); + + const handleDisconnect = async () => { + if (!disconnectConnection) return; + try { + await del(`/api/user/integrations/${disconnectConnection.id}`); + toast.success('Integration disconnected'); + mutateConnections(); + } catch { + toast.error('Failed to disconnect integration'); + } finally { + setDisconnectConnection(null); + } + }; + + const handleVisibilityChange = async (connectionId: string, visibility: string) => { + setUpdatingVisibility(connectionId); + try { + await patch(`/api/user/integrations/${connectionId}`, { visibility }); + mutateConnections(); + } catch { + toast.error('Failed to update visibility'); + } finally { + setUpdatingVisibility(null); + } + }; + + const isLoading = loadingProviders || loadingConnections; + + const getProviderDetailHref = (connection: SafeConnection) => { + if (connection.provider?.slug === 'google-calendar') { + return '/settings/integrations/google-calendar'; + } + return null; + }; + + return ( +
+
+ +

Integrations

+

+ Connect external APIs and services to your AI assistants. +

+
+ +
+ {/* Connected Integrations */} + + + + + Connected + + + Your active service connections. + + + + {isLoading ? ( +
+ + +
+ ) : connections.length === 0 ? ( +

+ No integrations connected yet. +

+ ) : ( +
+ {connections.map((connection) => { + const detailHref = getProviderDetailHref(connection); + return ( +
+
+
+ +
+
+
+ {connection.name} + +
+
+ {connection.provider?.name} + {connection.visibility && ( + <> · {visibilityLabels[connection.visibility] ?? connection.visibility} + )} +
+
+
+
+ {connection.visibility && ( + + )} + {detailHref && ( + + )} + +
+
+ ); + })} +
+ )} +
+
+ + {/* Available Providers */} + + + + + Available Providers + + + Connect new services to extend your AI assistants' capabilities. + + + + {isLoading ? ( +
+ + +
+ ) : availableProviders.length === 0 ? ( +
+ + {providers.length === 0 + ? 'No integration providers configured.' + : 'All available providers are already connected.'} +
+ ) : ( +
+ {availableProviders.map((provider) => ( +
+
+
+ +
+
+ {provider.name} + {provider.description && ( +

+ {provider.description} +

+ )} +
+
+ +
+ ))} +
+ )} +
+
+
+ + {/* Connect Dialog */} + { if (!open) setConnectProvider(null); }} + onConnected={() => mutateConnections()} + /> + + {/* Disconnect Dialog */} + { if (!open) setDisconnectConnection(null); }} + connectionName={disconnectConnection?.name ?? ''} + onConfirm={handleDisconnect} + /> +
+ ); +} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 55faf7736..382e142c2 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -6,7 +6,7 @@ import { useMCP } from "@/hooks/useMCP"; import { useAuth } from "@/hooks/useAuth"; import { useBillingVisibility } from "@/hooks/useBillingVisibility"; import { Button } from "@/components/ui/button"; -import { User, Plug2, Key, ArrowLeft, CreditCard, Bell, Shield, ChevronRight, Keyboard, Sparkles, Calendar, Eye } from "lucide-react"; +import { User, Plug2, Key, ArrowLeft, CreditCard, Bell, Shield, ChevronRight, Keyboard, Sparkles, Eye, Cable } from "lucide-react"; interface SettingsItem { title: string; @@ -79,13 +79,6 @@ export default function SettingsPage() { href: "/settings/account", available: true, }, - { - title: "Personalization", - description: "Customize how AI interacts with you", - icon: Sparkles, - href: "/settings/personalization", - available: true, - }, { title: "Notifications", description: "Manage email notification preferences", @@ -118,13 +111,32 @@ export default function SettingsPage() { ]), }, { - title: "AI Integrations", + title: "AI Settings", + items: filterItems([ + { + title: "Personalization", + description: "Customize how AI interacts with you", + icon: Sparkles, + href: "/settings/personalization", + available: true, + }, + { + title: "AI API Keys", + description: "Configure AI provider API keys", + icon: Key, + href: "/settings/ai", + available: true, + }, + ]), + }, + { + title: "Integrations", items: filterItems([ { - title: "Google Calendar", - description: "Import events from Google Calendar", - icon: Calendar, - href: "/settings/integrations/google-calendar", + title: "Service Connections", + description: "Connect external APIs and services to your AI assistants", + icon: Cable, + href: "/settings/integrations", available: true, }, { @@ -142,13 +154,6 @@ export default function SettingsPage() { available: true, desktopOnly: true, }, - { - title: "AI API Keys", - description: "Configure AI provider API keys", - icon: Key, - href: "/settings/ai", - available: true, - }, ]), }, ...(isAdmin ? [{ diff --git a/apps/web/src/components/ai/page-agents/AgentIntegrationsPanel.tsx b/apps/web/src/components/ai/page-agents/AgentIntegrationsPanel.tsx new file mode 100644 index 000000000..11783ea7d --- /dev/null +++ b/apps/web/src/components/ai/page-agents/AgentIntegrationsPanel.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Plug2, AlertCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { useAgentGrants, useUserConnections, useDriveConnections } from '@/hooks/useIntegrations'; +import { IntegrationStatusBadge } from '@/components/integrations/IntegrationStatusBadge'; +import { post, put, del } from '@/lib/auth/auth-fetch'; +import type { SafeConnection, SafeGrant } from '@/components/integrations/types'; + +interface AgentIntegrationsPanelProps { + pageId: string; + driveId: string; +} + +export function AgentIntegrationsPanel({ pageId, driveId }: AgentIntegrationsPanelProps) { + const { grants, isLoading: loadingGrants, error: grantsError, mutate: mutateGrants } = useAgentGrants(pageId); + const { connections: userConnections, isLoading: loadingUser, error: userError } = useUserConnections(); + const { connections: driveConnections, isLoading: loadingDrive, error: driveError } = useDriveConnections(driveId); + + const [toggling, setToggling] = useState(null); + const [updatingGrant, setUpdatingGrant] = useState(null); + + const isLoading = loadingGrants || loadingUser || loadingDrive; + const error = grantsError || userError || driveError; + + // Merge user + drive connections, deduplicate by id (O(n) with Map) + const allConnections = useMemo(() => { + const seen = new Map(); + for (const c of userConnections) seen.set(c.id, c); + for (const c of driveConnections) { + if (!seen.has(c.id)) seen.set(c.id, c); + } + return Array.from(seen.values()); + }, [userConnections, driveConnections]); + + // Map connectionId -> grant for quick lookup + const grantByConnectionId = useMemo( + () => new Map(grants.map((g) => [g.connectionId, g])), + [grants] + ); + + const handleToggle = async (connection: SafeConnection, enabled: boolean) => { + setToggling(connection.id); + try { + if (enabled) { + await post(`/api/agents/${pageId}/integrations`, { + connectionId: connection.id, + }); + toast.success(`Enabled ${connection.name}`); + } else { + const grant = grantByConnectionId.get(connection.id); + if (grant) { + await del(`/api/agents/${pageId}/integrations/${grant.id}`); + toast.success(`Disabled ${connection.name}`); + } + } + mutateGrants(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update'); + } finally { + setToggling(null); + } + }; + + const handleUpdateGrant = async (grant: SafeGrant, updates: { + readOnly?: boolean; + rateLimitOverride?: { requestsPerMinute?: number } | null; + }) => { + setUpdatingGrant(grant.id); + try { + await put(`/api/agents/${pageId}/integrations/${grant.id}`, updates); + mutateGrants(); + } catch { + toast.error('Failed to update grant settings'); + } finally { + setUpdatingGrant(null); + } + }; + + return ( + + + + + External Integrations + + + Enable external API connections for this agent. + + + + {isLoading ? ( +
+ + +
+ ) : error ? ( +
+ + Failed to load integrations +
+ ) : allConnections.length === 0 ? ( +
+ + + No integrations available. Connect integrations in Settings → Integrations. + +
+ ) : ( +
+ {allConnections.map((connection) => { + const grant = grantByConnectionId.get(connection.id); + const isEnabled = !!grant; + const isActive = connection.status === 'active'; + + return ( +
+
+
+
+ +
+
+
+ {connection.name} + + {connection.visibility && ( + + {connection.visibility === 'private' ? 'User' : 'Drive'} + + )} +
+ {connection.provider && ( +

{connection.provider.name}

+ )} +
+
+ handleToggle(connection, checked)} + /> +
+ + {/* Expanded config when enabled */} + {grant && ( +
+
+ + handleUpdateGrant(grant, { readOnly })} + /> +
+
+ + { + const val = e.target.value ? parseInt(e.target.value, 10) : null; + const current = grant.rateLimitOverride?.requestsPerMinute ?? null; + if (val !== current) { + handleUpdateGrant(grant, { + rateLimitOverride: val ? { requestsPerMinute: val } : null, + }); + } + }} + /> +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/integrations/ConnectIntegrationDialog.tsx b/apps/web/src/components/integrations/ConnectIntegrationDialog.tsx new file mode 100644 index 000000000..4414f4a6c --- /dev/null +++ b/apps/web/src/components/integrations/ConnectIntegrationDialog.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { post } from '@/lib/auth/auth-fetch'; +import type { SafeProvider } from './types'; + +interface ConnectIntegrationDialogProps { + provider: SafeProvider | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onConnected: () => void; + scope?: 'user' | 'drive'; + driveId?: string; +} + +export function ConnectIntegrationDialog({ + provider, + open, + onOpenChange, + onConnected, + scope = 'user', + driveId, +}: ConnectIntegrationDialogProps) { + const [name, setName] = useState(''); + const [visibility, setVisibility] = useState('owned_drives'); + const [apiKey, setApiKey] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + + const isOAuth = provider?.providerType === 'builtin'; + const requiresApiKey = !isOAuth; + + const handleConnect = async () => { + if (!provider) return; + setIsConnecting(true); + + try { + const connectionName = name.trim() || provider.name; + const endpoint = scope === 'drive' && driveId + ? `/api/drives/${driveId}/integrations` + : '/api/user/integrations'; + + const body: Record = { + providerId: provider.id, + name: connectionName, + returnUrl: '/settings/integrations', + }; + + if (scope === 'user') { + body.visibility = visibility; + } + + if (requiresApiKey && apiKey.trim()) { + body.credentials = { apiKey: apiKey.trim() }; + } + + const result = await post<{ url?: string; connection?: { id: string } }>(endpoint, body); + + if (result.url) { + window.location.href = result.url; + return; + } + + toast.success(`Connected to ${provider.name}`); + onConnected(); + onOpenChange(false); + resetForm(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to connect'); + } finally { + setIsConnecting(false); + } + }; + + const resetForm = () => { + setName(''); + setVisibility('owned_drives'); + setApiKey(''); + }; + + return ( + { if (!o) resetForm(); onOpenChange(o); }}> + + + Connect {provider?.name} + + {isOAuth + ? `You'll be redirected to ${provider?.name} to authorize access.` + : `Enter your API credentials for ${provider?.name}.`} + + + +
+
+ + setName(e.target.value)} + placeholder={provider?.name ?? 'My Connection'} + maxLength={100} + /> +
+ + {scope === 'user' && ( +
+ + +

+ Controls which drives can use this connection. +

+
+ )} + + {requiresApiKey && ( +
+ + setApiKey(e.target.value)} + placeholder="Enter your API key" + /> +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/integrations/DisconnectConfirmDialog.tsx b/apps/web/src/components/integrations/DisconnectConfirmDialog.tsx new file mode 100644 index 000000000..010f20802 --- /dev/null +++ b/apps/web/src/components/integrations/DisconnectConfirmDialog.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface DisconnectConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionName: string; + onConfirm: () => void; +} + +export function DisconnectConfirmDialog({ + open, + onOpenChange, + connectionName, + onConfirm, +}: DisconnectConfirmDialogProps) { + return ( + + + + Disconnect {connectionName}? + + This will remove the connection and revoke access. Any AI agents using this + integration will lose access to its tools. This action cannot be undone. + + + + Cancel + + Disconnect + + + + + ); +} diff --git a/apps/web/src/components/integrations/IntegrationStatusBadge.tsx b/apps/web/src/components/integrations/IntegrationStatusBadge.tsx new file mode 100644 index 000000000..f29dcb502 --- /dev/null +++ b/apps/web/src/components/integrations/IntegrationStatusBadge.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import type { ConnectionStatus } from '@/components/integrations/types'; + +const statusConfig: Record = { + active: { label: 'Active', variant: 'default' }, + pending: { label: 'Pending', variant: 'secondary' }, + expired: { label: 'Expired', variant: 'destructive' }, + error: { label: 'Error', variant: 'destructive' }, + revoked: { label: 'Revoked', variant: 'outline' }, +}; + +interface IntegrationStatusBadgeProps { + status: ConnectionStatus; +} + +export function IntegrationStatusBadge({ status }: IntegrationStatusBadgeProps) { + const config = statusConfig[status] ?? { label: status, variant: 'outline' as const }; + return {config.label}; +} diff --git a/apps/web/src/components/integrations/JsonSchemaBuilder.tsx b/apps/web/src/components/integrations/JsonSchemaBuilder.tsx new file mode 100644 index 000000000..8e3b79d6a --- /dev/null +++ b/apps/web/src/components/integrations/JsonSchemaBuilder.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Plus, Trash2 } from 'lucide-react'; + +export interface SchemaParameter { + name: string; + type: 'string' | 'number' | 'boolean' | 'integer'; + description: string; + required: boolean; +} + +interface JsonSchemaBuilderProps { + parameters: SchemaParameter[]; + onChange: (parameters: SchemaParameter[]) => void; +} + +export function JsonSchemaBuilder({ parameters, onChange }: JsonSchemaBuilderProps) { + const addParameter = () => { + onChange([...parameters, { name: '', type: 'string', description: '', required: false }]); + }; + + const removeParameter = (index: number) => { + onChange(parameters.filter((_, i) => i !== index)); + }; + + const updateParameter = (index: number, updates: Partial) => { + onChange(parameters.map((p, i) => i === index ? { ...p, ...updates } : p)); + }; + + return ( +
+
+ + +
+ + {parameters.length === 0 ? ( +

+ No parameters defined. Click Add to create one. +

+ ) : ( +
+ {parameters.map((param, index) => ( +
+
+ updateParameter(index, { name: e.target.value })} + className="h-8 text-xs" + aria-label={`Parameter ${index + 1} name`} + /> +
+
+ +
+
+ updateParameter(index, { description: e.target.value })} + className="h-8 text-xs" + aria-label={`Parameter ${index + 1} description`} + /> +
+
+ updateParameter(index, { required: !!c })} + /> + +
+
+ +
+
+ ))} +
+ )} +
+ ); +} + +/** + * Convert SchemaParameter[] to a JSON Schema object for the tool's inputSchema. + */ +export function parametersToJsonSchema(parameters: SchemaParameter[]): Record { + const properties: Record = {}; + const required: string[] = []; + + for (const param of parameters) { + if (!param.name.trim()) continue; + properties[param.name] = { + type: param.type, + ...(param.description ? { description: param.description } : {}), + }; + if (param.required) { + required.push(param.name); + } + } + + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + }; +} diff --git a/apps/web/src/components/integrations/OpenAPIImportDialog.tsx b/apps/web/src/components/integrations/OpenAPIImportDialog.tsx new file mode 100644 index 000000000..f7060bed0 --- /dev/null +++ b/apps/web/src/components/integrations/OpenAPIImportDialog.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Loader2, AlertTriangle, FileJson, Link as LinkIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { post } from '@/lib/auth/auth-fetch'; + +interface ParsedSpec { + provider: { + name: string; + description?: string; + baseUrl: string; + authMethod: { type: string }; + tools: Array<{ + id: string; + name: string; + description: string; + category: string; + }>; + }; + warnings: string[]; +} + +interface OpenAPIImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: (result: ParsedSpec) => void; +} + +export function OpenAPIImportDialog({ open, onOpenChange, onImported }: OpenAPIImportDialogProps) { + const [tab, setTab] = useState('url'); + const [specUrl, setSpecUrl] = useState(''); + const [specText, setSpecText] = useState(''); + const [isFetching, setIsFetching] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [parsed, setParsed] = useState(null); + const [selectedOps, setSelectedOps] = useState>(new Set()); + + const resetForm = () => { + setSpecUrl(''); + setSpecText(''); + setParsed(null); + setSelectedOps(new Set()); + setTab('url'); + }; + + const handleFetchUrl = async () => { + if (!specUrl.trim()) return; + setIsFetching(true); + try { + const res = await fetch(specUrl.trim()); + if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); + const text = await res.text(); + setSpecText(text); + await parseSpec(text); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to fetch spec'); + } finally { + setIsFetching(false); + } + }; + + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > MAX_FILE_SIZE) { + toast.error('File too large. Maximum size is 5MB.'); + e.target.value = ''; + return; + } + const reader = new FileReader(); + reader.onload = async (ev) => { + const text = ev.target?.result; + if (typeof text !== 'string') return; + setSpecText(text); + await parseSpec(text); + }; + reader.readAsText(file); + }; + + const parseSpec = async (spec: string) => { + setIsFetching(true); + try { + const result = await post<{ result: ParsedSpec }>('/api/integrations/providers/import-openapi', { + spec, + }); + setParsed(result.result); + // Select all operations by default + const allOps = new Set(result.result.provider.tools.map((t) => t.id)); + setSelectedOps(allOps); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to parse spec'); + } finally { + setIsFetching(false); + } + }; + + const toggleOp = (id: string) => { + setSelectedOps((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleImport = async () => { + if (!parsed) return; + setIsImporting(true); + try { + const result = await post<{ result: ParsedSpec }>('/api/integrations/providers/import-openapi', { + spec: specText, + selectedOperations: Array.from(selectedOps), + }); + toast.success(`Imported ${selectedOps.size} tools from ${parsed.provider.name}`); + onImported(result.result); + onOpenChange(false); + resetForm(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to import'); + } finally { + setIsImporting(false); + } + }; + + return ( + { if (!o) resetForm(); onOpenChange(o); }}> + + + Import from OpenAPI + + Import API tools from an OpenAPI 3.x specification. + + + + {!parsed ? ( +
+ + + + + URL + + + + File + + + + +
+ +
+ setSpecUrl(e.target.value)} + placeholder="https://api.example.com/openapi.json" + /> + +
+
+
+ + +
+ + +
+
+
+ + {isFetching && ( +
+ +
+ )} +
+ ) : ( +
+ {/* Spec Info */} +
+
+ {parsed.provider.name} + + {parsed.provider.authMethod.type} + +
+ {parsed.provider.description && ( +

{parsed.provider.description}

+ )} +

{parsed.provider.baseUrl}

+
+ + {/* Warnings */} + {parsed.warnings.length > 0 && ( +
+ {parsed.warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} + + {/* Tool Selection */} +
+ + +
+ + +
+ {parsed.provider.tools.map((tool) => ( + + ))} +
+
+
+ )} + + + {parsed ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/components/integrations/ToolBuilder.tsx b/apps/web/src/components/integrations/ToolBuilder.tsx new file mode 100644 index 000000000..7f0cfcb73 --- /dev/null +++ b/apps/web/src/components/integrations/ToolBuilder.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { put } from '@/lib/auth/auth-fetch'; +import { JsonSchemaBuilder, parametersToJsonSchema, type SchemaParameter } from './JsonSchemaBuilder'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +type ToolCategory = 'read' | 'write' | 'admin' | 'dangerous'; + +interface ToolBuilderProps { + providerId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +} + +export function ToolBuilder({ providerId, open, onOpenChange, onSaved }: ToolBuilderProps) { + const [toolName, setToolName] = useState(''); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState('read'); + const [method, setMethod] = useState('GET'); + const [pathTemplate, setPathTemplate] = useState(''); + const [parameters, setParameters] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const resetForm = () => { + setToolName(''); + setDescription(''); + setCategory('read'); + setMethod('GET'); + setPathTemplate(''); + setParameters([]); + }; + + const handleSave = async () => { + if (!toolName.trim() || !pathTemplate.trim()) { + toast.error('Tool name and path template are required'); + return; + } + + setIsSaving(true); + try { + const tool = { + id: toolName.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_'), + name: toolName.trim(), + description: description.trim(), + category, + inputSchema: parametersToJsonSchema(parameters), + execution: { + type: 'http', + config: { + method, + pathTemplate: pathTemplate.trim(), + }, + }, + }; + + await put(`/api/integrations/providers/${providerId}`, { + addTools: [tool], + }); + + toast.success(`Tool "${toolName}" added`); + onSaved(); + onOpenChange(false); + resetForm(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to save tool'); + } finally { + setIsSaving(false); + } + }; + + return ( + { if (!o) resetForm(); onOpenChange(o); }}> + + + Add Custom Tool + + Define a new API tool that AI agents can call. + + + +
+
+ + setToolName(e.target.value)} + placeholder="e.g., get_user_profile" + maxLength={100} + /> +
+ +
+ +