page.tsx•30.7 kB
"use client";
import { useConfig } from '@/src/app/config-context';
import { useIntegrations } from '@/src/app/integrations-context';
import { IntegrationForm } from '@/src/components/integrations/IntegrationForm';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/src/components/ui/alert-dialog";
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { DocStatus } from '@/src/components/utils/DocStatusSpinner';
import { useToast } from '@/src/hooks/use-toast';
import { needsUIToTriggerDocFetch } from '@/src/lib/client-utils';
import { createOAuthErrorHandler, triggerOAuthFlow } from '@/src/lib/oauth-utils';
import { composeUrl, getIntegrationIcon as getIntegrationIconName } from '@/src/lib/utils';
import type { Integration } from '@superglue/client';
import { SuperglueClient, UpsertMode } from '@superglue/client';
import { integrationOptions } from '@superglue/shared';
import { waitForIntegrationProcessing } from '@superglue/shared/utils';
import { Clock, FileDown, Globe, Key, Pencil, Plus, RotateCw, Sparkles, Trash2 } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import type { SimpleIcon } from 'simple-icons';
import * as simpleIcons from 'simple-icons';
export const detectAuthType = (credentials: any): 'oauth' | 'apikey' | 'none' => {
if (!credentials || Object.keys(credentials).length === 0) return 'none';
const oauthSpecificFields = ['client_id', 'client_secret', 'auth_url', 'token_url', 'access_token', 'refresh_token', 'scopes', 'expires_at', 'token_type'];
const allKeys = Object.keys(credentials);
const hasOAuthFields = allKeys.some(key => oauthSpecificFields.includes(key));
if (hasOAuthFields) return 'oauth';
return 'apikey';
};
export const getAuthBadge = (integration: Integration): {
type: 'oauth-configured' | 'oauth-incomplete' | 'apikey' | 'none',
label: string,
color: 'blue' | 'amber' | 'green',
icon: 'key' | 'clock'
} => {
const creds = integration.credentials || {};
const authType = detectAuthType(creds);
if (authType === 'none') {
return { type: 'none', label: 'No auth', color: 'amber', icon: 'key' };
}
if (authType === 'oauth') {
const hasAccess = !!creds.access_token;
const hasClientConfig = !!creds.client_id || !!creds.client_secret;
return hasAccess
? { type: 'oauth-configured', label: 'OAuth configured', color: 'blue', icon: 'key' }
: hasClientConfig
? { type: 'oauth-incomplete', label: 'OAuth incomplete', color: 'amber', icon: 'clock' }
: { type: 'none', label: 'No auth', color: 'amber', icon: 'key' };
}
return { type: 'apikey', label: 'API Key', color: 'green', icon: 'key' };
};
export default function IntegrationsPage() {
const config = useConfig();
const { toast } = useToast();
const searchParams = useSearchParams();
const router = useRouter();
const { integrations, pendingDocIds, loading: initialLoading, refreshIntegrations, setPendingDocIds } = useIntegrations();
const client = useMemo(() => new SuperglueClient({
endpoint: config.superglueEndpoint,
apiKey: config.superglueApiKey,
}), [config.superglueEndpoint, config.superglueApiKey]);
useEffect(() => {
const success = searchParams.get('success');
const error = searchParams.get('error');
const integration = searchParams.get('integration');
const message = searchParams.get('message');
const description = searchParams.get('description');
if (success === 'oauth_completed' && integration) {
toast({
title: 'OAuth Connection Successful',
description: `Successfully connected to ${integration}`,
});
} else if (error) {
const errorMessage = description || message || 'Failed to complete OAuth connection';
const handleOAuthError = createOAuthErrorHandler(integration || 'unknown', toast);
handleOAuthError(errorMessage);
}
}, [searchParams, toast]);
const { waitForIntegrationReady } = useMemo(() => ({
waitForIntegrationReady: (integrationIds: string[]) => {
// Create adapter for SuperglueClient to work with shared utility
const clientAdapter = {
getIntegration: (id: string) => client.getIntegration(id)
};
return waitForIntegrationProcessing(clientAdapter, integrationIds);
}
}), [client]);
const [editingIntegration, setEditingIntegration] = useState<Integration | null>(null);
// OAuth flows now use callbacks directly, no need for message listener
const getSimpleIcon = (name: string): SimpleIcon | null => {
if (!name || name === "default") return null;
const formatted = name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
const iconKey = `si${formatted}`;
try {
// @ts-ignore
let icon = simpleIcons[iconKey];
return icon || null;
} catch (e) {
return null;
}
};
const [addFormOpen, setAddFormOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(0);
const PAGE_SIZE = 10;
const filteredIntegrations = integrations?.filter(integration => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
integration.id.toLowerCase().includes(query) ||
integration.urlHost?.toLowerCase().includes(query) ||
integration.urlPath?.toLowerCase().includes(query)
);
}).sort((a, b) => a.id.localeCompare(b.id)) || [];
const paginatedIntegrations = filteredIntegrations.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filteredIntegrations.length / PAGE_SIZE);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [integrationToDelete, setIntegrationToDelete] = useState<Integration | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleDelete = async (id: string) => {
try {
await client.deleteIntegration(id);
await refreshIntegrations();
} catch (error) {
console.error('Error deleting integration:', error);
toast({
title: 'Error',
description: 'Failed to delete integration',
variant: 'destructive',
});
}
};
const handleEdit = async (integration: Integration) => {
setEditingIntegration(integration);
setAddFormOpen(true);
};
const handleAdd = () => {
setEditingIntegration(null);
setAddFormOpen(true);
};
const handleCompleteOAuth = (integration: Integration) => {
const hasRefreshToken = !!integration.credentials?.refresh_token;
const derivedGrantType = hasRefreshToken ? 'authorization_code' : 'client_credentials';
const oauthFields = {
access_token: integration.credentials?.access_token,
refresh_token: integration.credentials?.refresh_token,
client_id: integration.credentials?.client_id,
client_secret: integration.credentials?.client_secret,
scopes: integration.credentials?.scopes,
auth_url: integration.credentials?.auth_url,
token_url: integration.credentials?.token_url,
grant_type: derivedGrantType,
};
// Determine auth type dynamically
const authType = detectAuthType(integration.credentials || {});
const handleOAuthError = createOAuthErrorHandler(integration.id, toast);
const handleOAuthSuccess = (tokens: any) => {
if (tokens) {
toast({
title: 'OAuth Connection Successful',
description: `Successfully connected to ${integration.id}`,
});
if (editingIntegration?.id === integration.id) {
const updatedIntegration = {
...editingIntegration,
credentials: {
...editingIntegration.credentials,
...tokens
}
};
setEditingIntegration(updatedIntegration);
}
}
};
// Trigger OAuth flow with callbacks
triggerOAuthFlow(
integration.id,
oauthFields,
integration.id,
config.superglueApiKey,
authType,
handleOAuthError,
true,
undefined,
handleOAuthSuccess,
config.superglueEndpoint
);
};
const cleanIntegrationForInput = (integration: Integration) => {
return {
id: integration.id,
urlHost: integration.urlHost,
urlPath: integration.urlPath,
documentationUrl: integration.documentationUrl,
documentation: integration.documentation,
specificInstructions: integration.specificInstructions,
credentials: integration.credentials,
// Include documentationPending if it exists (for refresh docs functionality)
...(integration.documentationPending !== undefined && { documentationPending: integration.documentationPending }),
};
};
const handleSave = async (integration: Integration, isOAuthConnect?: boolean): Promise<Integration | null> => {
try {
if (integration.id) {
// Determine mode based on whether integration exists, not edit mode
const existingIntegration = integrations.find(i => i.id === integration.id);
const mode = existingIntegration ? UpsertMode.UPDATE : UpsertMode.CREATE;
const cleanedIntegration = cleanIntegrationForInput(integration);
const savedIntegration = await client.upsertIntegration(integration.id, cleanedIntegration, mode);
const willTriggerDocFetch = needsUIToTriggerDocFetch(savedIntegration, existingIntegration);
if (willTriggerDocFetch) {
setPendingDocIds(prev => new Set([...prev, savedIntegration.id]));
// Fire-and-forget poller for background doc fetch
waitForIntegrationReady([savedIntegration.id]).then(() => {
// Remove from pending when done
setPendingDocIds(prev => new Set([...prev].filter(id => id !== savedIntegration.id)));
}).catch((error) => {
console.error('Error waiting for docs:', error);
// Remove from pending on error
setPendingDocIds(prev => new Set([...prev].filter(id => id !== savedIntegration.id)));
});
}
if (isOAuthConnect) {
const currentCreds = JSON.stringify(editingIntegration?.credentials || {});
const newCreds = JSON.stringify(savedIntegration.credentials || {});
if (currentCreds !== newCreds) {
setEditingIntegration(savedIntegration);
}
} else {
setEditingIntegration(null);
setAddFormOpen(false);
}
await refreshIntegrations();
return savedIntegration; // Return the saved integration with correct ID
}
return null;
} catch (error) {
console.error('Error saving integration:', error);
toast({
title: 'Error',
description: 'Failed to save integration',
variant: 'destructive',
});
throw error; // Re-throw so the form can handle the error
}
};
// Function to refresh documentation for a specific integration
const handleRefreshDocs = async (integrationId: string) => {
// Get current integration
const integration = integrations.find(i => i.id === integrationId);
if (!integration) return;
// Set pending state immediately
setPendingDocIds(prev => new Set([...prev, integrationId]));
try {
// Use documentationPending flag to trigger backend refresh
const upsertData = cleanIntegrationForInput({
...integration,
documentationPending: true // Trigger refresh
});
await client.upsertIntegration(integrationId, upsertData, UpsertMode.UPDATE);
// Use proper polling to wait for docs to be ready
const results = await waitForIntegrationReady([integrationId]);
if (results.length > 0 && results[0]?.documentation) {
// Success - docs are ready
setPendingDocIds(prev => new Set([...prev].filter(id => id !== integrationId)));
toast({
title: 'Documentation Ready',
description: `Documentation for integration "${integrationId}" is now ready!`,
variant: 'default',
});
} else {
// Polling failed - reset documentationPending to false
await client.upsertIntegration(integrationId, {
...upsertData,
documentationPending: false
}, UpsertMode.UPDATE);
setPendingDocIds(prev => new Set([...prev].filter(id => id !== integrationId)));
}
} catch (error) {
console.error('Error refreshing docs:', error);
// Reset documentationPending to false on error
try {
const integration = integrations.find(i => i.id === integrationId);
if (integration) {
const resetData = cleanIntegrationForInput({
...integration,
documentation: integration.documentation || '',
documentationPending: false
});
await client.upsertIntegration(integrationId, resetData, UpsertMode.UPDATE);
}
} catch (resetError) {
console.error('Error resetting documentationPending:', resetError);
}
setPendingDocIds(prev => new Set([...prev].filter(id => id !== integrationId)));
}
};
// Helper function to determine if integration has documentation
const hasDocumentation = (integration: Integration) => {
// Check if integration has documentation URL and is not pending
return !!(integration.documentationUrl?.trim() && !pendingDocIds.has(integration.id));
};
// Helper to get icon for integration
function getIntegrationIcon(integration: Integration) {
const iconName = getIntegrationIconName(integration);
return iconName ? getSimpleIcon(iconName) : null;
}
const handleRefresh = async () => {
setIsRefreshing(true);
await refreshIntegrations();
setIsRefreshing(false);
};
const blockAllContent = initialLoading && !addFormOpen;
return (
<div className="flex flex-col min-h-full p-8 w-full">
{blockAllContent ? null : (
<>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold">Integrations</h1>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
className="transition-transform"
>
<RotateCw className={`h-5 w-5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
{addFormOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-background rounded-xl max-w-2xl w-full p-0">
<IntegrationForm
modal={true}
integration={editingIntegration}
onSave={handleSave}
onCancel={() => {
setAddFormOpen(false);
setEditingIntegration(null);
}}
integrationOptions={integrationOptions}
getSimpleIcon={getSimpleIcon}
/>
</div>
</div>
)}
{integrations.length === 0 && !addFormOpen ? (
<div className="flex flex-col items-center justify-center flex-1 py-24">
<Globe className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg text-muted-foreground mb-2">No integrations added yet.</p>
<p className="text-sm text-muted-foreground mb-6">Integrations let you connect to APIs and data sources for your tools.</p>
<Button variant="outline" size="sm" onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" /> Add Integration
</Button>
</div>
) : (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-3 mb-2">
<Input
placeholder="Search integrations..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(0);
}}
className="flex-1 h-8"
/>
<Button variant="outline" size="sm" onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" /> Add Integration
</Button>
</div>
{paginatedIntegrations.map((integration) => {
const badge = getAuthBadge(integration);
return (
<div key={integration.id} className="relative">
<div className="flex items-center gap-3 border rounded-lg p-4 bg-card">
<div className="flex items-center gap-3 flex-1 min-w-0 mr-4">
{getIntegrationIcon(integration) ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill={`#${getIntegrationIcon(integration)?.hex}`}
className="flex-shrink-0"
>
<path d={getIntegrationIcon(integration)?.path || ''} />
</svg>
) : (
<Globe className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium truncate">{integration.id}</span>
<span className="text-sm text-muted-foreground truncate">
{composeUrl(integration.urlHost, integration.urlPath) || 'No API endpoint'}
</span>
</div>
</div>
<div className="ml-auto flex items-center gap-3">
<div className="flex items-center gap-2">
<DocStatus
pending={pendingDocIds.has(integration.id)}
hasDocumentation={hasDocumentation(integration)}
/>
{(() => {
const colorClasses = {
blue: 'text-blue-600 dark:text-blue-300 bg-blue-500/10',
amber: 'text-amber-800 dark:text-amber-300 bg-amber-500/10',
green: 'text-green-800 dark:text-green-300 bg-green-500/10'
};
return (
<span className={`text-xs ${colorClasses[badge.color]} px-2 py-0.5 rounded flex items-center gap-1`}>
{badge.icon === 'clock' ? <Clock className="h-3 w-3" /> : <Key className="h-3 w-3" />}
{badge.label}
</span>
);
})()}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => badge.type === 'oauth-incomplete' ? handleCompleteOAuth(integration) : router.push(`/tools?integration=${integration.id}`)}
title={badge.type === 'oauth-incomplete' ? "Start OAuth flow to complete configuration" : "Build a tool with this integration"}
disabled={false}
>
{badge.type === 'oauth-incomplete' ? (
<>
<Key className="h-4 w-4 mr-2" />
Complete OAuth
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
Build Tool
</>
)}
</Button>
<Button
variant="outline"
size="sm"
className="text-muted-foreground hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleRefreshDocs(integration.id)}
disabled={
!integration.documentationUrl ||
!integration.documentationUrl.trim() ||
(pendingDocIds.has(integration.id) && Date.now() - new Date(integration.updatedAt).getTime() < 60000) ||
integration.documentationUrl.startsWith('file://')
}
title={
pendingDocIds.has(integration.id)
? "Documentation is already being processed"
: integration.documentationUrl?.startsWith('file://')
? "Cannot refresh file uploads"
: !integration.documentationUrl || !integration.documentationUrl.trim()
? "No documentation URL to refresh"
: "Refresh documentation from URL"
}
>
<FileDown className="h-4 w-4 mr-2" />
Refresh Docs
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleEdit(integration)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
setIntegrationToDelete(integration);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
})}
<div className="flex justify-between items-center mt-4">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {page + 1} of {totalPages}</span>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}>Next</Button>
</div>
</div>
)}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Integration?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the integration "{integrationToDelete?.id}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={async () => {
if (integrationToDelete) {
await handleDelete(integrationToDelete.id);
setDeleteDialogOpen(false);
setIntegrationToDelete(null);
}
}}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>)}
</div>
);
}