add composio tools

This commit is contained in:
Ramnique Singh 2025-07-03 15:19:48 +05:30
parent 8038d52495
commit 078f785a9e
27 changed files with 2514 additions and 140 deletions

View file

@ -0,0 +1,283 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Info, RefreshCw, Search } from 'lucide-react';
import clsx from 'clsx';
import { listToolkits, listTools, updateComposioSelectedTools } from '@/app/actions/composio_actions';
import { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ComposioToolsPanel } from './ComposioToolsPanel';
import { ToolkitCard } from './ToolkitCard';
type ToolkitType = z.infer<typeof ZToolkit>;
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
type ProjectType = z.infer<typeof Project>;
export function Composio() {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const [savingTools, setSavingTools] = useState(false);
const loadProjectConfig = useCallback(async () => {
try {
const config = await getProjectConfig(projectId);
setProjectConfig(config);
} catch (err: any) {
console.error('Error fetching project config:', err);
setError('Unable to load project configuration.');
}
}, [projectId]);
const loadAllToolkits = useCallback(async () => {
let cursor: string | null = null;
let allToolkits: ToolkitType[] = [];
try {
setLoading(true);
do {
const response: ToolkitListResponse = await listToolkits(projectId, cursor);
allToolkits = [...allToolkits, ...response.items];
cursor = response.next_cursor;
} while (cursor !== null);
// // Only show those toolkits that
// // - either do not require authentication, OR
// // - have oauth2 managed by Composio
// const filteredToolkits = allToolkits.filter(toolkit => {
// const noAuth = toolkit.no_auth;
// const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2');
// const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2');
// return noAuth || hasOAuth2;
// });
setToolkits(allToolkits);
setError(null);
} catch (err: any) {
setError('Unable to load all Composio toolkits. Please check your connection and try again.');
console.error('Error fetching all toolkits:', err);
setToolkits([]);
} finally {
setLoading(false);
}
}, [projectId]);
const handleManageTools = useCallback((toolkit: ToolkitType) => {
setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true);
}, []);
const handleCloseToolsPanel = useCallback(() => {
setSelectedToolkit(null);
setIsToolsPanelOpen(false);
}, []);
const handleProjectConfigUpdate = useCallback(() => {
loadProjectConfig();
}, [loadProjectConfig]);
const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer<typeof ZTool>[]) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from project config
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
// Create a map of existing tools by slug for easy lookup
const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool]));
// Add or update the new selections
for (const tool of selectedToolObjects) {
existingToolsMap.set(tool.slug, tool);
}
// Convert back to array
const mergedSelectedTools = Array.from(existingToolsMap.values());
await updateComposioSelectedTools(projectId, mergedSelectedTools);
// Refresh project config to get updated data
await loadProjectConfig();
} catch (error) {
console.error('Error saving tool selection:', error);
} finally {
setSavingTools(false);
}
}, [projectId, projectConfig, loadProjectConfig]);
const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from project config
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
// Filter out all tools from the specified toolkit
const filteredSelectedTools = existingSelectedTools.filter(tool =>
tool.toolkit.slug !== toolkitSlug
);
await updateComposioSelectedTools(projectId, filteredSelectedTools);
// Refresh project config to get updated data
await loadProjectConfig();
} catch (error) {
console.error('Error removing toolkit tools:', error);
} finally {
setSavingTools(false);
}
}, [projectId, projectConfig, loadProjectConfig]);
useEffect(() => {
loadProjectConfig();
}, [loadProjectConfig]);
useEffect(() => {
loadAllToolkits();
}, [loadAllToolkits]);
const filteredToolkits = toolkits.filter(toolkit => {
const searchLower = searchQuery.toLowerCase();
return (
toolkit.name.toLowerCase().includes(searchLower) ||
toolkit.meta.description.toLowerCase().includes(searchLower) ||
toolkit.slug.toLowerCase().includes(searchLower)
);
});
if (loading) {
return (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading Composio toolkits...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] space-y-6 px-4">
<p className="text-center text-red-500 dark:text-red-400 max-w-[600px]">
{error}
</p>
<Button
variant="secondary"
onClick={() => {
loadProjectConfig();
loadAllToolkits();
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="shrink-0">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 flex items-center gap-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<input
type="text"
placeholder="Search toolkits..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
/>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
</div>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
loadProjectConfig();
loadAllToolkits();
}}
disabled={loading}
>
<div className="inline-flex items-center">
<RefreshCw className={clsx("h-4 w-4", loading && "animate-spin")} />
<span className="ml-2">Refresh</span>
</div>
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredToolkits.map((toolkit) => {
const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
return (
<ToolkitCard
key={toolkit.slug}
toolkit={toolkit}
projectId={projectId}
isConnected={isConnected}
connectedAccountId={connectedAccountId}
projectConfig={projectConfig}
onManageTools={() => handleManageTools(toolkit)}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
/>
);
})}
</div>
{filteredToolkits.length === 0 && !loading && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'}
</p>
</div>
)}
{/* Tools Panel */}
<ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
projectConfig={projectConfig}
onUpdateToolsSelection={handleUpdateToolsSelection}
isSaving={savingTools}
/>
</div>
);
}

