diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 254a4331..e28f662e 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useState, useEffect, useCallback } from "react" +import { useState } from "react" import { AlertTriangle, Loader2, Mic, Mail, Calendar, MessageSquare, User } from "lucide-react" import { @@ -18,604 +18,40 @@ import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" import { GoogleClientIdModal } from "@/components/google-client-id-modal" -import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" -import { toast } from "sonner" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" - -interface ProviderState { - isConnected: boolean - isLoading: boolean - isConnecting: boolean -} - -interface ProviderStatus { - error?: string -} +import { useConnectors } from "@/hooks/useConnectors" interface ConnectorsPopoverProps { children: React.ReactNode tooltip?: string open?: boolean onOpenChange?: (open: boolean) => void + mode?: "all" | "unconnected" } -export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange }: ConnectorsPopoverProps) { +export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenChange, mode = "all" }: ConnectorsPopoverProps) { const [openInternal, setOpenInternal] = useState(false) const isControlled = typeof openProp === "boolean" const open = isControlled ? openProp : openInternal const setOpen = onOpenChange ?? setOpenInternal - const [providers, setProviders] = useState([]) - const [providersLoading, setProvidersLoading] = useState(true) - const [providerStates, setProviderStates] = useState>({}) - const [providerStatus, setProviderStatus] = useState>({}) - const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) - const [googleClientIdDescription, setGoogleClientIdDescription] = useState(undefined) - // Granola state - const [granolaEnabled, setGranolaEnabled] = useState(false) - const [granolaLoading, setGranolaLoading] = useState(true) + const c = useConnectors(open) - // Composio API key state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') - - // Slack state (agent-slack CLI) - const [slackEnabled, setSlackEnabled] = useState(false) - const [slackLoading, setSlackLoading] = useState(true) - const [slackWorkspaces, setSlackWorkspaces] = useState>([]) - const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) - const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) - const [slackPickerOpen, setSlackPickerOpen] = useState(false) - const [slackDiscovering, setSlackDiscovering] = useState(false) - const [slackDiscoverError, setSlackDiscoverError] = useState(null) - - // Composio/Gmail state - const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) - const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) - const [gmailConnecting, setGmailConnecting] = useState(false) - - // Composio/Google Calendar state - const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) - const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) - const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) - const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) - - // Load available providers on mount - useEffect(() => { - async function loadProviders() { - try { - setProvidersLoading(true) - const result = await window.ipc.invoke('oauth:list-providers', null) - setProviders(result.providers || []) - } catch (error) { - console.error('Failed to get available providers:', error) - setProviders([]) - } finally { - setProvidersLoading(false) - } - } - loadProviders() - }, []) - - // Re-check composio-for-google flag every time the popover opens - useEffect(() => { - if (!open) return - async function loadComposioForGoogleFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google', null) - setUseComposioForGoogle(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google flag:', error) - } - } - async function loadComposioForGoogleCalendarFlag() { - try { - const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) - setUseComposioForGoogleCalendar(result.enabled) - } catch (error) { - console.error('Failed to check composio-for-google-calendar flag:', error) - } - } - loadComposioForGoogleFlag() - loadComposioForGoogleCalendarFlag() - }, [open]) - - // Load Granola config - const refreshGranolaConfig = useCallback(async () => { - try { - setGranolaLoading(true) - const result = await window.ipc.invoke('granola:getConfig', null) - setGranolaEnabled(result.enabled) - } catch (error) { - console.error('Failed to load Granola config:', error) - setGranolaEnabled(false) - } finally { - setGranolaLoading(false) - } - }, []) - - // Update Granola config - const handleGranolaToggle = useCallback(async (enabled: boolean) => { - try { - setGranolaLoading(true) - await window.ipc.invoke('granola:setConfig', { enabled }) - setGranolaEnabled(enabled) - toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') - } catch (error) { - console.error('Failed to update Granola config:', error) - toast.error('Failed to update Granola sync settings') - } finally { - setGranolaLoading(false) - } - }, []) - - // Load Slack config - const refreshSlackConfig = useCallback(async () => { - try { - setSlackLoading(true) - const result = await window.ipc.invoke('slack:getConfig', null) - setSlackEnabled(result.enabled) - setSlackWorkspaces(result.workspaces || []) - } catch (error) { - console.error('Failed to load Slack config:', error) - setSlackEnabled(false) - setSlackWorkspaces([]) - } finally { - setSlackLoading(false) - } - }, []) - - // Enable Slack: discover workspaces - const handleSlackEnable = useCallback(async () => { - setSlackDiscovering(true) - setSlackDiscoverError(null) - try { - const result = await window.ipc.invoke('slack:listWorkspaces', null) - if (result.error || result.workspaces.length === 0) { - setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') - setSlackAvailableWorkspaces([]) - setSlackPickerOpen(true) - } else { - setSlackAvailableWorkspaces(result.workspaces) - setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) - setSlackPickerOpen(true) - } - } catch (error) { - console.error('Failed to discover Slack workspaces:', error) - setSlackDiscoverError('Failed to discover Slack workspaces') - setSlackPickerOpen(true) - } finally { - setSlackDiscovering(false) - } - }, []) - - // Load Gmail connection status - const refreshGmailStatus = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) - setGmailConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Gmail status:', error) - setGmailConnected(false) - } finally { - setGmailLoading(false) - } - }, []) - - // Load Google Calendar connection status - const refreshGoogleCalendarStatus = useCallback(async () => { - try { - setGoogleCalendarLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) - setGoogleCalendarConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Google Calendar status:', error) - setGoogleCalendarConnected(false) - } finally { - setGoogleCalendarLoading(false) - } - }, []) - - // Connect to Gmail via Composio - const startGmailConnect = useCallback(async () => { - try { - setGmailConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Gmail') - setGmailConnecting(false) - } - // Success will be handled by composio:didConnect event - } catch (error) { - console.error('Failed to connect to Gmail:', error) - toast.error('Failed to connect to Gmail') - setGmailConnecting(false) - } - }, []) - - // Handle Gmail connect button click - const handleConnectGmail = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGmailConnect() - }, [startGmailConnect]) - - // Connect to Google Calendar via Composio - const startGoogleCalendarConnect = useCallback(async () => { - try { - setGoogleCalendarConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Google Calendar') - setGoogleCalendarConnecting(false) - } - } catch (error) { - console.error('Failed to connect to Google Calendar:', error) - toast.error('Failed to connect to Google Calendar') - setGoogleCalendarConnecting(false) - } - }, []) - - // Handle Google Calendar connect button click - const handleConnectGoogleCalendar = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGoogleCalendarConnect() - }, [startGoogleCalendarConnect]) - - // Disconnect from Google Calendar - const handleDisconnectGoogleCalendar = useCallback(async () => { - try { - setGoogleCalendarLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' }) - if (result.success) { - setGoogleCalendarConnected(false) - toast.success('Disconnected from Google Calendar') - } else { - toast.error('Failed to disconnect from Google Calendar') - } - } catch (error) { - console.error('Failed to disconnect from Google Calendar:', error) - toast.error('Failed to disconnect from Google Calendar') - } finally { - setGoogleCalendarLoading(false) - } - }, []) - - // Disconnect from Gmail - const handleDisconnectGmail = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) - if (result.success) { - setGmailConnected(false) - toast.success('Disconnected from Gmail') - } else { - toast.error('Failed to disconnect from Gmail') - } - } catch (error) { - console.error('Failed to disconnect from Gmail:', error) - toast.error('Failed to disconnect from Gmail') - } finally { - setGmailLoading(false) - } - }, []) - - // Handle Composio API key submission - const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { - try { - await window.ipc.invoke('composio:set-api-key', { apiKey }) - setComposioApiKeyOpen(false) - toast.success('Composio API key saved') - await startGmailConnect() - } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') - } - }, [startGmailConnect]) - - // Save selected Slack workspaces - const handleSlackSaveWorkspaces = useCallback(async () => { - const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) - try { - setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) - setSlackEnabled(true) - setSlackWorkspaces(selected) - setSlackPickerOpen(false) - toast.success('Slack enabled') - } catch (error) { - console.error('Failed to save Slack config:', error) - toast.error('Failed to save Slack settings') - } finally { - setSlackLoading(false) - } - }, [slackAvailableWorkspaces, slackSelectedUrls]) - - // Disable Slack - const handleSlackDisable = useCallback(async () => { - try { - setSlackLoading(true) - await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) - setSlackEnabled(false) - setSlackWorkspaces([]) - setSlackPickerOpen(false) - toast.success('Slack disabled') - } catch (error) { - console.error('Failed to update Slack config:', error) - toast.error('Failed to update Slack settings') - } finally { - setSlackLoading(false) - } - }, []) - - // Check connection status for all providers - const refreshAllStatuses = useCallback(async () => { - // Refresh Granola - refreshGranolaConfig() - - // Refresh Slack config - refreshSlackConfig() - - // Refresh Gmail Composio status if enabled - if (useComposioForGoogle) { - refreshGmailStatus() - } - - // Refresh Google Calendar Composio status if enabled - if (useComposioForGoogleCalendar) { - refreshGoogleCalendarStatus() - } - - // Refresh OAuth providers - if (providers.length === 0) return - - const newStates: Record = {} - - try { - const result = await window.ipc.invoke('oauth:getState', null) - const config = result.config || {} - const statusMap: Record = {} - - for (const provider of providers) { - const providerConfig = config[provider] - newStates[provider] = { - isConnected: providerConfig?.connected ?? false, - isLoading: false, - isConnecting: false, - } - if (providerConfig?.error) { - statusMap[provider] = { error: providerConfig.error } - } - } - - setProviderStatus(statusMap) - } catch (error) { - console.error('Failed to check connection statuses:', error) - for (const provider of providers) { - newStates[provider] = { - isConnected: false, - isLoading: false, - isConnecting: false, - } - } - setProviderStatus({}) - } - - setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) - - // Refresh statuses when popover opens or providers list changes - useEffect(() => { - if (open) { - refreshAllStatuses() - } - }, [open, providers, refreshAllStatuses]) - - // Listen for OAuth state change events (connect + disconnect) - useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', async (event) => { - const { provider, success } = event - - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: success, - isLoading: false, - isConnecting: false, - } - })) - - if (success) { - const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - // Show detailed message for Google and Fireflies (includes sync info) - if (provider === 'google' || provider === 'fireflies-ai') { - toast.success(`Connected to ${displayName}`, { - description: 'Syncing your data in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.success(`Connected to ${displayName}`) - } - - // When Rowboat account connects, re-check composio flags so Gmail/Calendar use the right flow - if (provider === 'rowboat') { - try { - const [googleResult, calendarResult] = await Promise.all([ - window.ipc.invoke('composio:use-composio-for-google', null), - window.ipc.invoke('composio:use-composio-for-google-calendar', null), - ]) - setUseComposioForGoogle(googleResult.enabled) - setUseComposioForGoogleCalendar(calendarResult.enabled) - } catch (err) { - console.error('Failed to re-check composio flags:', err) - } - } - - // Refresh status to ensure consistency - refreshAllStatuses() - } - // Note: error toasts for failed connections are handled by startConnect/handleConnect. - // Disconnect events (success: false) are handled by handleDisconnect which shows its own toast. - }) - - return cleanup - }, [refreshAllStatuses]) - - // Listen for Composio connection events (Gmail, Google Calendar) - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event - - if (toolkitSlug === 'gmail') { - setGmailConnected(success) - setGmailConnecting(false) - - if (success) { - toast.success('Connected to Gmail', { - description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Gmail') - } - } - - if (toolkitSlug === 'googlecalendar') { - setGoogleCalendarConnected(success) - setGoogleCalendarConnecting(false) - - if (success) { - toast.success('Connected to Google Calendar', { - description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Google Calendar') - } - } - }) - - return cleanup - }, []) - - - const startConnect = useCallback(async (provider: string, clientId?: string) => { - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: true } - })) - - try { - const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) - - if (result.success) { - // OAuth flow started - keep isConnecting state, wait for event - // Event listener will handle the actual completion - } else { - // Immediate failure (e.g., couldn't start flow) - toast.error(result.error || `Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - } catch (error) { - console.error('Failed to connect:', error) - toast.error(`Failed to connect to ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isConnecting: false } - })) - } - }, []) - - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { - if (provider === 'google') { - setGoogleClientIdDescription(undefined) - const existingClientId = getGoogleClientId() - if (!existingClientId) { - setGoogleClientIdOpen(true) - return - } - await startConnect(provider, existingClientId) - return - } - - await startConnect(provider) - }, [startConnect]) - - const handleGoogleClientIdSubmit = useCallback((clientId: string) => { - setGoogleClientId(clientId) - setGoogleClientIdOpen(false) - setGoogleClientIdDescription(undefined) - startConnect('google', clientId) - }, [startConnect]) - - // Disconnect from a provider - const handleDisconnect = useCallback(async (provider: string) => { - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: true } - })) - - try { - const result = await window.ipc.invoke('oauth:disconnect', { provider }) - - if (result.success) { - if (provider === 'google') { - clearGoogleClientId() - } - const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - toast.success(`Disconnected from ${displayName}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: false, - isLoading: false, - isConnecting: false, - } - })) - } else { - toast.error(`Failed to disconnect from ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: false } - })) - } - } catch (error) { - console.error('Failed to disconnect:', error) - toast.error(`Failed to disconnect from ${provider}`) - setProviderStates(prev => ({ - ...prev, - [provider]: { ...prev[provider], isLoading: false } - })) - } - }, []) - - const hasProviderError = Object.values(providerStatus).some( - (status) => Boolean(status?.error) - ) + const isUnconnectedMode = mode === "unconnected" // Helper to render an OAuth provider row const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { - const state = providerStates[provider] || { + const state = c.providerStates[provider] || { isConnected: false, isLoading: true, isConnecting: false, } - const needsReconnect = Boolean(providerStatus[provider]?.error) + const needsReconnect = Boolean(c.providerStatus[provider]?.error) + + // In unconnected mode, skip connected providers (unless they need reconnect) + if (isUnconnectedMode && state.isConnected && !needsReconnect && !state.isLoading) { + return null + } return (
{ if (provider === 'google') { - setGoogleClientIdDescription( + c.setGoogleClientIdDescription( "To keep your Google account connected, please re-enter your client ID. You only need to do this once." ) - setGoogleClientIdOpen(true) + c.setGoogleClientIdOpen(true) return } - startConnect(provider) + c.startConnect(provider) }} className="h-7 px-2 text-xs" > @@ -662,23 +98,23 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) : ( )} @@ -687,19 +123,57 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha ) } + // Check if Gmail is unconnected (for filtering in unconnected mode) + const isGmailUnconnected = c.useComposioForGoogle ? !c.gmailConnected && !c.gmailLoading : true + const isGoogleCalendarUnconnected = c.useComposioForGoogleCalendar ? !c.googleCalendarConnected && !c.googleCalendarLoading : true + const isGranolaUnconnected = !c.granolaEnabled && !c.granolaLoading + const isSlackUnconnected = !c.slackEnabled && !c.slackLoading + + // For unconnected mode, check if there's anything to show + const hasUnconnectedEmailCalendar = (() => { + if (!isUnconnectedMode) return true + if (c.useComposioForGoogle && isGmailUnconnected) return true + if (c.useComposioForGoogleCalendar && isGoogleCalendarUnconnected) return true + if (!c.useComposioForGoogle && c.providers.includes('google')) { + const googleState = c.providerStates['google'] + if (!googleState?.isConnected || c.providerStatus['google']?.error) return true + } + return false + })() + + const hasUnconnectedMeetingNotes = (() => { + if (!isUnconnectedMode) return true + if (isGranolaUnconnected) return true + if (c.providers.includes('fireflies-ai')) { + const firefliesState = c.providerStates['fireflies-ai'] + if (!firefliesState?.isConnected || c.providerStatus['fireflies-ai']?.error) return true + } + return false + })() + + const hasUnconnectedSlack = !isUnconnectedMode || isSlackUnconnected + + const isRowboatUnconnected = (() => { + if (!c.providers.includes('rowboat')) return false + const rowboatState = c.providerStates['rowboat'] + return !rowboatState?.isConnected || rowboatState?.isLoading + })() + + const allConnected = isUnconnectedMode && !isRowboatUnconnected && !hasUnconnectedEmailCalendar && !hasUnconnectedMeetingNotes && !hasUnconnectedSlack + return ( <> { - setGoogleClientIdOpen(nextOpen) + c.setGoogleClientIdOpen(nextOpen) if (!nextOpen) { - setGoogleClientIdDescription(undefined) + c.setGoogleClientIdDescription(undefined) } }} - onSubmit={handleGoogleClientIdSubmit} - isSubmitting={providerStates.google?.isConnecting ?? false} - description={googleClientIdDescription} + onSubmit={c.handleGoogleClientIdSubmit} + isSubmitting={c.providerStates.google?.isConnecting ?? false} + description={c.googleClientIdDescription} /> {tooltip ? ( @@ -726,91 +200,106 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha >

