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.

This commit is contained in:
tusharmagar 2026-03-18 16:15:02 +05:30
parent b066aa2b24
commit 47ecc31988
9 changed files with 1522 additions and 779 deletions

File diff suppressed because it is too large Load diff

View file

@ -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) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "models" ? (
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "account" || activeTab === "connected-accounts" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connected-accounts" ? (
<ConnectedAccountsSettings dialogOpen={open} />
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
: <ModelSettings dialogOpen={open} />

View file

@ -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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (!isRowboatConnected) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
<User className="size-7 text-muted-foreground" />
</div>
<div className="text-center space-y-1">
<p className="text-sm font-medium">Not logged in</p>
<p className="text-xs text-muted-foreground">Log in to your Rowboat account to access premium features</p>
</div>
<Button onClick={handleConnect} disabled={connecting}>
{connecting ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
Log in to Rowboat
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Profile Section */}
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<User className="size-6 text-primary" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{billing?.userEmail ?? 'Loading...'}
</p>
<p className="text-xs text-muted-foreground">Rowboat Account</p>
</div>
</div>
</div>
<Separator />
{/* Plan Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<CreditCard className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Plan</h4>
</div>
{billingLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Loading plan details...
</div>
) : billing ? (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">{billing.subscriptionPlan ?? 'Free'} Plan</p>
{billing.subscriptionStatus && (
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
)}
</div>
<Button variant="outline" size="sm">
Upgrade
</Button>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Unable to load plan details</p>
)}
</div>
<Separator />
{/* Log Out Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<LogOut className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Log Out</h4>
</div>
<p className="text-xs text-muted-foreground">
Logging out will remove access to synced data and Rowboat-provided models.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
Log Out
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Log out of your Rowboat account?</AlertDialogTitle>
<AlertDialogDescription>
This will remove access to synced data and Rowboat-provided models. You can log back in at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDisconnect}
disabled={disconnecting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{disconnecting ? <Loader2 className="size-4 animate-spin mr-2" /> : null}
Log Out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View file

@ -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 (
<div
key={provider}
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{displayName}</span>
{state.isLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : needsReconnect ? (
<span className="text-xs text-amber-600">Needs reconnect</span>
) : state.isConnected ? (
<span className="text-xs text-emerald-600">Connected</span>
) : (
<span className="text-xs text-muted-foreground truncate">{description}</span>
)}
</div>
</div>
<div className="shrink-0">
{state.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : needsReconnect ? (
<Button
variant="default"
size="sm"
onClick={() => {
if (provider === 'google') {
c.setGoogleClientIdDescription(
"To keep your Google account connected, please re-enter your client ID. You only need to do this once."
)
c.setGoogleClientIdOpen(true)
return
}
c.startConnect(provider)
}}
className="h-7 px-3 text-xs"
>
Reconnect
</Button>
) : state.isConnected ? (
<Button
variant="outline"
size="sm"
onClick={() => c.handleDisconnect(provider)}
className="h-7 px-3 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => c.handleConnect(provider)}
disabled={state.isConnecting}
className="h-7 px-3 text-xs"
>
{state.isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
}
if (c.providersLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
return (
<>
<GoogleClientIdModal
open={c.googleClientIdOpen}
onOpenChange={(nextOpen) => {
c.setGoogleClientIdOpen(nextOpen)
if (!nextOpen) {
c.setGoogleClientIdDescription(undefined)
}
}}
onSubmit={c.handleGoogleClientIdSubmit}
isSubmitting={c.providerStates.google?.isConnecting ?? false}
description={c.googleClientIdDescription}
/>
<ComposioApiKeyModal
open={c.composioApiKeyOpen}
onOpenChange={c.setComposioApiKeyOpen}
onSubmit={c.handleComposioApiKeySubmit}
isSubmitting={c.gmailConnecting}
/>
<div className="space-y-1">
{/* Email & Calendar Section */}
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
<>
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
</div>
{c.useComposioForGoogle ? (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Gmail</span>
{c.gmailLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.gmailConnected ? (
<span className="text-xs text-emerald-600">Connected</span>
) : (
<span className="text-xs text-muted-foreground truncate">Sync emails</span>
)}
</div>
</div>
<div className="shrink-0">
{c.gmailLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.gmailConnected ? (
<Button
variant="outline"
size="sm"
onClick={c.handleDisconnectGmail}
className="h-7 px-3 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleConnectGmail}
disabled={c.gmailConnecting}
className="h-7 px-3 text-xs"
>
{c.gmailConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
) : (
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Google Calendar</span>
{c.googleCalendarLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : c.googleCalendarConnected ? (
<span className="text-xs text-emerald-600">Connected</span>
) : (
<span className="text-xs text-muted-foreground truncate">Sync calendar events</span>
)}
</div>
</div>
<div className="shrink-0">
{c.googleCalendarLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : c.googleCalendarConnected ? (
<Button
variant="outline"
size="sm"
onClick={c.handleDisconnectGoogleCalendar}
className="h-7 px-3 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleConnectGoogleCalendar}
disabled={c.googleCalendarConnecting}
className="h-7 px-3 text-xs"
>
{c.googleCalendarConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)}
<Separator className="my-3" />
</>
)}
{/* Meeting Notes Section */}
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>
</div>
{/* Granola */}
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<Mic className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Local meeting notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{c.granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={c.granolaEnabled}
onCheckedChange={c.handleGranolaToggle}
disabled={c.granolaLoading}
/>
</div>
</div>
{/* Fireflies */}
{c.providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-3" />
{/* Team Communication Section */}
<div className="px-4 py-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Team Communication
</span>
</div>
{/* Slack */}
<div className="rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{c.slackEnabled && c.slackWorkspaces.length > 0 ? (
<span className="text-xs text-emerald-600 truncate">
{c.slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{(c.slackLoading || c.slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{c.slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => c.handleSlackDisable()}
disabled={c.slackLoading}
/>
) : (
<Button
variant="default"
size="sm"
onClick={c.handleSlackEnable}
disabled={c.slackLoading || c.slackDiscovering}
className="h-7 px-3 text-xs"
>
Enable
</Button>
)}
</div>
</div>
{c.slackPickerOpen && (
<div className="mt-2 ml-12 space-y-2">
{c.slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{c.slackDiscoverError}</p>
) : (
<>
{c.slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={c.slackSelectedUrls.has(w.url)}
onChange={(e) => {
c.setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={c.handleSlackSaveWorkspaces}
disabled={c.slackSelectedUrls.size === 0 || c.slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</div>
</>
)
}

View file

@ -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<HTMLButtonElement | null>(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({
/>
)}
</SidebarContent>
{/* Billing / upgrade CTA */}
{isRowboatConnected && billing && (
{/* Billing / upgrade CTA or Log in CTA */}
{isRowboatConnected && billing ? (
<div className="px-3 py-2">
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
@ -500,18 +514,30 @@ export function SidebarContentPanel({
</button>
</div>
</div>
) : null}
{/* Sign in CTA */}
{!isRowboatConnected && (
<div className="px-3 py-2">
<button
onClick={handleRowboatLogin}
disabled={loggingIn}
className="flex w-full items-center justify-center rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2.5 text-xs font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent/40 disabled:opacity-50"
>
{loggingIn ? 'Signing in…' : 'Sign in to Rowboat'}
</button>
</div>
)}
{/* Bottom actions */}
<div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen}>
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen} mode="unconnected">
<button
ref={connectorsButtonRef}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Plug className="size-4" />
<span>Connected accounts</span>
<span>Connect Accounts</span>
</button>
</ConnectorsPopover>
{hasOauthError && (

View file

@ -1,6 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
interface BillingInfo {
userEmail: string | null
userId: string | null
subscriptionPlan: string
subscriptionStatus: string
sanctionedCredits: number

View file

@ -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<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
const [providerStatus, setProviderStatus] = useState<Record<string, ProviderStatus>>({})
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
const [googleClientIdDescription, setGoogleClientIdDescription] = useState<string | undefined>(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<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(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<string, ProviderState> = {}
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
const statusMap: Record<string, ProviderStatus> = {}
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,
}
}

View file

@ -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<BillingInfo> {
};
};
return {
userEmail: body.user.email ?? null,
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionStatus: body.billing.status,
sanctionedCredits: body.billing.usage.sanctionedCredits,

View file

@ -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(),