From 47ecc3198852de5a208673bb5b4c3046c7e36220 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 18 Mar 2026 16:15:02 +0530 Subject: [PATCH] Refactor ConnectorsPopover and SettingsDialog components to enhance account management features. Introduce AccountSettings and ConnectedAccountsSettings components for improved user experience in managing Rowboat account connections. Update billing information structure to include user email and ID. Implement dynamic tab visibility based on connection status in SettingsDialog. --- .../src/components/connectors-popover.tsx | 1036 +++++------------ .../src/components/settings-dialog.tsx | 28 +- .../components/settings/account-settings.tsx | 211 ++++ .../settings/connected-accounts-settings.tsx | 364 ++++++ .../src/components/sidebar-content.tsx | 36 +- apps/x/apps/renderer/src/hooks/useBilling.ts | 2 + .../apps/renderer/src/hooks/useConnectors.ts | 618 ++++++++++ apps/x/packages/core/src/billing/billing.ts | 4 + apps/x/packages/shared/src/ipc.ts | 2 + 9 files changed, 1522 insertions(+), 779 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/settings/account-settings.tsx create mode 100644 apps/x/apps/renderer/src/components/settings/connected-accounts-settings.tsx create mode 100644 apps/x/apps/renderer/src/hooks/useConnectors.ts 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(),