- Connected accounts - {hasProviderError && ( + {isUnconnectedMode ? "Connect Accounts" : "Connected accounts"} + {!isUnconnectedMode && c.hasProviderError && ( )}

- Connect accounts to sync data + {isUnconnectedMode ? "Add new account connections" : "Connect accounts to sync data"}

- {providersLoading ? ( + {c.providersLoading ? (
+ ) : allConnected ? ( +
+

All accounts connected

+

+ Manage your connections in Settings +

+
) : ( <> - {/* Rowboat Account */} - {providers.includes('rowboat') && ( - <> -
- Account -
- {renderOAuthProvider('rowboat', 'Rowboat', , 'Connect your Rowboat account')} - - - )} + {/* Rowboat Account - show in "all" mode always, or in "unconnected" mode only when not connected */} + {c.providers.includes('rowboat') && (() => { + const rowboatState = c.providerStates['rowboat'] + const isRowboatConnected = rowboatState?.isConnected && !rowboatState?.isLoading + if (isUnconnectedMode && isRowboatConnected) return null + return ( + <> +
+ Account +
+ {renderOAuthProvider('rowboat', 'Rowboat', , 'Log in to your Rowboat account')} + + + ) + })()} {/* Email & Calendar Section */} - {(useComposioForGoogle || useComposioForGoogleCalendar || providers.includes('google')) && ( + {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && hasUnconnectedEmailCalendar && ( <>
Email & Calendar
- {useComposioForGoogle ? ( -
-
-
- + {c.useComposioForGoogle ? ( + // In unconnected mode, only show if not connected + (!isUnconnectedMode || isGmailUnconnected) ? ( +
+
+
+ +
+
+ Gmail + {c.gmailLoading ? ( + Checking... + ) : ( + + Sync emails + + )} +
-
- Gmail - {gmailLoading ? ( - Checking... +
+ {c.gmailLoading ? ( + + ) : c.gmailConnected ? ( + ) : ( - - Sync emails - + )}
-
- {gmailLoading ? ( - - ) : gmailConnected ? ( - - ) : ( - - )} -
-
+ ) : null ) : ( renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') )} - {useComposioForGoogleCalendar && ( + {c.useComposioForGoogleCalendar && (!isUnconnectedMode || isGoogleCalendarUnconnected) && (
@@ -818,7 +307,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
Google Calendar - {googleCalendarLoading ? ( + {c.googleCalendarLoading ? ( Checking... ) : ( @@ -828,13 +317,13 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
- {googleCalendarLoading ? ( + {c.googleCalendarLoading ? ( - ) : googleCalendarConnected ? ( + ) : c.googleCalendarConnected ? ( + )} +
-
- Slack - {slackEnabled && slackWorkspaces.length > 0 ? ( - - {slackWorkspaces.map(w => w.name).join(', ')} - - ) : ( - - Send messages and view channels - - )} -
-
-
- {(slackLoading || slackDiscovering) && ( - - )} - {slackEnabled ? ( - handleSlackDisable()} - disabled={slackLoading} - /> - ) : ( - + {c.slackPickerOpen && ( +
+ {c.slackDiscoverError ? ( +

{c.slackDiscoverError}

+ ) : ( + <> + {c.slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
)}
-
- {slackPickerOpen && ( -
- {slackDiscoverError ? ( -

{slackDiscoverError}

- ) : ( - <> - {slackAvailableWorkspaces.map(w => ( - - ))} - - - )} -
- )} -
+ + )} )}
) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 65c5b424..8c513232 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react" import { Dialog, @@ -22,8 +22,10 @@ import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { useTheme } from "@/contexts/theme-context" import { toast } from "sonner" +import { AccountSettings } from "@/components/settings/account-settings" +import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" -type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging" +type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" interface TabConfig { id: ConfigTab @@ -34,6 +36,18 @@ interface TabConfig { } const tabs: TabConfig[] = [ + { + id: "account", + label: "Account", + icon: User, + description: "Manage your Rowboat account", + }, + { + id: "connected-accounts", + label: "Connected Accounts", + icon: Plug, + description: "Manage connected services", + }, { id: "models", label: "Models", @@ -1259,7 +1273,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance" || tab === "models" || tab === "note-tagging") return + if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -1367,8 +1381,12 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Content */} -
- {activeTab === "models" ? ( +
+ {activeTab === "account" ? ( + + ) : activeTab === "connected-accounts" ? ( + + ) : activeTab === "models" ? ( rowboatConnected ? : diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx new file mode 100644 index 00000000..04a1e805 --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -0,0 +1,211 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Loader2, User, CreditCard, LogOut } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Separator } from "@/components/ui/separator" +import { useBilling } from "@/hooks/useBilling" +import { toast } from "sonner" + +interface AccountSettingsProps { + dialogOpen: boolean +} + +export function AccountSettings({ dialogOpen }: AccountSettingsProps) { + const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [connectionLoading, setConnectionLoading] = useState(true) + const [disconnecting, setDisconnecting] = useState(false) + const [connecting, setConnecting] = useState(false) + const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected) + + const checkConnection = useCallback(async () => { + try { + setConnectionLoading(true) + const result = await window.ipc.invoke('oauth:getState', null) + const connected = result.config?.rowboat?.connected ?? false + setIsRowboatConnected(connected) + } catch { + setIsRowboatConnected(false) + } finally { + setConnectionLoading(false) + } + }, []) + + useEffect(() => { + if (dialogOpen) { + checkConnection() + } + }, [dialogOpen, checkConnection]) + + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider === 'rowboat') { + setIsRowboatConnected(event.success) + setConnecting(false) + if (event.success) { + toast.success('Logged in to Rowboat') + } + } + }) + return cleanup + }, []) + + const handleConnect = useCallback(async () => { + try { + setConnecting(true) + const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' }) + if (!result.success) { + toast.error(result.error || 'Failed to log in to Rowboat') + setConnecting(false) + } + } catch { + toast.error('Failed to log in to Rowboat') + setConnecting(false) + } + }, []) + + const handleDisconnect = useCallback(async () => { + try { + setDisconnecting(true) + const result = await window.ipc.invoke('oauth:disconnect', { provider: 'rowboat' }) + if (result.success) { + setIsRowboatConnected(false) + toast.success('Logged out of Rowboat') + } else { + toast.error('Failed to log out of Rowboat') + } + } catch { + toast.error('Failed to log out of Rowboat') + } finally { + setDisconnecting(false) + } + }, []) + + if (connectionLoading) { + return ( +
+ +
+ ) + } + + if (!isRowboatConnected) { + return ( +
+
+ +
+
+

Not logged in

+

Log in to your Rowboat account to access premium features

+
+ +
+ ) + } + + return ( +
+ {/* Profile Section */} +
+
+
+ +
+
+

+ {billing?.userEmail ?? 'Loading...'} +

+

Rowboat Account

+
+
+
+ + + + {/* Plan Section */} +
+
+ +

Plan

+
+ + {billingLoading ? ( +
+ + Loading plan details... +
+ ) : billing ? ( +
+
+
+

{billing.subscriptionPlan ?? 'Free'} Plan

+ {billing.subscriptionStatus && ( +

{billing.subscriptionStatus}

+ )} +
+ +
+
+ ) : ( +

Unable to load plan details

+ )} +
+ + + + {/* Log Out Section */} +
+
+ +

Log Out

+
+

+ Logging out will remove access to synced data and Rowboat-provided models. +

+ + + + + + + Log out of your Rowboat account? + + This will remove access to synced data and Rowboat-provided models. You can log back in at any time. + + + + Cancel + + {disconnecting ? : null} + Log Out + + + + +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx new file mode 100644 index 00000000..1cb12f6e --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader2, Mic, Mail, Calendar, MessageSquare } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { Separator } from "@/components/ui/separator" +import { GoogleClientIdModal } from "@/components/google-client-id-modal" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" +import { useConnectors } from "@/hooks/useConnectors" + +interface ConnectedAccountsSettingsProps { + dialogOpen: boolean +} + +export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSettingsProps) { + const c = useConnectors(dialogOpen) + + const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => { + const state = c.providerStates[provider] || { + isConnected: false, + isLoading: true, + isConnecting: false, + } + const needsReconnect = Boolean(c.providerStatus[provider]?.error) + + return ( +
+
+
+ {icon} +
+
+ {displayName} + {state.isLoading ? ( + Checking... + ) : needsReconnect ? ( + Needs reconnect + ) : state.isConnected ? ( + Connected + ) : ( + {description} + )} +
+
+
+ {state.isLoading ? ( + + ) : needsReconnect ? ( + + ) : state.isConnected ? ( + + ) : ( + + )} +
+
+ ) + } + + if (c.providersLoading) { + return ( +
+ +
+ ) + } + + return ( + <> + { + c.setGoogleClientIdOpen(nextOpen) + if (!nextOpen) { + c.setGoogleClientIdDescription(undefined) + } + }} + onSubmit={c.handleGoogleClientIdSubmit} + isSubmitting={c.providerStates.google?.isConnecting ?? false} + description={c.googleClientIdDescription} + /> + + +
+ {/* Email & Calendar Section */} + {(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && ( + <> +
+ + Email & Calendar + +
+ {c.useComposioForGoogle ? ( +
+
+
+ +
+
+ Gmail + {c.gmailLoading ? ( + Checking... + ) : c.gmailConnected ? ( + Connected + ) : ( + Sync emails + )} +
+
+
+ {c.gmailLoading ? ( + + ) : c.gmailConnected ? ( + + ) : ( + + )} +
+
+ ) : ( + c.providers.includes('google') && renderOAuthProvider('google', 'Google', , 'Sync emails and calendar') + )} + {c.useComposioForGoogleCalendar && ( +
+
+
+ +
+
+ Google Calendar + {c.googleCalendarLoading ? ( + Checking... + ) : c.googleCalendarConnected ? ( + Connected + ) : ( + Sync calendar events + )} +
+
+
+ {c.googleCalendarLoading ? ( + + ) : c.googleCalendarConnected ? ( + + ) : ( + + )} +
+
+ )} + + + )} + + {/* Meeting Notes Section */} +
+ + Meeting Notes + +
+ + {/* Granola */} +
+
+
+ +
+
+ Granola + + Local meeting notes + +
+
+
+ {c.granolaLoading && ( + + )} + +
+
+ + {/* Fireflies */} + {c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + {/* Team Communication Section */} +
+ + Team Communication + +
+ + {/* Slack */} +
+
+
+
+ +
+
+ Slack + {c.slackEnabled && c.slackWorkspaces.length > 0 ? ( + + {c.slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
+
+
+ {(c.slackLoading || c.slackDiscovering) && ( + + )} + {c.slackEnabled ? ( + c.handleSlackDisable()} + disabled={c.slackLoading} + /> + ) : ( + + )} +
+
+ {c.slackPickerOpen && ( +
+ {c.slackDiscoverError ? ( +

{c.slackDiscoverError}

+ ) : ( + <> + {c.slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )} +
+
+ + ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 7f7b52aa..ee280031 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { Bot, ChevronRight, @@ -403,8 +403,21 @@ export function SidebarContentPanel({ const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false) const connectorsButtonRef = useRef(null) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + const [loggingIn, setLoggingIn] = useState(false) const { billing } = useBilling(isRowboatConnected) + const handleRowboatLogin = useCallback(async () => { + try { + setLoggingIn(true) + const result = await window.ipc.invoke('oauth:connect', { provider: 'rowboat' }) + if (!result.success) { + setLoggingIn(false) + } + } catch { + setLoggingIn(false) + } + }, []) + useEffect(() => { let mounted = true @@ -433,6 +446,7 @@ export function SidebarContentPanel({ refreshOauthError() const cleanup = window.ipc.on('oauth:didConnect', () => { refreshOauthError() + setLoggingIn(false) }) return () => { @@ -488,8 +502,8 @@ export function SidebarContentPanel({ /> )} - {/* Billing / upgrade CTA */} - {isRowboatConnected && billing && ( + {/* Billing / upgrade CTA or Log in CTA */} + {isRowboatConnected && billing ? (
@@ -500,18 +514,30 @@ export function SidebarContentPanel({
+ ) : null} + {/* Sign in CTA */} + {!isRowboatConnected && ( +
+ +
)} {/* Bottom actions */}
- + {hasOauthError && ( diff --git a/apps/x/apps/renderer/src/hooks/useBilling.ts b/apps/x/apps/renderer/src/hooks/useBilling.ts index 49459fdb..d2be5807 100644 --- a/apps/x/apps/renderer/src/hooks/useBilling.ts +++ b/apps/x/apps/renderer/src/hooks/useBilling.ts @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback } from 'react' interface BillingInfo { + userEmail: string | null + userId: string | null subscriptionPlan: string subscriptionStatus: string sanctionedCredits: number diff --git a/apps/x/apps/renderer/src/hooks/useConnectors.ts b/apps/x/apps/renderer/src/hooks/useConnectors.ts new file mode 100644 index 00000000..cad319d7 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useConnectors.ts @@ -0,0 +1,618 @@ +import { useState, useEffect, useCallback } from "react" +import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" +import { toast } from "sonner" + +export interface ProviderState { + isConnected: boolean + isLoading: boolean + isConnecting: boolean +} + +export interface ProviderStatus { + error?: string +} + +export function useConnectors(active: boolean) { + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + const [providerStates, setProviderStates] = useState>({}) + const [providerStatus, setProviderStatus] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) + const [googleClientIdDescription, setGoogleClientIdDescription] = useState(undefined) + + // Granola state + const [granolaEnabled, setGranolaEnabled] = useState(false) + const [granolaLoading, setGranolaLoading] = useState(true) + + // Composio API key state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'slack' | 'gmail'>('gmail') + + // Slack state + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) + + // Composio/Gmail state + const [useComposioForGoogle, setUseComposioForGoogle] = useState(false) + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) + + // Composio/Google Calendar state + const [useComposioForGoogleCalendar, setUseComposioForGoogleCalendar] = useState(false) + const [googleCalendarConnected, setGoogleCalendarConnected] = useState(false) + const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true) + const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false) + + // Load available providers on mount + useEffect(() => { + async function loadProviders() { + try { + setProvidersLoading(true) + const result = await window.ipc.invoke('oauth:list-providers', null) + setProviders(result.providers || []) + } catch (error) { + console.error('Failed to get available providers:', error) + setProviders([]) + } finally { + setProvidersLoading(false) + } + } + loadProviders() + }, []) + + // Re-check composio-for-google flags when active + useEffect(() => { + if (!active) return + async function loadComposioForGoogleFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google', null) + setUseComposioForGoogle(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google flag:', error) + } + } + async function loadComposioForGoogleCalendarFlag() { + try { + const result = await window.ipc.invoke('composio:use-composio-for-google-calendar', null) + setUseComposioForGoogleCalendar(result.enabled) + } catch (error) { + console.error('Failed to check composio-for-google-calendar flag:', error) + } + } + loadComposioForGoogleFlag() + loadComposioForGoogleCalendarFlag() + }, [active]) + + // Load Granola config + const refreshGranolaConfig = useCallback(async () => { + try { + setGranolaLoading(true) + const result = await window.ipc.invoke('granola:getConfig', null) + setGranolaEnabled(result.enabled) + } catch (error) { + console.error('Failed to load Granola config:', error) + setGranolaEnabled(false) + } finally { + setGranolaLoading(false) + } + }, []) + + const handleGranolaToggle = useCallback(async (enabled: boolean) => { + try { + setGranolaLoading(true) + await window.ipc.invoke('granola:setConfig', { enabled }) + setGranolaEnabled(enabled) + toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled') + } catch (error) { + console.error('Failed to update Granola config:', error) + toast.error('Failed to update Granola sync settings') + } finally { + setGranolaLoading(false) + } + }, []) + + // Slack + const refreshSlackConfig = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) + } catch (error) { + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) + } finally { + setSlackLoading(false) + } + }, []) + + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) + try { + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) + } + } catch (error) { + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) + } + }, []) + + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') + } catch (error) { + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) + + // Gmail (Composio) + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + const handleDisconnectGmail = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) + if (result.success) { + setGmailConnected(false) + toast.success('Disconnected from Gmail') + } else { + toast.error('Failed to disconnect from Gmail') + } + } catch (error) { + console.error('Failed to disconnect from Gmail:', error) + toast.error('Failed to disconnect from Gmail') + } finally { + setGmailLoading(false) + } + }, []) + + // Google Calendar (Composio) + const refreshGoogleCalendarStatus = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' }) + setGoogleCalendarConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Google Calendar status:', error) + setGoogleCalendarConnected(false) + } finally { + setGoogleCalendarLoading(false) + } + }, []) + + const startGoogleCalendarConnect = useCallback(async () => { + try { + setGoogleCalendarConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'googlecalendar' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Google Calendar:', error) + toast.error('Failed to connect to Google Calendar') + setGoogleCalendarConnecting(false) + } + }, []) + + const handleConnectGoogleCalendar = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGoogleCalendarConnect() + }, [startGoogleCalendarConnect]) + + const handleDisconnectGoogleCalendar = useCallback(async () => { + try { + setGoogleCalendarLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'googlecalendar' }) + if (result.success) { + setGoogleCalendarConnected(false) + toast.success('Disconnected from Google Calendar') + } else { + toast.error('Failed to disconnect from Google Calendar') + } + } catch (error) { + console.error('Failed to disconnect from Google Calendar:', error) + toast.error('Failed to disconnect from Google Calendar') + } finally { + setGoogleCalendarLoading(false) + } + }, []) + + // Composio API key + const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + try { + await window.ipc.invoke('composio:set-api-key', { apiKey }) + setComposioApiKeyOpen(false) + toast.success('Composio API key saved') + await startGmailConnect() + } catch (error) { + console.error('Failed to save Composio API key:', error) + toast.error('Failed to save API key') + } + }, [startGmailConnect]) + + // OAuth connect/disconnect + const startConnect = useCallback(async (provider: string, clientId?: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: true } + })) + + try { + const result = await window.ipc.invoke('oauth:connect', { provider, clientId }) + + if (!result.success) { + toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`)) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + } catch (error) { + console.error('Failed to connect:', error) + toast.error(provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isConnecting: false } + })) + } + }, []) + + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + setGoogleClientIdDescription(undefined) + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + setGoogleClientIdDescription(undefined) + startConnect('google', clientId) + }, [startConnect]) + + const handleDisconnect = useCallback(async (provider: string) => { + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: true } + })) + + try { + const result = await window.ipc.invoke('oauth:disconnect', { provider }) + + if (result.success) { + if (provider === 'google') { + clearGoogleClientId() + } + const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) + toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: false, + isLoading: false, + isConnecting: false, + } + })) + } else { + toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: false } + })) + } + } catch (error) { + console.error('Failed to disconnect:', error) + toast.error(provider === 'rowboat' ? 'Failed to log out of Rowboat' : `Failed to disconnect from ${provider}`) + setProviderStates(prev => ({ + ...prev, + [provider]: { ...prev[provider], isLoading: false } + })) + } + }, []) + + // Refresh all statuses + const refreshAllStatuses = useCallback(async () => { + refreshGranolaConfig() + refreshSlackConfig() + + if (useComposioForGoogle) { + refreshGmailStatus() + } + + if (useComposioForGoogleCalendar) { + refreshGoogleCalendarStatus() + } + + if (providers.length === 0) return + + const newStates: Record = {} + + try { + const result = await window.ipc.invoke('oauth:getState', null) + const config = result.config || {} + const statusMap: Record = {} + + for (const provider of providers) { + const providerConfig = config[provider] + newStates[provider] = { + isConnected: providerConfig?.connected ?? false, + isLoading: false, + isConnecting: false, + } + if (providerConfig?.error) { + statusMap[provider] = { error: providerConfig.error } + } + } + + setProviderStatus(statusMap) + } catch (error) { + console.error('Failed to check connection statuses:', error) + for (const provider of providers) { + newStates[provider] = { + isConnected: false, + isLoading: false, + isConnecting: false, + } + } + setProviderStatus({}) + } + + setProviderStates(newStates) + }, [providers, refreshGranolaConfig, refreshSlackConfig, refreshGmailStatus, useComposioForGoogle, refreshGoogleCalendarStatus, useComposioForGoogleCalendar]) + + // Refresh when active or providers change + useEffect(() => { + if (active) { + refreshAllStatuses() + } + }, [active, providers, refreshAllStatuses]) + + // Listen for OAuth events + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', async (event) => { + const { provider, success } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + + if (success) { + const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) + if (provider === 'rowboat') { + toast.success('Logged in to Rowboat') + } else if (provider === 'google' || provider === 'fireflies-ai') { + toast.success(`Connected to ${displayName}`, { + description: 'Syncing your data in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.success(`Connected to ${displayName}`) + } + + if (provider === 'rowboat') { + try { + const [googleResult, calendarResult] = await Promise.all([ + window.ipc.invoke('composio:use-composio-for-google', null), + window.ipc.invoke('composio:use-composio-for-google-calendar', null), + ]) + setUseComposioForGoogle(googleResult.enabled) + setUseComposioForGoogleCalendar(calendarResult.enabled) + } catch (err) { + console.error('Failed to re-check composio flags:', err) + } + } + + refreshAllStatuses() + } + }) + + return cleanup + }, [refreshAllStatuses]) + + // Listen for Composio events + useEffect(() => { + const cleanup = window.ipc.on('composio:didConnect', (event) => { + const { toolkitSlug, success, error } = event + + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + + if (success) { + toast.success('Connected to Gmail', { + description: 'Syncing your emails in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Gmail') + } + } + + if (toolkitSlug === 'googlecalendar') { + setGoogleCalendarConnected(success) + setGoogleCalendarConnecting(false) + + if (success) { + toast.success('Connected to Google Calendar', { + description: 'Syncing your calendar in the background. This may take a few minutes before changes appear.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Google Calendar') + } + } + }) + + return cleanup + }, []) + + const hasProviderError = Object.values(providerStatus).some( + (status) => Boolean(status?.error) + ) + + return { + // OAuth providers + providers, + providersLoading, + providerStates, + providerStatus, + hasProviderError, + handleConnect, + handleDisconnect, + startConnect, + + // Google client ID modal + googleClientIdOpen, + setGoogleClientIdOpen, + googleClientIdDescription, + setGoogleClientIdDescription, + handleGoogleClientIdSubmit, + + // Granola + granolaEnabled, + granolaLoading, + handleGranolaToggle, + + // Composio API key modal + composioApiKeyOpen, + setComposioApiKeyOpen, + composioApiKeyTarget, + setComposioApiKeyTarget, + handleComposioApiKeySubmit, + + // Slack + slackEnabled, + slackLoading, + slackWorkspaces, + slackAvailableWorkspaces, + slackSelectedUrls, + setSlackSelectedUrls, + slackPickerOpen, + setSlackPickerOpen, + slackDiscovering, + slackDiscoverError, + handleSlackEnable, + handleSlackSaveWorkspaces, + handleSlackDisable, + + // Gmail (Composio) + useComposioForGoogle, + gmailConnected, + gmailLoading, + gmailConnecting, + handleConnectGmail, + handleDisconnectGmail, + + // Google Calendar (Composio) + useComposioForGoogleCalendar, + googleCalendarConnected, + googleCalendarLoading, + googleCalendarConnecting, + handleConnectGoogleCalendar, + handleDisconnectGoogleCalendar, + + // Refresh + refreshAllStatuses, + } +} diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts index 69cf6083..b3011130 100644 --- a/apps/x/packages/core/src/billing/billing.ts +++ b/apps/x/packages/core/src/billing/billing.ts @@ -2,6 +2,8 @@ import { getAccessToken } from '../auth/tokens.js'; import { API_URL } from '../config/env.js'; export interface BillingInfo { + userEmail: string | null; + userId: string | null; subscriptionPlan: string | null; subscriptionStatus: string | null; sanctionedCredits: number; @@ -31,6 +33,8 @@ export async function getBillingInfo(): Promise { }; }; return { + userEmail: body.user.email ?? null, + userId: body.user.id ?? null, subscriptionPlan: body.billing.plan, subscriptionStatus: body.billing.status, sanctionedCredits: body.billing.usage.sanctionedCredits, diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index b6abbf29..346a95d2 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -526,6 +526,8 @@ const ipcSchemas = { 'billing:getInfo': { req: z.null(), res: z.object({ + userEmail: z.string().nullable(), + userId: z.string().nullable(), subscriptionPlan: z.string().nullable(), subscriptionStatus: z.string().nullable(), sanctionedCredits: z.number(),