View file

@ -0,0 +1,269 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { PictureImg } from '@/components/ui/picture-img';
import { Checkbox } from '@heroui/react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { listTools } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZTool, ZListResponse } from '@/app/lib/composio/composio';
import { SlidePanel } from '@/components/ui/slide-panel';
import { Project } from '@/app/lib/types/project_types';
type ToolType = z.infer<typeof ZTool>;
type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>;
type ProjectType = z.infer<typeof Project>;
interface ComposioToolsPanelProps {
toolkit: {
slug: string;
name: string;
meta: {
logo: string;
};
no_auth?: boolean;
} | null;
isOpen: boolean;
onClose: () => void;
projectConfig: ProjectType | null;
onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void;
isSaving: boolean;
}
export function ComposioToolsPanel({
toolkit,
isOpen,
onClose,
projectConfig,
onUpdateToolsSelection,
isSaving
}: ComposioToolsPanelProps) {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
const [tools, setTools] = useState<ToolType[]>([]);
const [toolsLoading, setToolsLoading] = useState(false);
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false);
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => {
try {
setToolsLoading(true);
const response: ToolListResponse = await listTools(projectId, toolkitSlug, cursor);
setTools(response.items);
setNextCursor(response.next_cursor);
if (cursor === null) {
// First page - reset pagination state
setCurrentCursor(null);
setCursorHistory([]);
}
} catch (err: any) {
console.error('Error fetching tools:', err);
setTools([]);
} finally {
setToolsLoading(false);
}
}, [projectId]);
const handleNextPage = useCallback(async () => {
if (!nextCursor || !toolkit) return;
// Add current cursor to history
setCursorHistory(prev => [...prev, currentCursor || '']);
setCurrentCursor(nextCursor);
await loadToolsForToolkit(toolkit.slug, nextCursor);
}, [nextCursor, toolkit, currentCursor, loadToolsForToolkit]);
const handlePreviousPage = useCallback(async () => {
if (cursorHistory.length === 0 || !toolkit) return;
// Get the previous cursor from history
const previousCursor = cursorHistory[cursorHistory.length - 1];
const newHistory = cursorHistory.slice(0, -1);
setCursorHistory(newHistory);
setCurrentCursor(previousCursor);
await loadToolsForToolkit(toolkit.slug, previousCursor);
}, [cursorHistory, toolkit, loadToolsForToolkit]);
const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => {
setSelectedTools(prev => {
const next = new Set(prev);
if (selected) {
next.add(toolSlug);
} else {
next.delete(toolSlug);
}
setHasChanges(true);
return next;
});
}, []);
const handleSaveTools = useCallback(async () => {
// Convert selected tool slugs to actual tool objects
const selectedToolObjects = tools.filter(tool => selectedTools.has(tool.slug));
await onUpdateToolsSelection(selectedToolObjects);
setHasChanges(false);
}, [onUpdateToolsSelection, selectedTools, tools]);
const handleClose = useCallback(() => {
setTools([]);
setSelectedTools(new Set());
setHasChanges(false);
if (hasChanges) {
if (window.confirm('You have unsaved changes. Are you sure you want to close?')) {
onClose();
}
} else {
onClose();
}
}, [onClose, hasChanges]);
// Initialize selected tools from project config when opening the panel
useEffect(() => {
if (toolkit && isOpen && projectConfig?.composioSelectedTools) {
const toolSlugs = new Set(projectConfig.composioSelectedTools.map(tool => tool.slug));
setSelectedTools(toolSlugs);
setHasChanges(false);
}
}, [toolkit, isOpen, projectConfig]);
useEffect(() => {
if (toolkit && isOpen) {
loadToolsForToolkit(toolkit.slug, null);
}
}, [toolkit, isOpen, loadToolsForToolkit]);
if (!toolkit) return null;
// Check if the toolkit is connected (has an active connected account) or doesn't require auth
const isToolkitConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
return (
<SlidePanel
isOpen={isOpen}
onClose={handleClose}
title={
<div className="flex items-center gap-3">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
width={24}
height={24}
className="rounded-md object-cover"
/>
)}
<span>{toolkit.name}</span>
</div>
}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4>
<div className="flex items-center gap-2">
{!isToolkitConnected && !toolkit.no_auth && (
<div className="text-sm text-orange-600 dark:text-orange-400 px-3 py-1 rounded-full bg-orange-50 dark:bg-orange-900/20">
Toolkit not connected
</div>
)}
{hasChanges && (
<Button
variant="primary"
size="sm"
onClick={handleSaveTools}
disabled={isSaving || !isToolkitConnected}
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-b-transparent border-white mr-2" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
)}
</div>
</div>
</div>
{/* Scrollable Tools List */}
<div className="flex-1 overflow-y-auto">
{toolsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
</div>
) : (
<div className="space-y-4">
{tools.map((tool) => (
<div key={tool.slug} className={`group p-4 rounded-lg transition-all duration-200 border border-transparent ${
isToolkitConnected
? 'bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600'
: 'bg-gray-100/50 dark:bg-gray-900/50 opacity-60'
}`}>
<div className="flex items-start gap-3">
<Checkbox
isSelected={selectedTools.has(tool.slug)}
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
size="sm"
isDisabled={!isToolkitConnected}
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{tool.name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{tool.description}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Fixed Pagination Controls */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex items-center justify-end">
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={handlePreviousPage}
disabled={cursorHistory.length === 0 || toolsLoading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextPage}
disabled={!nextCursor || toolsLoading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
</div>
</SlidePanel>
);
}

View file

@ -0,0 +1,518 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner, Button as HeroButton, Input } from "@heroui/react";
import { PictureImg } from '@/components/ui/picture-img';
import { Wrench, Shield, Key, Globe, ArrowLeft } from "lucide-react";
import { getToolkit, createComposioManagedOauth2ConnectedAccount, syncConnectedAccount, listToolkits, createCustomConnectedAccount } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZGetToolkitResponse, ZToolkit, ZComposioField, ZAuthScheme } from '@/app/lib/composio/composio';
interface ToolkitAuthModalProps {
isOpen: boolean;
onClose: () => void;
toolkitSlug: string;
projectId: string;
onComplete: () => void;
}
export function ToolkitAuthModal({
isOpen,
onClose,
toolkitSlug,
projectId,
onComplete
}: ToolkitAuthModalProps) {
const [toolkit, setToolkit] = useState<z.infer<typeof ZGetToolkitResponse> | null>(null);
const [toolkitDetails, setToolkitDetails] = useState<z.infer<typeof ZToolkit> | null>(null);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [showForm, setShowForm] = useState(false);
const [selectedAuthScheme, setSelectedAuthScheme] = useState<z.infer<typeof ZAuthScheme> | null>(null);
const [formData, setFormData] = useState<Record<string, string>>({});
// Fetch toolkit details when modal opens
useEffect(() => {
if (isOpen && toolkitSlug) {
setLoading(true);
setError(null);
// Fetch both toolkit auth details and full toolkit info
Promise.all([
getToolkit(projectId, toolkitSlug),
listToolkits(projectId).then(response =>
response.items.find(t => t.slug === toolkitSlug) || null
)
])
.then(([authDetails, fullDetails]) => {
setToolkit(authDetails);
setToolkitDetails(fullDetails);
})
.catch(err => {
console.error('Failed to fetch toolkit:', err);
setError('Failed to load toolkit details');
})
.finally(() => setLoading(false));
}
}, [isOpen, toolkitSlug, projectId]);
// Reset form state when modal closes
useEffect(() => {
if (!isOpen) {
setShowForm(false);
setSelectedAuthScheme(null);
setFormData({});
setError(null);
}
}, [isOpen]);
const handleOAuthCompletion = useCallback(async (connectedAccountId: string) => {
try {
// Sync the connected account to get the latest status
await syncConnectedAccount(projectId, toolkitSlug, connectedAccountId);
// Call completion callback
onComplete();
onClose();
} catch (error) {
console.error('Error syncing connected account after OAuth:', error);
setError('Authentication completed but failed to sync status. Please refresh and try again.');
}
}, [projectId, toolkitSlug, onComplete, onClose]);
const handleComposioOAuth2 = useCallback(async () => {
setError(null);
setProcessing(true);
try {
// Start OAuth flow
const returnUrl = `${window.location.origin}/composio/oauth2/callback`;
const response = await createComposioManagedOauth2ConnectedAccount(projectId, toolkitSlug, returnUrl);
console.log('OAuth response:', JSON.stringify(response, null, 2));
// if error, set error
if ('error' in response) {
if (response.error === 'CUSTOM_OAUTH2_CONFIG_REQUIRED') {
setError('Please set up a custom OAuth2 configuration for this toolkit in the Composio dashboard');
} else {
setError('Failed to connect to toolkit');
}
return;
}
// Open OAuth window
const authWindow = window.open(
response.connectionData.val.redirectUrl as string,
'_blank',
'width=600,height=700'
);
if (authWindow) {
// Use postMessage since we control the callback URL
const handleMessage = (event: MessageEvent) => {
// Only accept messages from our own origin
if (event.origin !== window.location.origin) {
return;
}
// Check if this is an OAuth completion message
if (event.data && event.data.type === 'OAUTH_COMPLETE') {
window.removeEventListener('message', handleMessage);
clearInterval(checkInterval);
if (event.data.success) {
// Handle successful OAuth completion
handleOAuthCompletion(response.id);
} else {
// Handle OAuth error
const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed';
setError(errorMessage);
}
}
};
// Listen for postMessage from our callback page
window.addEventListener('message', handleMessage);
// Minimal fallback: check if window closes without message
const checkInterval = setInterval(() => {
if (authWindow.closed) {
clearInterval(checkInterval);
window.removeEventListener('message', handleMessage);
// If we didn't get a postMessage, still try to sync
// (in case the message was missed for some reason)
handleOAuthCompletion(response.id);
}
}, 1000); // Check less frequently since we expect postMessage
} else {
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
setError('Failed to open authentication window');
}
} catch (err: any) {
console.error('OAuth flow failed:', err);
const errorMessage = err.message || 'Failed to connect to toolkit';
setError(errorMessage);
} finally {
setProcessing(false);
}
}, [projectId, toolkitSlug, handleOAuthCompletion]);
const handleCustomAuth = useCallback((authScheme: z.infer<typeof ZAuthScheme>) => {
setSelectedAuthScheme(authScheme);
// Initialize form data with default values
const authConfig = toolkit?.auth_config_details?.find(config => config.mode === authScheme);
if (authConfig) {
const initialData: Record<string, string> = {};
// Try connected_account_initiation first, fallback to auth_config_creation
const requiredFields = authConfig.fields.connected_account_initiation.required.length > 0
? authConfig.fields.connected_account_initiation.required
: authConfig.fields.auth_config_creation.required;
const optionalFields = authConfig.fields.connected_account_initiation.optional.length > 0
? authConfig.fields.connected_account_initiation.optional
: authConfig.fields.auth_config_creation.optional;
// Add defaults for required fields
requiredFields.forEach(field => {
if (field.default) {
initialData[field.name] = field.default;
}
});
// Add defaults for optional fields
optionalFields.forEach(field => {
if (field.default) {
initialData[field.name] = field.default;
}
});
setFormData(initialData);
}
setShowForm(true);
}, [toolkit]);
const handleFormSubmit = useCallback(async () => {
if (!selectedAuthScheme || !toolkit) return;
setError(null);
setProcessing(true);
try {
const callbackUrl = `${window.location.origin}/composio/oauth2/callback`;
const response = await createCustomConnectedAccount(projectId, {
toolkitSlug: toolkit.slug,
authConfig: {
authScheme: selectedAuthScheme,
credentials: formData,
},
callbackUrl,
});
console.log('Custom auth response:', JSON.stringify(response, null, 2));
// Check if we need to open a popup window (OAuth2 flow)
if ('connectionData' in response &&
response.connectionData.val &&
'redirectUrl' in response.connectionData.val &&
response.connectionData.val.redirectUrl) {
// Open OAuth window for custom OAuth2
const authWindow = window.open(
response.connectionData.val.redirectUrl as string,
'_blank',
'width=600,height=700'
);
if (authWindow) {
// Use the same postMessage logic as Composio OAuth2
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return;
}
if (event.data && event.data.type === 'OAUTH_COMPLETE') {
window.removeEventListener('message', handleMessage);
clearInterval(checkInterval);
if (event.data.success) {
handleOAuthCompletion(response.id);
} else {
const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed';
setError(errorMessage);
}
}
};
window.addEventListener('message', handleMessage);
const checkInterval = setInterval(() => {
if (authWindow.closed) {
clearInterval(checkInterval);
window.removeEventListener('message', handleMessage);
handleOAuthCompletion(response.id);
}
}, 1000);
} else {
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
setError('Failed to open authentication window');
}
} else {
// No redirect needed, just sync and complete
await syncConnectedAccount(projectId, toolkitSlug, response.id);
onComplete();
onClose();
}
} catch (err: any) {
console.error('Custom auth failed:', err);
const errorMessage = err.message || 'Failed to authenticate with toolkit';
setError(errorMessage);
} finally {
setProcessing(false);
}
}, [selectedAuthScheme, toolkit, projectId, formData, handleOAuthCompletion, onComplete, onClose, toolkitSlug]);
const handleBackToOptions = useCallback(() => {
setShowForm(false);
setSelectedAuthScheme(null);
setFormData({});
setError(null);
}, []);
const getAuthMethodIcon = (authScheme: string) => {
switch (authScheme) {
case 'OAUTH2':
return <Shield className="h-5 w-5" />;
case 'API_KEY':
return <Key className="h-5 w-5" />;
case 'BEARER_TOKEN':
return <Key className="h-5 w-5" />;
default:
return <Globe className="h-5 w-5" />;
}
};
const getAuthMethodName = (authScheme: string) => {
switch (authScheme) {
case 'OAUTH2':
return 'OAuth2';
case 'API_KEY':
return 'API Key';
case 'BEARER_TOKEN':
return 'Bearer Token';
case 'BASIC':
return 'Basic Auth';
default:
return authScheme.toLowerCase().replace('_', ' ');
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={onClose}
size="md"
classNames={{
base: "bg-white dark:bg-gray-900",
header: "border-b border-gray-200 dark:border-gray-800",
footer: "border-t border-gray-200 dark:border-gray-800",
}}
>
<ModalContent>
<ModalHeader className="flex gap-3 items-center">
{showForm && (
<HeroButton
variant="light"
size="sm"
isIconOnly
onPress={handleBackToOptions}
className="mr-1"
>
<ArrowLeft className="h-4 w-4" />
</HeroButton>
)}
{toolkitDetails?.meta?.logo ? (
<PictureImg
src={toolkitDetails.meta.logo}
alt={`${toolkitSlug} logo`}
className="w-8 h-8 rounded-md object-cover"
/>
) : (
<Wrench className="w-5 h-5 text-blue-500" />
)}
<span>
{showForm
? `Configure ${getAuthMethodName(selectedAuthScheme || '')}`
: `Connect to ${toolkitSlug}`
}
</span>
</ModalHeader>
<ModalBody>
{loading ? (
<div className="flex justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md">
{error}
</div>
) : toolkit ? (
showForm ? (
// Form view
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Enter your credentials for {getAuthMethodName(selectedAuthScheme || '')} authentication:
</div>
{(() => {
const authConfig = toolkit.auth_config_details?.find(config => config.mode === selectedAuthScheme);
if (!authConfig) {
return <div>No configuration found for {selectedAuthScheme}</div>;
}
// Try connected_account_initiation first, fallback to auth_config_creation
const allFields = [
...authConfig.fields.connected_account_initiation.required.map(field => ({ ...field, required: true })),
...authConfig.fields.connected_account_initiation.optional.map(field => ({ ...field, required: false }))
];
// If no fields in connected_account_initiation, try auth_config_creation
if (allFields.length === 0) {
allFields.push(
...authConfig.fields.auth_config_creation.required.map(field => ({ ...field, required: true })),
...authConfig.fields.auth_config_creation.optional.map(field => ({ ...field, required: false }))
);
}
return (
<div className="space-y-4">
{allFields.map(field => (
<Input
key={field.name}
label={field.displayName}
placeholder={field.description}
value={formData[field.name] || ''}
onValueChange={(value) => setFormData(prev => ({ ...prev, [field.name]: value }))}
isRequired={field.required}
type={field.type === 'password' ? 'password' : 'text'}
variant="bordered"
description={field.description}
required={field.required}
/>
))}
</div>
);
})()}
</div>
) : (
// Auth options view
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
Choose how you&apos;d like to authenticate with this toolkit:
</div>
<div className="space-y-3">
{/* OAuth2 Composio Managed */}
{toolkit.composio_managed_auth_schemes.includes('OAUTH2') && (
<HeroButton
className="w-full justify-start gap-3 h-auto py-4 px-4"
variant="bordered"
onPress={handleComposioOAuth2}
isDisabled={processing}
size="lg"
>
<div className="bg-green-100 dark:bg-green-900/20 p-2 rounded-lg">
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="text-left">
<div className="font-medium">Connect using OAuth2</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Secure authentication managed by Composio
</div>
</div>
{processing && <Spinner size="sm" className="ml-auto" />}
</HeroButton>
)}
{/* Custom OAuth2 - always show if OAuth2 is supported */}
{(toolkit.composio_managed_auth_schemes.includes('OAUTH2') ||
toolkit.auth_config_details?.some(config => config.mode === 'OAUTH2')) && (
<HeroButton
className="w-full justify-start gap-3 h-auto py-4 px-4"
variant="bordered"
onPress={() => handleCustomAuth('OAUTH2')}
isDisabled={processing}
size="lg"
>
<div className="bg-orange-100 dark:bg-orange-900/20 p-2 rounded-lg">
<Shield className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="text-left">
<div className="font-medium">Connect using custom OAuth2 app</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Use your own OAuth2 configuration
</div>
</div>
</HeroButton>
)}
{/* Other auth schemes (excluding OAuth2 since it's shown above) */}
{toolkit.auth_config_details?.filter(config => config.mode !== 'OAUTH2').map(config => (
<HeroButton
key={config.mode}
className="w-full justify-start gap-3 h-auto py-4 px-4"
variant="bordered"
onPress={() => handleCustomAuth(config.mode)}
isDisabled={processing}
size="lg"
>
<div className="bg-blue-100 dark:bg-blue-900/20 p-2 rounded-lg">
{getAuthMethodIcon(config.mode)}
</div>
<div className="text-left">
<div className="font-medium">Connect using {getAuthMethodName(config.mode)}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Enter your credentials
</div>
</div>
</HeroButton>
))}
</div>
</div>
)
) : null}
</ModalBody>
<ModalFooter>
{showForm ? (
<>
<HeroButton variant="bordered" onPress={handleBackToOptions} isDisabled={processing}>
Back
</HeroButton>
<HeroButton
variant="solid"
color="primary"
onPress={handleFormSubmit}
isDisabled={processing}
isLoading={processing}
>
{processing ? 'Connecting...' : 'Connect'}
</HeroButton>
</>
) : (
<HeroButton variant="bordered" onPress={onClose}>
Cancel
</HeroButton>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View file

@ -0,0 +1,220 @@
'use client';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { PictureImg } from '@/components/ui/picture-img';
import { Wrench } from 'lucide-react';
import clsx from 'clsx';
import { Spinner } from '@heroui/react';
import { deleteConnectedAccount } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZToolkit } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ToolkitAuthModal } from './ToolkitAuthModal';
type ToolkitType = z.infer<typeof ZToolkit>;
type ProjectType = z.infer<typeof Project>;
const toolkitCardStyles = {
base: clsx(
"group p-6 rounded-xl transition-all duration-200",
"bg-white dark:bg-gray-900 shadow-sm dark:shadow-none",
"border-2 border-gray-200/80 dark:border-gray-700/80",
"hover:shadow-md dark:hover:shadow-none",
"hover:border-blue-200 dark:hover:border-blue-900",
"min-h-[280px] flex flex-col"
),
};
interface ToolkitCardProps {
toolkit: ToolkitType;
projectId: string;
isConnected: boolean;
connectedAccountId?: string;
projectConfig: ProjectType | null;
onManageTools: () => void;
onProjectConfigUpdate: () => void;
onRemoveToolkitTools: (toolkitSlug: string) => void;
}
export function ToolkitCard({
toolkit,
projectId,
isConnected,
connectedAccountId,
projectConfig,
onManageTools,
onProjectConfigUpdate,
onRemoveToolkitTools
}: ToolkitCardProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const handleToggleConnection = useCallback(async () => {
const newState = !isConnected;
// Clear any previous error when starting a new operation
setError(null);
if (newState) {
// Show authentication modal
setShowAuthModal(true);
} else {
// Disconnect - remove the connected account
setIsProcessing(true);
try {
if (connectedAccountId) {
await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId);
onProjectConfigUpdate();
onRemoveToolkitTools(toolkit.slug);
} else {
// Fallback: just refresh the project config
onProjectConfigUpdate();
}
} catch (err: any) {
console.error('Disconnect failed:', err);
const errorMessage = err.message || 'Failed to disconnect toolkit';
setError(errorMessage);
} finally {
setIsProcessing(false);
}
}
}, [projectId, toolkit.slug, isConnected, connectedAccountId, onProjectConfigUpdate, onRemoveToolkitTools]);
const handleAuthComplete = useCallback(() => {
// Update project config when authentication completes
onProjectConfigUpdate();
}, [onProjectConfigUpdate]);
// Calculate selected tools count for this toolkit
const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool =>
tool.toolkit.slug === toolkit.slug
).length || 0;
return (
<div className={toolkitCardStyles.base}>
<div className="flex flex-col h-full">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{toolkit.meta.logo && (
<PictureImg
src={toolkit.meta.logo}
alt={`${toolkit.name} logo`}
className="w-8 h-8 rounded-md object-cover"
/>
)}
<div>
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100">
{toolkit.name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
{toolkit.meta.tools_count} tools
</span>
{selectedToolsCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300">
{selectedToolsCount} selected
</span>
)}
{toolkit.no_auth && (
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300">
No Auth
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{toolkit.no_auth ? (
<div className="flex items-center gap-2">
<Switch
checked={true}
onCheckedChange={() => {}} // No-op for no-auth toolkits
disabled={true}
className={clsx(
"data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600",
"data-[state=unchecked]:bg-emerald-500 dark:data-[state=unchecked]:bg-emerald-600",
"opacity-50 cursor-not-allowed",
"scale-75"
)}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Always Available
</span>
</div>
) : (
<Switch
checked={isConnected}
onCheckedChange={handleToggleConnection}
disabled={isProcessing}
className={clsx(
"data-[state=checked]:bg-blue-500 dark:data-[state=checked]:bg-blue-600",
"data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-700",
isProcessing && "opacity-50 cursor-not-allowed",
"scale-75"
)}
/>
)}
</div>
</div>
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-3">
{toolkit.meta.description}
</p>
</div>
<div className="mt-auto">
<div className="flex items-center justify-between">
<div className="text-xs text-gray-400 dark:text-gray-500">
ID: {toolkit.slug}
</div>
<div className="flex items-center gap-2">
{isProcessing && (
<div className="flex items-center gap-1 text-xs py-1 px-2 rounded-full text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20">
<Spinner size="sm" />
<span>Processing...</span>
</div>
)}
{(isConnected || toolkit.no_auth) && !isProcessing && (
<div className="text-xs py-1 px-2 rounded-full text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20">
{toolkit.no_auth ? 'Available' : 'Connected'}
</div>
)}
{error && (
<div className="text-xs py-1 px-2 rounded-full text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
Error: {error}
</div>
)}
<Button
size="sm"
variant="secondary"
onClick={onManageTools}
className="text-xs"
>
<div className="inline-flex items-center">
<Wrench className="h-3.5 w-3.5" />
<span className="ml-1.5">Tools</span>
</div>
</Button>
</div>
</div>
</div>
</div>
<ToolkitAuthModal
key={toolkit.slug}
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={toolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
</div>
);
}

View file

@ -4,10 +4,11 @@ import { useState } from 'react';
import { Tabs, Tab } from '@/components/ui/tabs';
import { HostedServers } from './HostedServers';
import { CustomServers } from './CustomServers';
import { Composio } from './Composio';
import type { Key } from 'react';
export function ToolsConfig() {
const [activeTab, setActiveTab] = useState('hosted');
export function ToolsConfig({ useComposioTools }: { useComposioTools: boolean }) {
const [activeTab, setActiveTab] = useState(useComposioTools ? 'composio' : 'hosted');
const handleTabChange = (key: Key) => {
setActiveTab(key.toString());
@ -22,6 +23,13 @@ export function ToolsConfig() {
className="w-full"
fullWidth
>
{useComposioTools && (
<Tab key="composio" title="Composio">
<div className="mt-4 p-6">
<Composio />
</div>
</Tab>
)}
<Tab key="hosted" title={
<div className="flex items-center gap-2">
<span>Tools Library</span>