From a1a16ec455ea22728d010990289be6feca27acba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Mar 2025 18:04:12 -0800 Subject: [PATCH] feat(oauth): added credentials tab to settings modal, added credential-selector sub-block, added routes for connecting/disconnecting/listing credentials --- app/api/auth/oauth/check-tool/route.ts | 27 +- app/api/auth/oauth/connections/route.ts | 51 +++ app/api/auth/oauth/credentials/route.ts | 54 +++ app/api/auth/oauth/disconnect/route.ts | 46 +++ .../components/credential-selector.tsx | 265 +++++++++++++ .../sub-block/components/oauth-input.tsx | 39 ++ .../components/credentials/credentials.tsx | 355 ++++++++++++++++++ .../settings-navigation.tsx | 9 +- .../settings-modal/settings-modal.tsx | 24 +- components/ui/oauth-required-modal.tsx | 108 ++++-- components/ui/skeleton.tsx | 7 + lib/auth.ts | 46 +-- tools/index.ts | 41 +- 13 files changed, 998 insertions(+), 74 deletions(-) create mode 100644 app/api/auth/oauth/connections/route.ts create mode 100644 app/api/auth/oauth/credentials/route.ts create mode 100644 app/api/auth/oauth/disconnect/route.ts create mode 100644 app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx create mode 100644 app/w/[id]/components/workflow-block/components/sub-block/components/oauth-input.tsx create mode 100644 app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx create mode 100644 components/ui/skeleton.tsx diff --git a/app/api/auth/oauth/check-tool/route.ts b/app/api/auth/oauth/check-tool/route.ts index a9e91e8e6a..39f3bcc1c7 100644 --- a/app/api/auth/oauth/check-tool/route.ts +++ b/app/api/auth/oauth/check-tool/route.ts @@ -11,10 +11,22 @@ import { OAuthProvider } from '@/tools/types' async function hasAuthorizedProvider( userId: string, provider: OAuthProvider, - requiredScopes?: string[] + requiredScopes?: string[], + credentialId?: string ): Promise { try { - // Determine the appropriate provider ID based on scopes + // If a specific credential ID is provided, check if it exists and belongs to the user + if (credentialId) { + const credential = await db + .select() + .from(account) + .where(and(eq(account.id, credentialId), eq(account.userId, userId))) + .limit(1) + + return credential.length > 0 + } + + // Otherwise, determine the appropriate provider ID based on scopes let featureType = 'default' if (requiredScopes && requiredScopes.length > 0) { if (requiredScopes.some((scope) => scope.includes('repo'))) { @@ -69,8 +81,8 @@ export async function POST(request: NextRequest) { ) } - // Get the tool from the request body - const { tool } = await request.json() + // Get the tool and credential ID from the request body + const { tool, credentialId } = await request.json() // Check if the tool requires OAuth if (!tool.oauth || !tool.oauth.required) { @@ -82,7 +94,12 @@ export async function POST(request: NextRequest) { const requiredScopes = tool.oauth.additionalScopes || [] // Check if the user has authorized this provider - const isAuthorized = await hasAuthorizedProvider(session.user.id, provider, requiredScopes) + const isAuthorized = await hasAuthorizedProvider( + session.user.id, + provider, + requiredScopes, + credentialId + ) // Return the authorization status if (isAuthorized) { diff --git a/app/api/auth/oauth/connections/route.ts b/app/api/auth/oauth/connections/route.ts new file mode 100644 index 0000000000..4df1dcca79 --- /dev/null +++ b/app/api/auth/oauth/connections/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { and, eq, like } from 'drizzle-orm' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { account } from '@/db/schema' +import { OAuthProvider } from '@/tools/types' + +// Valid OAuth providers +const VALID_PROVIDERS = ['google', 'github', 'twitter'] + +/** + * Get all OAuth connections for the current user + */ +export async function GET(request: NextRequest) { + try { + // Get the session + const session = await getSession() + + // Check if the user is authenticated + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + // Get all accounts for this user + const accounts = await db.select().from(account).where(eq(account.userId, session.user.id)) + + // Process accounts to determine connections + const connections: any[] = [] + + accounts.forEach((acc) => { + // Extract the base provider and feature type from providerId (e.g., 'google-email' -> 'google', 'email') + const [provider, featureType = 'default'] = acc.providerId.split('-') + + if (provider && VALID_PROVIDERS.includes(provider)) { + connections.push({ + provider: provider as OAuthProvider, + featureType, + isConnected: true, + scopes: acc.scope ? acc.scope.split(' ') : [], + lastConnected: acc.updatedAt.toISOString(), + accountId: acc.id, + }) + } + }) + + return NextResponse.json({ connections }, { status: 200 }) + } catch (error) { + console.error('Error fetching OAuth connections:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/auth/oauth/credentials/route.ts b/app/api/auth/oauth/credentials/route.ts new file mode 100644 index 0000000000..07b55cf8aa --- /dev/null +++ b/app/api/auth/oauth/credentials/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { and, eq, like } from 'drizzle-orm' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { account } from '@/db/schema' +import { OAuthProvider } from '@/tools/types' + +/** + * Get credentials for a specific provider + */ +export async function GET(request: NextRequest) { + try { + // Get the session + const session = await getSession() + + // Check if the user is authenticated + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + // Get the provider from the query params + const { searchParams } = new URL(request.url) + const provider = searchParams.get('provider') as OAuthProvider | null + + if (!provider) { + return NextResponse.json({ error: 'Provider is required' }, { status: 400 }) + } + + // Get all accounts for this user and provider + const accounts = await db + .select() + .from(account) + .where(and(eq(account.userId, session.user.id), like(account.providerId, `${provider}-%`))) + + // Transform accounts into credentials + const credentials = accounts.map((acc) => { + // Extract the feature type from providerId (e.g., 'google-default' -> 'default') + const [_, featureType = 'default'] = acc.providerId.split('-') + + return { + id: acc.id, + name: `${provider.charAt(0).toUpperCase() + provider.slice(1)} ${featureType !== 'default' ? featureType : ''}`.trim(), + provider, + lastUsed: acc.updatedAt.toISOString(), + isDefault: featureType === 'default', + } + }) + + return NextResponse.json({ credentials }, { status: 200 }) + } catch (error) { + console.error('Error fetching credentials:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/auth/oauth/disconnect/route.ts b/app/api/auth/oauth/disconnect/route.ts new file mode 100644 index 0000000000..8055f80041 --- /dev/null +++ b/app/api/auth/oauth/disconnect/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import { and, eq, like } from 'drizzle-orm' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { account } from '@/db/schema' +import { OAuthProvider } from '@/tools/types' + +/** + * Disconnect an OAuth provider for the current user + */ +export async function POST(request: NextRequest) { + try { + // Get the session + const session = await getSession() + + // Check if the user is authenticated + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + // Get the provider and providerId from the request body + const { provider, providerId } = await request.json() + + if (!provider) { + return NextResponse.json({ error: 'Provider is required' }, { status: 400 }) + } + + // If a specific providerId is provided, delete only that account + if (providerId) { + await db + .delete(account) + .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) + } else { + // Otherwise, delete all accounts for this provider + // We use LIKE to match all feature types (e.g., google-default, google-email, etc.) + await db + .delete(account) + .where(and(eq(account.userId, session.user.id), like(account.providerId, `${provider}-%`))) + } + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + console.error('Error disconnecting OAuth provider:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx new file mode 100644 index 0000000000..52fe449240 --- /dev/null +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector.tsx @@ -0,0 +1,265 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Check, ChevronDown, ExternalLink, Key, RefreshCw } from 'lucide-react' +import { GoogleIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { saveToStorage } from '@/stores/workflows/persistence' +import { OAuthProvider } from '@/tools/types' + +interface CredentialSelectorProps { + value: string + onChange: (value: string) => void + provider: OAuthProvider + requiredScopes?: string[] + label?: string + disabled?: boolean + serviceId?: string +} + +interface Credential { + id: string + name: string + provider: OAuthProvider + lastUsed?: string + isDefault?: boolean +} + +export function CredentialSelector({ + value, + onChange, + provider, + requiredScopes = [], + label = 'Select credential', + disabled = false, + serviceId, +}: CredentialSelectorProps) { + const [open, setOpen] = useState(false) + const [credentials, setCredentials] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [showOAuthModal, setShowOAuthModal] = useState(false) + + // Fetch available credentials for this provider + useEffect(() => { + const fetchCredentials = async () => { + setIsLoading(true) + try { + const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`) + if (response.ok) { + const data = await response.json() + setCredentials(data.credentials) + + // If we have a value but it's not in the credentials, reset it + if (value && !data.credentials.some((cred: Credential) => cred.id === value)) { + onChange('') + } + + // If we have no value but have a default credential, select it + if (!value && data.credentials.length > 0) { + const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault) + if (defaultCred) { + onChange(defaultCred.id) + } else if (data.credentials.length === 1) { + // If only one credential, select it + onChange(data.credentials[0].id) + } + } + } + } catch (error) { + console.error('Error fetching credentials:', error) + } finally { + setIsLoading(false) + } + } + + fetchCredentials() + }, [provider, onChange, value]) + + // Get the selected credential + const selectedCredential = credentials.find((cred) => cred.id === value) + + // Determine the appropriate service ID based on provider and scopes + const getServiceId = (): string => { + if (serviceId) return serviceId + + if (provider === 'google') { + if (requiredScopes.some((scope) => scope.includes('gmail') || scope.includes('mail'))) { + return 'gmail' + } else if (requiredScopes.some((scope) => scope.includes('drive'))) { + return 'google-drive' + } else if (requiredScopes.some((scope) => scope.includes('calendar'))) { + return 'google-calendar' + } else { + return 'gmail' // Default Google service + } + } else if (provider === 'github') { + return 'github' + } else if (provider === 'twitter') { + return 'twitter' + } + + return provider + } + + // Determine the appropriate provider ID based on service and scopes + const getProviderId = (): string => { + const effectiveServiceId = getServiceId() + + switch (effectiveServiceId) { + case 'gmail': + return 'google-email' + case 'google-drive': + return 'google-drive' + case 'google-calendar': + return 'google-calendar' + case 'github': + if (requiredScopes.some((scope) => scope.includes('workflow'))) { + return 'github-workflow' + } + return 'github-repo' + case 'twitter': + if (requiredScopes.some((scope) => scope.includes('write'))) { + return 'twitter-write' + } + return 'twitter-read' + default: + return `${provider}-default` + } + } + + // Handle adding a new credential + const handleAddCredential = () => { + const effectiveServiceId = getServiceId() + const providerId = getProviderId() + + // Store information about the required connection + saveToStorage('pending_service_id', effectiveServiceId) + saveToStorage('pending_oauth_scopes', requiredScopes) + saveToStorage('pending_oauth_return_url', window.location.href) + saveToStorage('pending_oauth_provider_id', providerId) + + // Show the OAuth modal + setShowOAuthModal(true) + setOpen(false) + } + + // Get provider icon + const getProviderIcon = (provider: OAuthProvider) => { + switch (provider) { + case 'google': + return + default: + return + } + } + + // Get provider name + const getProviderName = (provider: OAuthProvider) => { + switch (provider) { + case 'google': + return 'Google' + case 'github': + return 'GitHub' + case 'twitter': + return 'X (Twitter)' + default: + return provider + } + } + + return ( + <> + + + + + + + + + + {isLoading ? ( +
+ +
+ ) : ( +
+

No credentials found

+
+ )} +
+ {credentials.length > 0 && ( + + {credentials.map((credential) => ( + { + onChange(credential.id) + setOpen(false) + }} + > +
+ {getProviderIcon(credential.provider)} + {credential.name} +
+ {credential.id === value && } +
+ ))} +
+ )} +
+ +
+
+
+
+
+ + setShowOAuthModal(false)} + provider={provider} + toolName={`${getProviderName(provider)} Integration`} + requiredScopes={requiredScopes} + serviceId={getServiceId()} + /> + + ) +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/oauth-input.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/oauth-input.tsx new file mode 100644 index 0000000000..473761ef9a --- /dev/null +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/oauth-input.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useEffect, useState } from 'react' +import { OAuthProvider } from '@/tools/types' +import { CredentialSelector } from './credential-selector' + +interface OAuthInputProps { + value: string + onChange: (value: string) => void + provider: OAuthProvider + requiredScopes?: string[] + label?: string + disabled?: boolean + serviceId?: string +} + +export function OAuthInput({ + value, + onChange, + provider, + requiredScopes = [], + label, + disabled = false, + serviceId, +}: OAuthInputProps) { + return ( +
+ +
+ ) +} diff --git a/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx b/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx new file mode 100644 index 0000000000..23d822dc4c --- /dev/null +++ b/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx @@ -0,0 +1,355 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Check, ExternalLink, RefreshCw } from 'lucide-react' +import { GithubIcon, GoogleIcon, xIcon as XIcon } from '@/components/icons' +import { GmailIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { client } from '@/lib/auth-client' +import { useSession } from '@/lib/auth-client' +import { cn } from '@/lib/utils' +import { loadFromStorage, removeFromStorage, saveToStorage } from '@/stores/workflows/persistence' +import { OAuthProvider } from '@/tools/types' + +interface CredentialsProps { + onOpenChange?: (open: boolean) => void +} + +interface ServiceInfo { + id: string + name: string + description: string + provider: OAuthProvider + providerId: string + icon: React.ReactNode + isConnected: boolean + scopes: string[] + lastConnected?: string +} + +export function Credentials({ onOpenChange }: CredentialsProps) { + const router = useRouter() + const searchParams = useSearchParams() + const { data: session } = useSession() + const userId = session?.user?.id + + const [services, setServices] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isConnecting, setIsConnecting] = useState(null) + const [pendingService, setPendingService] = useState(null) + const [pendingScopes, setPendingScopes] = useState([]) + const [authSuccess, setAuthSuccess] = useState(false) + + // Define available services + const defineServices = (): ServiceInfo[] => [ + { + id: 'gmail', + name: 'Gmail', + description: 'Automate email workflows and enhance communication efficiency.', + provider: 'google', + providerId: 'google-email', + icon: , + isConnected: false, + scopes: [], + }, + { + id: 'google-drive', + name: 'Google Drive', + description: 'Streamline file organization and document workflows.', + provider: 'google', + providerId: 'google-drive', + icon: , + isConnected: false, + scopes: [], + }, + { + id: 'github', + name: 'GitHub', + description: 'Access repositories, issues, and other GitHub features.', + provider: 'github', + providerId: 'github-repo', + icon: , + isConnected: false, + scopes: [], + }, + { + id: 'twitter', + name: 'X (Twitter)', + description: 'Read and post tweets, access user data, and more.', + provider: 'twitter', + providerId: 'twitter-read', + icon: , + isConnected: false, + scopes: [], + }, + ] + + // Fetch connection status + const fetchServices = async () => { + if (!userId) return + + setIsLoading(true) + try { + // Get the base services + const baseServices = defineServices() + + // Call your API to check connections + const response = await fetch('/api/auth/oauth/connections') + if (response.ok) { + const data = await response.json() + const connections = data.connections || [] + + // Update services with connection status + const updatedServices = baseServices.map((service) => { + // Find matching connection + const connection = connections.find((conn: any) => { + if ( + service.id === 'gmail' && + conn.provider === 'google' && + conn.featureType === 'email' + ) { + return true + } + if ( + service.id === 'google-drive' && + conn.provider === 'google' && + conn.featureType === 'drive' + ) { + return true + } + if (service.id === 'github' && conn.provider === 'github') { + return true + } + if (service.id === 'twitter' && conn.provider === 'twitter') { + return true + } + return false + }) + + if (connection) { + return { + ...service, + isConnected: true, + scopes: connection.scopes || [], + lastConnected: connection.lastConnected, + } + } + + return service + }) + + setServices(updatedServices) + } else { + // If API fails, set default state + setServices(baseServices) + } + } catch (error) { + console.error('Error fetching connections:', error) + // Set default state on error + setServices(defineServices()) + } finally { + setIsLoading(false) + } + } + + // Handle OAuth callback + useEffect(() => { + // Check if this is an OAuth callback + const code = searchParams.get('code') + const state = searchParams.get('state') + + if (code && state) { + // This is an OAuth callback - set success flag + setAuthSuccess(true) + + // Refresh connections to show the new connection + if (userId) { + fetchServices() + } + } + }, [searchParams, userId]) + + // Check for pending OAuth connections and return URL + useEffect(() => { + if (typeof window === 'undefined') return + + // Check if there's a pending OAuth connection + const serviceId = loadFromStorage('pending_service_id') + const scopes = loadFromStorage('pending_oauth_scopes') || [] + const returnUrl = loadFromStorage('pending_oauth_return_url') + + if (serviceId) { + setPendingService(serviceId) + setPendingScopes(scopes) + + // Clear the pending connection after a short delay + // This gives the user time to see the highlighted connection + setTimeout(() => { + removeFromStorage('pending_service_id') + removeFromStorage('pending_oauth_scopes') + }, 500) + } + + // Handle successful authentication return + if (authSuccess && returnUrl && onOpenChange) { + // Clear the success flag + setAuthSuccess(false) + removeFromStorage('pending_oauth_return_url') + + // Close the settings modal and return to workflow + setTimeout(() => { + onOpenChange(false) + + // Navigate back to the workflow if needed + if (returnUrl !== window.location.href) { + router.push(returnUrl) + } + }, 1500) // Slightly longer delay to show the connected state + } + }, [authSuccess, onOpenChange, router]) + + // Fetch connection status on component mount + useEffect(() => { + fetchServices() + }, [userId]) + + const handleConnect = async (service: ServiceInfo) => { + setIsConnecting(service.id) + try { + // Store the current URL to return to after auth + saveToStorage('auth_return_url', window.location.href) + saveToStorage('pending_service_id', service.id) + + // Set a flag to indicate we're in the auth flow + saveToStorage('auth_in_progress', true) + + // Begin OAuth flow with the appropriate provider + await client.signIn.oauth2({ + providerId: service.providerId, + callbackURL: window.location.href, // Return to the current page after auth + }) + } catch (error) { + console.error('OAuth login error:', error) + setIsConnecting(null) + } + } + + const handleDisconnect = async (service: ServiceInfo) => { + setIsConnecting(service.id) + try { + // Call your API to disconnect the provider + const response = await fetch('/api/auth/oauth/disconnect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + provider: service.provider, + providerId: service.providerId, + }), + }) + + if (response.ok) { + // Update the local state + setServices((prev) => + prev.map((svc) => + svc.id === service.id ? { ...svc, isConnected: false, scopes: [] } : svc + ) + ) + } + } catch (error) { + console.error('Error disconnecting provider:', error) + } finally { + setIsConnecting(null) + } + } + + return ( +
+
+

Credentials

+

+ Connect your accounts to use tools that require authentication. +

+
+ +
+ {isLoading ? ( + <> + + + + + + ) : ( + services.map((service) => ( + +
+
+ {service.icon} +
+
+

{service.name}

+

{service.description}

+ {service.isConnected && ( +

+ + Connected +

+ )} +
+
+ + +
+ )) + )} +
+ + {pendingService && ( +
+

+ Note: Connect this service to use tools that + require this authentication. +

+
+ )} +
+ ) +} + +function ConnectionSkeleton() { + return ( + +
+ +
+ + +
+
+ +
+ ) +} diff --git a/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx index ff99f73c76..fa6c3a308a 100644 --- a/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx +++ b/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -1,9 +1,9 @@ -import { KeyRound, Settings, UserCircle } from 'lucide-react' +import { Key, KeyRound, Settings, UserCircle } from 'lucide-react' import { cn } from '@/lib/utils' interface SettingsNavigationProps { activeSection: string - onSectionChange: (section: 'general' | 'environment' | 'account') => void + onSectionChange: (section: 'general' | 'environment' | 'account' | 'credentials') => void } const navigationItems = [ @@ -22,6 +22,11 @@ const navigationItems = [ label: 'Account', icon: UserCircle, }, + { + id: 'credentials', + label: 'Credentials', + icon: Key, + }, ] as const export function SettingsNavigation({ activeSection, onSectionChange }: SettingsNavigationProps) { diff --git a/app/w/components/sidebar/components/settings-modal/settings-modal.tsx b/app/w/components/sidebar/components/settings-modal/settings-modal.tsx index 4f57bce1c7..ac319def47 100644 --- a/app/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/app/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -1,11 +1,12 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { cn } from '@/lib/utils' import { Account } from './components/account/account' +import { Credentials } from './components/credentials/credentials' import { EnvironmentVariables } from './components/environment/environment' import { General } from './components/general/general' import { SettingsNavigation } from './components/settings-navigation/settings-navigation' @@ -15,11 +16,27 @@ interface SettingsModalProps { onOpenChange: (open: boolean) => void } -type SettingsSection = 'general' | 'environment' | 'account' +type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const [activeSection, setActiveSection] = useState('general') + // Listen for the custom event to open the settings modal with a specific tab + useEffect(() => { + const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => { + setActiveSection(event.detail.tab) + onOpenChange(true) + } + + // Add event listener + window.addEventListener('open-settings', handleOpenSettings as EventListener) + + // Clean up + return () => { + window.removeEventListener('open-settings', handleOpenSettings as EventListener) + } + }, [onOpenChange]) + return ( @@ -55,6 +72,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
+
+ +
diff --git a/components/ui/oauth-required-modal.tsx b/components/ui/oauth-required-modal.tsx index b598380bc8..fd09b4b57f 100644 --- a/components/ui/oauth-required-modal.tsx +++ b/components/ui/oauth-required-modal.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { client } from '@/lib/auth-client' +import { loadFromStorage, saveToStorage } from '@/stores/workflows/persistence' import { OAuthProvider } from '@/tools/types' export interface OAuthRequiredModalProps { @@ -19,6 +19,7 @@ export interface OAuthRequiredModalProps { provider: OAuthProvider toolName: string requiredScopes?: string[] + serviceId?: string } // Map of provider names to friendly display names @@ -41,44 +42,87 @@ export function OAuthRequiredModal({ provider, toolName, requiredScopes = [], + serviceId, }: OAuthRequiredModalProps) { const providerName = PROVIDER_NAMES[provider] || provider const ProviderIcon = PROVIDER_ICONS[provider] - const handleAuth = async () => { + const handleRedirectToSettings = () => { try { - // Determine the appropriate providerId based on the provider and required scopes - let featureType = 'default' + // Determine the appropriate providerId and serviceId based on the provider and required scopes + let providerId: string + let effectiveServiceId = serviceId - // Simple scope-based feature detection (expand as needed) - if (requiredScopes.some((scope) => scope.includes('repo'))) { - featureType = 'repo' - } else if (requiredScopes.some((scope) => scope.includes('workflow'))) { - featureType = 'workflow' - } else if ( - requiredScopes.some((scope) => scope.includes('gmail') || scope.includes('mail')) - ) { - featureType = 'email' - } else if (requiredScopes.some((scope) => scope.includes('calendar'))) { - featureType = 'calendar' - } else if (requiredScopes.some((scope) => scope.includes('drive'))) { - featureType = 'drive' - } else if (requiredScopes.some((scope) => scope.includes('write'))) { - featureType = 'write' - } else if (requiredScopes.some((scope) => scope.includes('read'))) { - featureType = 'read' + // If no serviceId is provided, determine it based on scopes + if (!effectiveServiceId) { + if (provider === 'google') { + if (requiredScopes.some((scope) => scope.includes('gmail') || scope.includes('mail'))) { + effectiveServiceId = 'gmail' + providerId = 'google-email' + } else if (requiredScopes.some((scope) => scope.includes('drive'))) { + effectiveServiceId = 'google-drive' + providerId = 'google-drive' + } else if (requiredScopes.some((scope) => scope.includes('calendar'))) { + effectiveServiceId = 'google-calendar' + providerId = 'google-calendar' + } else { + effectiveServiceId = 'gmail' // Default Google service + providerId = 'google-email' + } + } else if (provider === 'github') { + effectiveServiceId = 'github' + if (requiredScopes.some((scope) => scope.includes('workflow'))) { + providerId = 'github-workflow' + } else { + providerId = 'github-repo' + } + } else if (provider === 'twitter') { + effectiveServiceId = 'twitter' + if (requiredScopes.some((scope) => scope.includes('write'))) { + providerId = 'twitter-write' + } else { + providerId = 'twitter-read' + } + } else { + effectiveServiceId = provider + providerId = `${provider}-default` + } + } else { + // Use the provided serviceId to determine the providerId + switch (effectiveServiceId) { + case 'gmail': + providerId = 'google-email' + break + case 'google-drive': + providerId = 'google-drive' + break + case 'github': + providerId = 'github-repo' + break + case 'twitter': + providerId = 'twitter-read' + break + default: + providerId = `${provider}-default` + } } - // Construct the providerId based on the provider and feature type - const providerId = `${provider}-${featureType}` + // Store information about the required connection + saveToStorage('pending_service_id', effectiveServiceId) + saveToStorage('pending_oauth_scopes', requiredScopes) + saveToStorage('pending_oauth_return_url', window.location.href) + saveToStorage('pending_oauth_provider_id', providerId) - // Begin OAuth flow with the appropriate provider - await client.signIn.oauth2({ - providerId, - callbackURL: window.location.href, // Return to the current page after auth + // Close the modal + onClose() + + // Open the settings modal with the credentials tab + const event = new CustomEvent('open-settings', { + detail: { tab: 'credentials' }, }) + window.dispatchEvent(event) } catch (error) { - console.error('OAuth login error:', error) + console.error('Error redirecting to settings:', error) } } @@ -99,7 +143,9 @@ export function OAuthRequiredModal({

Connect {providerName}

-

Authorize access to use this tool

+

+ You need to connect your {providerName} account in settings +

@@ -118,8 +164,8 @@ export function OAuthRequiredModal({ - diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000000..7347fdff94 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
+} + +export { Skeleton } diff --git a/lib/auth.ts b/lib/auth.ts index 453da58767..6eb018c601 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -38,11 +38,11 @@ export const auth = betterAuth({ 'https://www.googleapis.com/auth/userinfo.profile', ], }, - twitter: { - clientId: process.env.TWITTER_CLIENT_ID as string, - clientSecret: process.env.TWITTER_CLIENT_SECRET as string, - scopes: ['tweet.read', 'users.read'], - }, + // twitter: { + // clientId: process.env.TWITTER_CLIENT_ID as string, + // clientSecret: process.env.TWITTER_CLIENT_SECRET as string, + // scopes: ['tweet.read', 'users.read'], + // }, }, emailAndPassword: { enabled: true, @@ -159,24 +159,24 @@ export const auth = betterAuth({ }, // Twitter providers - { - providerId: 'twitter-read', - clientId: process.env.TWITTER_CLIENT_ID as string, - clientSecret: process.env.TWITTER_CLIENT_SECRET as string, - authorizationUrl: 'https://twitter.com/i/oauth2/authorize', - tokenUrl: 'https://api.twitter.com/2/oauth2/token', - userInfoUrl: 'https://api.twitter.com/2/users/me', - scopes: ['tweet.read', 'users.read'], - }, - { - providerId: 'twitter-write', - clientId: process.env.TWITTER_CLIENT_ID as string, - clientSecret: process.env.TWITTER_CLIENT_SECRET as string, - authorizationUrl: 'https://twitter.com/i/oauth2/authorize', - tokenUrl: 'https://api.twitter.com/2/oauth2/token', - userInfoUrl: 'https://api.twitter.com/2/users/me', - scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], - }, + // { + // providerId: 'twitter-read', + // clientId: process.env.TWITTER_CLIENT_ID as string, + // clientSecret: process.env.TWITTER_CLIENT_SECRET as string, + // authorizationUrl: 'https://twitter.com/i/oauth2/authorize', + // tokenUrl: 'https://api.twitter.com/2/oauth2/token', + // userInfoUrl: 'https://api.twitter.com/2/users/me', + // scopes: ['tweet.read', 'users.read'], + // }, + // { + // providerId: 'twitter-write', + // clientId: process.env.TWITTER_CLIENT_ID as string, + // clientSecret: process.env.TWITTER_CLIENT_SECRET as string, + // authorizationUrl: 'https://twitter.com/i/oauth2/authorize', + // tokenUrl: 'https://api.twitter.com/2/oauth2/token', + // userInfoUrl: 'https://api.twitter.com/2/users/me', + // scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], + // }, ], }), ], diff --git a/tools/index.ts b/tools/index.ts index 50b4948685..66779cca9d 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -276,20 +276,25 @@ function getCustomTool(customToolId: string): ToolConfig | undefined { } // Function to check OAuth via API -async function checkOAuth(tool: any): Promise { - // Skip if no OAuth config or not required or if running in browser - if (!tool.oauth?.required || isBrowser()) { - return +async function checkOAuth(tool: any, params: Record): Promise { + if (!tool.oauth || !tool.oauth.required) { + return // No OAuth required } + // Check if a credential ID is provided + const credentialId = params._credentialId + try { - // Call the API route for OAuth checking + // Call the API to check if the user is authorized const response = await fetch('/api/auth/oauth/check-tool', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ tool }), + body: JSON.stringify({ + tool, + credentialId, // Pass the credential ID if provided + }), }) if (!response.ok) { @@ -298,12 +303,26 @@ async function checkOAuth(tool: any): Promise { const data = await response.json() - // If requires auth but not authorized, throw the OAuth error - if (data.requiresAuth && !data.isAuthorized && data.error) { - throw new Error(data.error) + if (!data.isAuthorized) { + // Parse the error to get OAuth details + const errorDetails = JSON.parse(data.error || '{}') + + if (errorDetails.type === 'oauth_required') { + throw new Error( + JSON.stringify({ + type: 'oauth_required', + provider: errorDetails.provider, + toolId: errorDetails.toolId, + toolName: errorDetails.toolName, + requiredScopes: errorDetails.requiredScopes, + }) + ) + } else { + throw new Error('OAuth authorization required') + } } } catch (error) { - // Re-throw the error to be caught by execution error handlers + console.error('Error checking OAuth authorization:', error) throw error } } @@ -327,7 +346,7 @@ export async function executeTool( // Check OAuth requirements before executing the tool if (tool.oauth?.required && !isBrowser()) { - await checkOAuth(tool) + await checkOAuth(tool, params) } // For custom tools, try direct execution in browser first if available