diff --git a/apps/mail/app/(routes)/settings/[...settings]/page.tsx b/apps/mail/app/(routes)/settings/[...settings]/page.tsx index 7553a0878e..3d3c93e767 100644 --- a/apps/mail/app/(routes)/settings/[...settings]/page.tsx +++ b/apps/mail/app/(routes)/settings/[...settings]/page.tsx @@ -22,7 +22,6 @@ export default function SettingsPage() { const params = useParams(); const section = params.settings?.[0] || 'general'; - const SettingsComponent = settingsPages[section]; if (!SettingsComponent) { diff --git a/apps/mail/app/(routes)/settings/connections/page.tsx b/apps/mail/app/(routes)/settings/connections/page.tsx index 018a3c271a..07d67d3fb0 100644 --- a/apps/mail/app/(routes)/settings/connections/page.tsx +++ b/apps/mail/app/(routes)/settings/connections/page.tsx @@ -7,36 +7,85 @@ import { DialogTrigger, DialogClose, } from '@/components/ui/dialog'; +import { AddIntegrationDialog, toolkitIcons } from '@/components/connection/add-integration-dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useComposioConnections } from '@/hooks/use-composio-connection'; +import { useArcadeConnections } from '@/hooks/use-arcade-connection'; import { SettingsCard } from '@/components/settings/settings-card'; import { AddConnectionDialog } from '@/components/connection/add'; - +import { Trash, Plus, Unplug, Sparkles } from 'lucide-react'; import { useSession, authClient } from '@/lib/auth-client'; import { useConnections } from '@/hooks/use-connections'; import { useTRPC } from '@/providers/query-provider'; import { Skeleton } from '@/components/ui/skeleton'; import { useMutation } from '@tanstack/react-query'; -import { Trash, Plus, Unplug } from 'lucide-react'; import { useThreads } from '@/hooks/use-threads'; import { useBilling } from '@/hooks/use-billing'; import { emailProviders } from '@/lib/constants'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { useState, useEffect } from 'react'; import { m } from '@/paraglide/messages'; import { useQueryState } from 'nuqs'; -import { useState } from 'react'; import { toast } from 'sonner'; export default function ConnectionsPage() { const { data, isLoading, refetch: refetchConnections } = useConnections(); + const { + connections: arcadeConnections, + isLoading: arcadeLoading, + refetch: refetchArcadeConnections, + revokeAuthorization, + } = useArcadeConnections(); + const { + connections: composioConnections, + isLoading: composioLoading, + refetch: refetchComposioConnections, + revokeAuthorization: revokeComposioAuthorization, + } = useComposioConnections(); const { refetch } = useSession(); const [openTooltip, setOpenTooltip] = useState(null); const trpc = useTRPC(); const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions()); + const { mutateAsync: createArcadeConnection } = useMutation( + trpc.arcadeConnections.createConnection.mutationOptions(), + ); const [{ refetch: refetchThreads }] = useThreads(); const { isPro } = useBilling(); const [, setPricingDialog] = useQueryState('pricingDialog'); + const [arcadeAuthSuccess] = useQueryState('arcade_auth_success'); + const [toolkit] = useQueryState('toolkit'); + const [authId] = useQueryState('auth_id'); + const [error] = useQueryState('error'); + + useEffect(() => { + if (arcadeAuthSuccess === 'true' && toolkit && authId) { + createArcadeConnection({ toolkit, authId }) + .then(() => { + toast.success(`Successfully connected ${toolkit}`); + void refetchArcadeConnections(); + window.history.replaceState({}, document.title, window.location.pathname); + }) + .catch((err) => { + console.error('Failed to create Arcade connection:', err); + toast.error(`Failed to connect ${toolkit}`); + }); + } else if (error) { + let errorMessage = 'Authentication failed'; + if (error === 'arcade_auth_failed') { + errorMessage = 'Arcade authorization failed'; + } else if (error === 'arcade_auth_incomplete') { + errorMessage = 'Authorization was not completed'; + } else if (error === 'arcade_verification_failed') { + errorMessage = 'User verification failed'; + } else if (error === 'arcade_auth_error') { + errorMessage = 'An error occurred during authentication'; + } + toast.error(errorMessage); + window.history.replaceState({}, document.title, window.location.pathname); + } + }, [arcadeAuthSuccess, toolkit, authId, error, createArcadeConnection, refetchArcadeConnections]); const disconnectAccount = async (connectionId: string) => { await deleteConnection( { connectionId }, @@ -53,12 +102,11 @@ export default function ConnectionsPage() { void refetchThreads(); }; + console.log('arcadeConnections', arcadeConnections); + return (
- +
{isLoading ? (
@@ -205,9 +253,9 @@ export default function ConnectionsPage() {
+ + +
+ {arcadeLoading || composioLoading ? ( +
+ {[...Array(3)].map((n) => ( +
+
+ +
+ + +
+
+ +
+ ))} +
+ ) : arcadeConnections.length > 0 || composioConnections.length > 0 ? ( +
+ {arcadeConnections.map((connection) => { + const toolkit = connection?.providerId?.split('-')[0] || ''; + const Icon = toolkitIcons[toolkit.toLowerCase()] || Sparkles; + return ( +
+
+
+
+ +
+
+ {toolkit} +
+ + Connected + + + Arcade + +
+
+
+
+ + + + + + + Disconnect {toolkit} + + Are you sure you want to disconnect this integration? + + +
+ + + + + + +
+
+
+
+ ); + })} + + {composioConnections.map((connection) => { + const toolkit = connection?.providerId?.split('-')[0] || ''; + const Icon = toolkitIcons[toolkit.toLowerCase()] || Sparkles; + return ( +
+
+
+
+ +
+
+ {toolkit} +
+ + Connected + + + Composio + +
+
+
+
+ + + + + + + Disconnect {toolkit} + + Are you sure you want to disconnect this integration? + + +
+ + + + + + +
+
+
+
+ ); + })} +
+ ) : ( +
+ +

No integrations connected

+

+ Connect to external services to access powerful AI tools +

+
+ )} + +
+ { + void refetchArcadeConnections(); + void refetchComposioConnections(); + }} + > + + +
+
+
); } diff --git a/apps/mail/app/routes.ts b/apps/mail/app/routes.ts index 57d9fbe23f..2df78c8f0f 100644 --- a/apps/mail/app/routes.ts +++ b/apps/mail/app/routes.ts @@ -47,6 +47,7 @@ export default [ route('/privacy', '(routes)/settings/privacy/page.tsx'), route('/security', '(routes)/settings/security/page.tsx'), route('/shortcuts', '(routes)/settings/shortcuts/page.tsx'), + route('/*', '(routes)/settings/[...settings]/page.tsx'), ]), ), diff --git a/apps/mail/components/connection/add-integration-dialog.tsx b/apps/mail/components/connection/add-integration-dialog.tsx new file mode 100644 index 0000000000..1c095d66c8 --- /dev/null +++ b/apps/mail/components/connection/add-integration-dialog.tsx @@ -0,0 +1,321 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../ui/dialog'; +import { Loader2, CheckCircle2, Sparkles, ArrowLeft, ChevronRight } from 'lucide-react'; +import { useComposioConnections } from '@/hooks/use-composio-connection'; +import { useArcadeConnections } from '@/hooks/use-arcade-connection'; +import { useTRPC } from '@/providers/query-provider'; +import { useMutation } from '@tanstack/react-query'; +import { GitHub, Linear } from '../icons/icons'; +import { Button } from '../ui/button'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +const Stripe = ({ className }: { className?: string }) => ( + + + +); + +export const toolkitIcons: Record> = { + github: GitHub, + stripe: Stripe, + linear: Linear, +}; + +type IntegrationProvider = 'arcade' | 'composio'; +type Step = 'select-provider' | 'select-toolkit' | 'connecting'; + +interface Toolkit { + name: string; + description: string; + toolCount: number; +} + +export const AddIntegrationDialog = ({ + children, + onSuccess, +}: { + children?: React.ReactNode; + onSuccess?: () => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + const [currentStep, setCurrentStep] = useState('select-provider'); + const [selectedProvider, setSelectedProvider] = useState(null); + const [connectingToolkit, setConnectingToolkit] = useState(null); + + const { + toolkits: arcadeToolkits, + connections: arcadeConnections, + isLoading: arcadeLoading, + authorizeToolkit: authorizeArcade, + } = useArcadeConnections(); + + const { + toolkits: composioToolkits, + connections: composioConnections, + isLoading: composioLoading, + authorizeToolkit: authorizeComposio, + } = useComposioConnections(); + + const trpc = useTRPC(); + const { mutateAsync: createArcadeConnection } = useMutation( + trpc.arcadeConnections.createConnection.mutationOptions(), + ); + const { mutateAsync: createComposioConnection } = useMutation( + trpc.composioConnections.createConnection.mutationOptions(), + ); + + const handleProviderSelect = (provider: IntegrationProvider) => { + setSelectedProvider(provider); + setCurrentStep('select-toolkit'); + }; + + const handleBack = () => { + if (currentStep === 'select-toolkit') { + setCurrentStep('select-provider'); + setSelectedProvider(null); + } + }; + + const handleConnect = async (toolkit: string) => { + if (!selectedProvider) return; + + setConnectingToolkit(toolkit); + setCurrentStep('connecting'); + + try { + const isArcade = selectedProvider === 'arcade'; + const authorizeFunc = isArcade ? authorizeArcade : authorizeComposio; + const createFunc = isArcade ? createArcadeConnection : createComposioConnection; + + const authResult = await authorizeFunc(toolkit.toLowerCase()); + + console.log(`[${selectedProvider.toUpperCase()} AUTH RESULT]`, authResult); + + if (authResult?.authUrl && authResult?.authId) { + const authWindow = window.open(authResult.authUrl, '_blank', 'width=600,height=600'); + + const checkInterval = setInterval(async () => { + if (authWindow?.closed) { + clearInterval(checkInterval); + + try { + await createFunc({ + toolkit, + authId: authResult.authId, + }); + + toast.success(`Successfully connected ${toolkit}`); + setConnectingToolkit(null); + setCurrentStep('select-provider'); + setSelectedProvider(null); + setIsOpen(false); + onSuccess?.(); + } catch { + console.log('Authorization not complete or failed'); + setConnectingToolkit(null); + setCurrentStep('select-toolkit'); + } + } + }, 1000); + + setTimeout( + () => { + clearInterval(checkInterval); + setConnectingToolkit(null); + setCurrentStep('select-toolkit'); + }, + 5 * 60 * 1000, + ); + } + } catch (error) { + console.error('Failed to connect toolkit:', error); + toast.error(`Failed to connect ${toolkit}`); + setConnectingToolkit(null); + setCurrentStep('select-toolkit'); + } + }; + + const isConnected = (toolkit: string) => { + if (selectedProvider === 'arcade') { + return arcadeConnections.some((c) => c.providerId?.split('-')[0] === toolkit.toLowerCase()); + } else { + return composioConnections.some((c) => c.providerId?.split('-')[0] === toolkit.toLowerCase()); + } + }; + + const getCurrentToolkits = (): Toolkit[] => { + if (!selectedProvider) return []; + return selectedProvider === 'arcade' ? arcadeToolkits : composioToolkits; + }; + + const isLoading = selectedProvider === 'arcade' ? arcadeLoading : composioLoading; + + const resetDialog = () => { + setCurrentStep('select-provider'); + setSelectedProvider(null); + setConnectingToolkit(null); + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open) { + resetDialog(); + } + }; + + return ( + + {children} + + +
+ {currentStep !== 'select-provider' && currentStep !== 'connecting' && ( + + )} +
+ + {currentStep === 'select-provider' && 'Add Integration'} + {currentStep === 'select-toolkit' && + `${selectedProvider === 'arcade' ? 'Arcade' : 'Composio'} Integrations`} + {currentStep === 'connecting' && 'Connecting...'} + + + {currentStep === 'select-provider' && + 'Choose an integration provider to connect external services'} + {currentStep === 'select-toolkit' && 'Select a service to connect to Zero Mail'} + {currentStep === 'connecting' && `Authorizing ${connectingToolkit} connection`} + +
+
+
+ +
+ {currentStep === 'select-provider' && ( +
+ + + +
+ )} + + {currentStep === 'select-toolkit' && ( + <> + {isLoading ? ( +
+ +
+ ) : getCurrentToolkits().length === 0 ? ( +
+ +

No integrations available

+

+ Please check your {selectedProvider === 'arcade' ? 'Arcade' : 'Composio'} API + key configuration +

+
+ ) : ( +
+ {getCurrentToolkits().map((toolkit) => { + const Icon = toolkitIcons[toolkit.name.toLowerCase()] || Sparkles; + const connected = isConnected(toolkit.name); + + return ( +
+
+
+ +
+
+

{toolkit.name}

+

+ {toolkit.description} +

+

+ {toolkit.toolCount} tools available +

+
+ {connected ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ )} + + )} + + {currentStep === 'connecting' && ( +
+ +

+ Please complete the authorization in the popup window +

+

+ This window will close automatically once authorization is complete +

+
+ )} +
+
+
+ ); +}; diff --git a/apps/mail/components/create/ai-chat.tsx b/apps/mail/components/create/ai-chat.tsx index c1e637e57e..afc5156a9b 100644 --- a/apps/mail/components/create/ai-chat.tsx +++ b/apps/mail/components/create/ai-chat.tsx @@ -1,5 +1,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; import { useAIFullScreen, useAISidebar } from '../ui/ai-sidebar'; +import { ArcadeToolsDisplay } from './arcade-tools-display'; import { VoiceProvider } from '@/providers/voice-provider'; import useComposeEditor from '@/hooks/use-compose-editor'; import { useRef, useCallback, useEffect } from 'react'; @@ -293,7 +294,11 @@ export function AIChat({ const toolParts = message.parts.filter((part) => part.type === 'tool-invocation'); return ( -
+
{toolParts.map( (part, index) => part.toolInvocation?.result && ( @@ -369,6 +374,7 @@ export function AIChat({ {/* Fixed input at bottom */}
+
diff --git a/apps/mail/components/create/arcade-tools-display.tsx b/apps/mail/components/create/arcade-tools-display.tsx new file mode 100644 index 0000000000..e8d4559464 --- /dev/null +++ b/apps/mail/components/create/arcade-tools-display.tsx @@ -0,0 +1,88 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; +import { useArcadeTools } from '@/hooks/use-arcade-tools'; +import { Info, Wrench } from 'lucide-react'; +import { Skeleton } from '../ui/skeleton'; +import { Badge } from '../ui/badge'; + +export function ArcadeToolsDisplay() { + const { isLoading, error, getUniqueToolkits, getToolsByToolkit, hasTools } = useArcadeTools(); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (error) { + return null; + } + + if (!hasTools) { + return null; + } + + const toolkits = getUniqueToolkits(); + + console.log(toolkits); + + return ( +
+
+ + Available Tools: +
+ {toolkits.map((toolkit) => { + const toolkitTools = getToolsByToolkit(toolkit); + return ( + + + + + {toolkit} ({toolkitTools.length}) + + + +
+

{toolkit} Tools

+
    + {toolkitTools.slice(0, 5).map((tool) => ( +
  • + • {tool.toolName} +
  • + ))} + {toolkitTools.length > 5 && ( +
  • + ...and {toolkitTools.length - 5} more +
  • + )} +
+
+
+
+
+ ); + })} +
+ + + + + + +

+ These external tools are now integrated with Zero's AI via MCP. Just ask Zero + to perform actions using your connected services. +

+
+
+
+
+
+ ); +} diff --git a/apps/mail/components/icons/icons.tsx b/apps/mail/components/icons/icons.tsx index 0a13e7c352..628f00d659 100644 --- a/apps/mail/components/icons/icons.tsx +++ b/apps/mail/components/icons/icons.tsx @@ -170,9 +170,28 @@ export const Google = ({ className }: { className?: string }) => ( ); export const GitHub = ({ className }: { className?: string }) => ( - + GitHub - + + +); + +export const Linear = ({ className }: { className?: string }) => ( + + ); @@ -1033,15 +1052,7 @@ export const Figma = ({ className }: { className?: string }) => ( className={className} > - + diff --git a/apps/mail/components/settings/composio-settings.tsx b/apps/mail/components/settings/composio-settings.tsx new file mode 100644 index 0000000000..a0dc27687c --- /dev/null +++ b/apps/mail/components/settings/composio-settings.tsx @@ -0,0 +1,316 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "sonner"; +import { useTRPC } from "@/providers/query-provider"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export function ComposioSettingsPage() { + console.log("🎯 ComposioSettingsPage component is rendering"); + + const [isConnecting, setIsConnecting] = useState(false); + const [isSendingEmail, setIsSendingEmail] = useState(false); + const [isCreatingTrigger, setIsCreatingTrigger] = useState(false); + const [emailData, setEmailData] = useState({ + to: "", + subject: "", + body: "" + }); + const [connectionStatus, setConnectionStatus] = useState<{ + connected: boolean; + accountId?: string; + error?: string; + }>({ connected: false }); + + const trpc = useTRPC(); + + // TRPC mutations + const { mutateAsync: initiateConnection } = useMutation(trpc.composio.initiateConnection.mutationOptions()); + const { mutateAsync: completeConnection } = useMutation(trpc.composio.completeConnection.mutationOptions()); + const { mutateAsync: sendEmail } = useMutation(trpc.composio.sendEmail.mutationOptions()); + const { mutateAsync: createTrigger } = useMutation(trpc.composio.createGmailTrigger.mutationOptions()); + + const handleConnect = async () => { + setIsConnecting(true); + try { + const initiateResult = await initiateConnection({ authConfigId: "ac_rcLucYNgqmzt" }); + if (initiateResult.redirectUrl) { + window.open(initiateResult.redirectUrl, '_blank', 'width=600,height=700'); + } + toast.success("Connection initiated. Please complete the authorization in the new window."); + + const completeResult = await completeConnection({ authConfigId: "ac_rcLucYNgqmzt" }); + toast.success("Connection successful! Successfully connected to Gmail"); + setConnectionStatus({ + connected: true, + accountId: completeResult.connectedAccount?.id + }); + } catch (error: any) { + console.error('Connection error:', error); + toast.error(error?.message || "Connection failed"); + setConnectionStatus({ + connected: false, + error: error?.message + }); + } finally { + setIsConnecting(false); + } + }; + + const handleSendEmail = async () => { + if (!emailData.to || !emailData.subject || !emailData.body) { + toast.error("Missing email data. Please fill in all email fields"); + return; + } + + setIsSendingEmail(true); + try { + await sendEmail({ + to: emailData.to, + subject: emailData.subject, + body: emailData.body + }); + toast.success("Email sent successfully! Email was sent using Composio and AI"); + } catch (error: any) { + console.error('Email sending error:', error); + toast.error(error?.message || "Email sending failed"); + } finally { + setIsSendingEmail(false); + } + }; + + const handleCreateTrigger = async () => { + if (!connectionStatus.accountId) { + toast.error("No connection. Please connect to Gmail first"); + return; + } + + setIsCreatingTrigger(true); + try { + const result = await createTrigger({ + connectedAccountId: connectionStatus.accountId + }); + toast.success(`Trigger created successfully! Gmail trigger created with ID: ${result.trigger?.triggerId}`); + } catch (error: any) { + console.error('Trigger creation error:', error); + toast.error(error?.message || "Trigger creation failed"); + } finally { + setIsCreatingTrigger(false); + } + }; + + return ( +
+
+

Composio AI Integration

+ + + Testing + +
+ + + + This page allows you to test Composio AI integration features including Gmail connections, + AI-powered email sending, and trigger management. + + + + {/* Connection Status */} + + + + + Connection Status + + + Connect your Gmail account to enable AI-powered email features + + + +
+ {connectionStatus.connected ? ( + <> + + Connected to Gmail + {connectionStatus.accountId} + + ) : ( + <> + + Not connected + + )} +
+ + {connectionStatus.error && ( +
+ Error: {connectionStatus.error} +
+ )} + + +
+
+ + + + {/* Email Testing */} + + + + + AI Email Testing + + + Test sending emails using AI and Composio integration + + + +
+
+ + setEmailData(prev => ({ ...prev, to: e.target.value }))} + /> +
+ +
+ + setEmailData(prev => ({ ...prev, subject: e.target.value }))} + /> +
+ +
+ +