improvement: standardized oauth implementation & ui

This commit is contained in:
Waleed Latif
2025-03-08 20:47:50 -08:00
parent b53177cd14
commit 6b10c8fedc
8 changed files with 555 additions and 481 deletions

View File

@@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { getSession } from '@/lib/auth'
import { OAuthService } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { OAuthProvider } from '@/tools/types'
interface GoogleIdToken {
email?: string
@@ -66,7 +66,7 @@ export async function GET(request: NextRequest) {
} else {
// Create new connection
connections.push({
provider: provider as OAuthProvider,
provider: provider as OAuthService,
featureType,
isConnected: true,
scopes: acc.scope ? acc.scope.split(' ') : [],

View File

@@ -3,9 +3,9 @@ import { and, eq, like } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { getSession } from '@/lib/auth'
import { parseProvider } from '@/lib/oauth'
import { OAuthService } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { OAuthProvider } from '@/tools/types'
interface GoogleIdToken {
email?: string
@@ -27,7 +27,7 @@ export async function GET(request: NextRequest) {
// Get the provider from the query params
const { searchParams } = new URL(request.url)
const provider = searchParams.get('provider') as OAuthProvider | null
const provider = searchParams.get('provider') as OAuthService | null
if (!provider) {
return NextResponse.json({ error: 'Provider is required' }, { status: 400 })

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Key, RefreshCw } from 'lucide-react'
import { GithubIcon, GoogleIcon, xIcon as TwitterIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -14,8 +13,16 @@ import {
} from '@/components/ui/command'
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { client } from '@/lib/auth-client'
import {
Credential,
OAUTH_PROVIDERS,
OAuthProvider,
getProviderIdFromServiceId,
getServiceByProviderAndId,
getServiceIdFromScopes,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthProvider } from '@/tools/types'
interface CredentialSelectorProps {
value: string
@@ -27,14 +34,6 @@ interface CredentialSelectorProps {
serviceId?: string
}
interface Credential {
id: string
name: string
provider: OAuthProvider
lastUsed?: string
isDefault?: boolean
}
export function CredentialSelector({
value,
onChange,
@@ -54,58 +53,13 @@ export function CredentialSelector({
// 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('docs'))) {
return 'google-docs'
} else if (requiredScopes.some((scope) => scope.includes('sheets'))) {
return 'google-sheets'
} 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
return getServiceIdFromScopes(provider, requiredScopes)
}
// 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-sheets':
return 'google-sheets'
case 'google-docs':
return 'google-docs'
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`
}
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
@@ -187,50 +141,53 @@ export function CredentialSelector({
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)
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Get provider icon
const getProviderIcon = (provider: OAuthProvider) => {
const baseProvider = provider.split('-')[0] as OAuthProvider
switch (baseProvider) {
case 'google':
return <GoogleIcon className="h-4 w-4" />
case 'github':
return <GithubIcon className="h-4 w-4" />
case 'twitter':
return <TwitterIcon className="h-4 w-4" />
default:
return <ExternalLink className="h-4 w-4" />
// Handle direct OAuth flow
const handleDirectOAuth = async () => {
try {
const providerId = getProviderId()
// Begin OAuth flow with the appropriate provider
await client.signIn.oauth2({
providerId,
callbackURL: window.location.href,
})
} catch (error) {
console.error('OAuth login error:', error)
}
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const providerConfig = OAUTH_PROVIDERS[providerName]
if (providerConfig) {
return providerConfig.icon({ className: 'h-4 w-4' })
}
return <ExternalLink className="h-4 w-4" />
}
// Get provider name
const getProviderName = (provider: OAuthProvider) => {
switch (provider) {
case 'google':
return 'Google'
case 'google-email':
return 'Gmail'
case 'google-drive':
return 'Google Drive'
case 'google-sheets':
return 'Google Sheets'
case 'google-docs':
return 'Google Docs'
case 'github':
return 'GitHub'
case 'twitter':
return 'X (Twitter)'
default:
return provider
const getProviderName = (providerName: OAuthProvider) => {
const effectiveServiceId = getServiceId()
try {
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
return service.name
} catch (error) {
// Fallback to provider name if service not found
const providerConfig = OAUTH_PROVIDERS[providerName]
if (providerConfig) {
return providerConfig.name
}
return providerName
}
}
@@ -251,7 +208,7 @@ export function CredentialSelector({
<span>{selectedCredential.name}</span>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="flex items-center gap-2">
<Key className="h-4 w-4" />
<span>{label}</span>
</div>
@@ -259,61 +216,65 @@ export function CredentialSelector({
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0">
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder={`Search credentials...`} />
<CommandInput placeholder="Search credentials..." />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className="flex items-center justify-center py-6">
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="ml-2">Loading credentials...</span>
</div>
) : (
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">No credentials found</p>
<div className="p-4 text-center">
<p className="text-sm">No credentials found.</p>
<p className="text-xs text-muted-foreground">
Connect a new account to continue.
</p>
</div>
)}
</CommandEmpty>
{credentials.length > 0 && (
<CommandGroup>
{credentials.map((credential) => (
{credentials.map((cred) => (
<CommandItem
key={credential.id}
value={credential.id}
onSelect={() => handleSelect(credential.id)}
key={cred.id}
value={cred.id}
onSelect={() => handleSelect(cred.id)}
>
<div className="flex items-center gap-2">
{getProviderIcon(credential.provider)}
<span>{credential.name}</span>
{getProviderIcon(cred.provider)}
<span>{cred.name}</span>
</div>
{credential.id === selectedId && <Check className="ml-auto h-4 w-4" />}
{cred.id === selectedId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
<div className="p-2 border-t">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleAddCredential}
>
<span>Add New Credential</span>
</Button>
</div>
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
<ExternalLink className="h-4 w-4" />
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={`${getProviderName(provider)} Integration`}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}

View File

@@ -3,36 +3,20 @@
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Check, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import {
GithubIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleSheetsIcon,
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 { client, useSession } from '@/lib/auth-client'
import { OAUTH_PROVIDERS, OAuthServiceConfig } from '@/lib/oauth'
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
interface ServiceInfo extends OAuthServiceConfig {
isConnected: boolean
scopes: string[]
lastConnected?: string
accounts?: { id: string; name: string }[]
}
@@ -50,133 +34,53 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
const [pendingScopes, setPendingScopes] = useState<string[]>([])
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: <GmailIcon className="h-5 w-5" />,
isConnected: false,
scopes: [],
},
{
id: 'google-drive',
name: 'Google Drive',
description: 'Streamline file organization and document workflows.',
provider: 'google',
providerId: 'google-drive',
icon: <GoogleDriveIcon className="h-5 w-5" />,
isConnected: false,
scopes: [],
},
{
id: 'google-docs',
name: 'Google Docs',
description: 'Create, read, and edit Google Documents programmatically.',
provider: 'google',
providerId: 'google-docs',
icon: <GoogleDocsIcon className="h-5 w-5" />,
isConnected: false,
scopes: [],
},
{
id: 'google-sheets',
name: 'Google Sheets',
description: 'Create, read, and edit Google Sheets programmatically.',
provider: 'google',
providerId: 'google-sheets',
icon: <GoogleSheetsIcon className="h-5 w-5" />,
isConnected: false,
scopes: [],
},
{
id: 'github',
name: 'GitHub',
description: 'Access repositories, issues, and other GitHub features.',
provider: 'github',
providerId: 'github-repo',
icon: <GithubIcon className="h-5 w-5" />,
isConnected: false,
scopes: [],
},
{
id: 'twitter',
name: 'X (Twitter)',
description: 'Read and post tweets, access user data, and more.',
provider: 'twitter',
providerId: 'twitter-read',
icon: <XIcon className="h-5 w-5" />,
isConnected: false,
scopes: [],
},
]
// Define available services from our standardized OAuth providers
const defineServices = (): ServiceInfo[] => {
const servicesList: ServiceInfo[] = []
// Fetch connection status
// Convert our standardized providers to ServiceInfo objects
Object.values(OAUTH_PROVIDERS).forEach((provider) => {
Object.values(provider.services).forEach((service) => {
servicesList.push({
...service,
isConnected: false,
scopes: service.scopes || [],
})
})
})
return servicesList
}
// Fetch services and their connection status
const fetchServices = async () => {
if (!userId) return
setIsLoading(true)
try {
// Get the base services
const baseServices = defineServices()
// Start with the base service definitions
const serviceDefinitions = defineServices()
// Call your API to check connections
// Fetch all OAuth connections for the user
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) => {
// Update services with connection status and account info
const updatedServices = serviceDefinitions.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 === 'google-docs' &&
conn.provider === 'google' &&
conn.featureType === 'docs'
) {
return true
}
if (
service.id === 'google-sheets' &&
conn.provider === 'google' &&
conn.featureType === 'sheets'
) {
return true
}
if (service.id === 'github' && conn.provider === 'github') {
return true
}
if (service.id === 'twitter' && conn.provider === 'twitter') {
return true
}
return false
const [provider, featureType] = conn.provider.split('-')
return service.providerId.startsWith(provider)
})
if (connection) {
return {
...service,
isConnected: connection.accounts?.length > 0,
scopes: connection.scopes || [],
lastConnected: connection.lastConnected,
accounts: connection.accounts || [],
lastConnected: connection.lastConnected,
}
}
@@ -185,24 +89,25 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
setServices(updatedServices)
} else {
// If API fails, set default state
setServices(baseServices)
// If there's an error, just use the base definitions
setServices(serviceDefinitions)
}
} catch (error) {
console.error('Error fetching connections:', error)
// Set default state on error
console.error('Error fetching services:', error)
// Use base definitions on error
setServices(defineServices())
} finally {
setIsLoading(false)
}
}
// Handle OAuth callback
// Check for OAuth callback
useEffect(() => {
// Check if this is an OAuth callback
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
// Handle OAuth callback
if (code && state) {
// This is an OAuth callback - set success flag
setAuthSuccess(true)
@@ -211,8 +116,14 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
if (userId) {
fetchServices()
}
// Clear the URL parameters
router.replace('/w')
} else if (error) {
console.error('OAuth error:', error)
router.replace('/w')
}
}, [searchParams, userId])
}, [searchParams, router, userId])
// Check for pending OAuth connections and return URL
useEffect(() => {
@@ -253,18 +164,23 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
}
}, [authSuccess, onOpenChange, router])
// Fetch connection status on component mount
// Fetch services on mount
useEffect(() => {
fetchServices()
if (userId) {
fetchServices()
}
}, [userId])
// Handle connect button click
const handleConnect = async (service: ServiceInfo) => {
setIsConnecting(service.id)
try {
// Store information about the required connection
saveToStorage('auth_return_url', window.location.href)
saveToStorage('pending_service_id', service.id)
saveToStorage('pending_oauth_provider_id', service.providerId)
setIsConnecting(service.id)
// Store information about the connection
saveToStorage<string>('pending_service_id', service.id)
saveToStorage<string[]>('pending_oauth_scopes', service.scopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', service.providerId)
// Begin OAuth flow with the appropriate provider
await client.signIn.oauth2({
@@ -277,18 +193,18 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
}
}
// Handle disconnect button click
const handleDisconnect = async (service: ServiceInfo, accountId: string) => {
setIsConnecting(`${service.id}-${accountId}`)
try {
// Call your API to disconnect the provider
// Call the API to disconnect the account
const response = await fetch('/api/auth/oauth/disconnect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: service.provider,
providerId: service.providerId,
provider: service.providerId.split('-')[0],
accountId,
}),
})
@@ -307,14 +223,35 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
return svc
})
)
} else {
console.error('Error disconnecting service')
}
} catch (error) {
console.error('Error disconnecting provider:', error)
console.error('Error disconnecting service:', error)
} finally {
setIsConnecting(null)
}
}
// Group services by provider
const groupedServices = services.reduce(
(acc, service) => {
// Find the provider for this service
const providerKey =
Object.keys(OAUTH_PROVIDERS).find((key) =>
Object.keys(OAUTH_PROVIDERS[key].services).includes(service.id)
) || 'other'
if (!acc[providerKey]) {
acc[providerKey] = []
}
acc[providerKey].push(service)
return acc
},
{} as Record<string, ServiceInfo[]>
)
return (
<div className="p-6 space-y-6">
<div>
@@ -324,6 +261,21 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
</p>
</div>
{/* Success message */}
{authSuccess && (
<div className="bg-green-50 border border-green-200 rounded-md p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<Check className="h-5 w-5 text-green-400" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-green-800">Account connected successfully!</p>
</div>
</div>
</div>
)}
{/* Pending service message */}
{pendingService && (
<div className="mb-6 p-4 bg-primary/10 border border-primary rounded-md text-sm flex items-start gap-2">
<div className="min-w-4 mt-0.5">
@@ -337,112 +289,128 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
</div>
)}
<div className="space-y-4">
{isLoading ? (
<>
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</>
) : (
services.map((service) => (
<Card
key={service.id}
className={cn(
'p-6 transition-all hover:shadow-md',
pendingService === service.id && 'border-primary shadow-md'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
{service.icon}
</div>
<div className="space-y-1">
<div>
<h4 className="font-medium leading-none">{service.name}</h4>
<p className="text-sm text-muted-foreground mt-1">{service.description}</p>
</div>
{service.accounts && service.accounts.length > 0 && (
<div className="pt-3 space-y-2">
{service.accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between gap-2 rounded-md border bg-card/50 p-2"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-green-500/10 flex items-center justify-center">
<Check className="h-3 w-3 text-green-600" />
</div>
<span className="text-sm font-medium">{account.name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(service, account.id)}
disabled={isConnecting === `${service.id}-${account.id}`}
className="h-7 px-2"
>
{isConnecting === `${service.id}-${account.id}` ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
'Disconnect'
)}
</Button>
{/* Loading state */}
{isLoading ? (
<div className="space-y-4">
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
) : (
<div className="space-y-6">
{/* Group services by provider */}
{Object.entries(groupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground">
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</h4>
<div className="space-y-4">
{providerServices.map((service) => (
<Card
key={service.id}
className={cn(
'p-6 transition-all hover:shadow-md',
pendingService === service.id && 'border-primary shadow-md'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
{typeof service.icon === 'function'
? service.icon({ className: 'h-5 w-5' })
: service.icon}
</div>
<div className="space-y-1">
<div>
<h4 className="font-medium leading-none">{service.name}</h4>
<p className="text-sm text-muted-foreground mt-1">
{service.description}
</p>
</div>
))}
{service.accounts && service.accounts.length > 0 && (
<div className="pt-3 space-y-2">
{service.accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between gap-2 rounded-md border bg-card/50 p-2"
>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-green-500/10 flex items-center justify-center">
<Check className="h-3 w-3 text-green-600" />
</div>
<span className="text-sm font-medium">{account.name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(service, account.id)}
disabled={isConnecting === `${service.id}-${account.id}`}
className="h-7 px-2"
>
{isConnecting === `${service.id}-${account.id}` ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
'Disconnect'
)}
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="w-full mt-2"
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
>
{isConnecting === service.id ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Connecting...
</>
) : (
<>
<Plus className="h-3 w-3 mr-2" />
Connect Another Account
</>
)}
</Button>
</div>
)}
</div>
</div>
{!service.accounts?.length && (
<Button
variant="outline"
variant="default"
size="sm"
className="w-full mt-2"
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className="shrink-0"
>
{isConnecting === service.id ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Connecting...
</>
) : (
<>
<Plus className="h-3 w-3 mr-2" />
Connect Another Account
</>
'Connect'
)}
</Button>
</div>
)}
</div>
</div>
{!service.accounts?.length && (
<Button
variant="default"
size="sm"
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className="shrink-0"
>
{isConnecting === service.id ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Connecting...
</>
) : (
'Connect'
)}
</Button>
)}
)}
</div>
</Card>
))}
</div>
</Card>
))
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
// Loading skeleton for connections
function ConnectionSkeleton() {
return (
<Card className="p-6">

View File

@@ -1589,8 +1589,8 @@ export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) {
<path
d="M204 229a25 22 1 1 1 25 27h-9h9a25 22 1 1 1-25 27M270 231l27-19h4v-7V308"
stroke="#4285f4"
stroke-width="15"
stroke-linejoin="bevel"
strokeWidth="15"
strokeLinejoin="bevel"
fill="none"
/>
</svg>

View File

@@ -1,15 +1,6 @@
'use client'
import { Check } from 'lucide-react'
import {
GithubIcon,
GmailIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleIcon,
GoogleSheetsIcon,
xIcon as XIcon,
} from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -19,8 +10,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { client } from '@/lib/auth-client'
import {
OAUTH_PROVIDERS,
OAuthProvider,
getProviderIdFromServiceId,
getServiceIdFromScopes,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthProvider } from '@/tools/types'
export interface OAuthRequiredModalProps {
isOpen: boolean
@@ -31,28 +28,6 @@ export interface OAuthRequiredModalProps {
serviceId?: string
}
// Map of provider names to friendly display names
const PROVIDER_NAMES: Record<OAuthProvider, string> = {
github: 'GitHub',
google: 'Google',
'google-email': 'Gmail',
'google-drive': 'Google Drive',
'google-docs': 'Google Docs',
'google-sheets': 'Google Sheets',
twitter: 'X (Twitter)',
}
// Map of provider to icons
const PROVIDER_ICONS: Record<OAuthProvider, React.FC<React.SVGProps<SVGSVGElement>>> = {
github: GithubIcon,
google: GoogleIcon,
'google-email': GmailIcon,
'google-drive': GoogleDriveIcon,
'google-docs': GoogleDocsIcon,
'google-sheets': GoogleSheetsIcon,
twitter: XIcon,
}
// Map of OAuth scopes to user-friendly descriptions
const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/gmail.send': 'Send emails on your behalf',
@@ -85,8 +60,10 @@ export function OAuthRequiredModal({
requiredScopes = [],
serviceId,
}: OAuthRequiredModalProps) {
const providerName = PROVIDER_NAMES[provider] || provider
const ProviderIcon = PROVIDER_ICONS[provider]
// Get provider configuration
const providerConfig = OAUTH_PROVIDERS[provider]
const providerName = providerConfig?.name || provider
const ProviderIcon = providerConfig?.icon || (() => null)
// Filter out userinfo scopes as they're not relevant to show to users
const displayScopes = requiredScopes.filter(
@@ -95,81 +72,15 @@ export function OAuthRequiredModal({
const handleRedirectToSettings = () => {
try {
// Determine the appropriate providerId and serviceId based on the provider and required scopes
let providerId: string
let effectiveServiceId = serviceId
// 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('docs'))) {
effectiveServiceId = 'google-docs'
providerId = 'google-docs'
} else if (requiredScopes.some((scope) => scope.includes('sheets'))) {
effectiveServiceId = 'google-sheets'
providerId = 'google-sheets'
} 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 'google-sheets':
providerId = 'google-sheets'
break
case 'google-docs':
providerId = 'google-docs'
break
case 'github':
providerId = 'github-repo'
break
case 'twitter':
providerId = 'twitter-read'
break
default:
providerId = `${provider}-default`
}
}
// Determine the appropriate serviceId and providerId
const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes)
const providerId = getProviderIdFromServiceId(effectiveServiceId)
// 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)
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Close the modal
onClose()
@@ -184,6 +95,31 @@ export function OAuthRequiredModal({
}
}
const handleConnectDirectly = async () => {
try {
// Determine the appropriate serviceId and providerId
const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes)
const providerId = getProviderIdFromServiceId(effectiveServiceId)
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Close the modal
onClose()
// Begin OAuth flow with the appropriate provider
await client.signIn.oauth2({
providerId,
callbackURL: window.location.href,
})
} catch (error) {
console.error('Error initiating OAuth flow:', error)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
@@ -202,7 +138,7 @@ export function OAuthRequiredModal({
<div className="flex-1">
<p className="text-sm font-medium">Connect {providerName}</p>
<p className="text-sm text-muted-foreground">
You need to connect your {providerName} account in settings
You need to connect your {providerName} account to continue
</p>
</div>
</div>
@@ -225,11 +161,19 @@ export function OAuthRequiredModal({
</div>
)}
</div>
<DialogFooter className="flex space-x-2 sm:justify-end">
<Button variant="outline" onClick={onClose}>
<DialogFooter className="flex flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={onClose} className="sm:order-1">
Cancel
</Button>
<Button type="button" onClick={handleRedirectToSettings}>
<Button type="button" onClick={handleConnectDirectly} className="sm:order-3">
Connect Now
</Button>
<Button
type="button"
variant="secondary"
onClick={handleRedirectToSettings}
className="sm:order-2"
>
Go to Settings
</Button>
</DialogFooter>

View File

@@ -1,6 +1,213 @@
import { OAuthProvider } from '@/tools/types'
import { ReactNode } from 'react'
import {
GithubIcon,
GmailIcon,
GoogleCalendarIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleIcon,
GoogleSheetsIcon,
xIcon as TwitterIcon,
} from '@/components/icons'
interface ProviderConfig {
// Define the base OAuth provider type
export type OAuthProvider = 'google' | 'github' | 'twitter' | string
export type OAuthService =
| 'google'
| 'google-email'
| 'google-drive'
| 'google-docs'
| 'google-sheets'
| 'github'
| 'twitter'
// Define the interface for OAuth provider configuration
export interface OAuthProviderConfig {
id: OAuthProvider
name: string
icon: (props: { className?: string }) => ReactNode
services: Record<string, OAuthServiceConfig>
defaultService: string
}
// Define the interface for OAuth service configuration
export interface OAuthServiceConfig {
id: string
name: string
description: string
providerId: string
icon: (props: { className?: string }) => ReactNode
scopes: string[]
}
// Define the available OAuth providers
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
google: {
id: 'google',
name: 'Google',
icon: (props) => GoogleIcon(props),
services: {
gmail: {
id: 'gmail',
name: 'Gmail',
description: 'Automate email workflows and enhance communication efficiency.',
providerId: 'google-email',
icon: (props) => GmailIcon(props),
scopes: [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.readonly',
],
},
'google-drive': {
id: 'google-drive',
name: 'Google Drive',
description: 'Streamline file organization and document workflows.',
providerId: 'google-drive',
icon: (props) => GoogleDriveIcon(props),
scopes: ['https://www.googleapis.com/auth/drive'],
},
'google-docs': {
id: 'google-docs',
name: 'Google Docs',
description: 'Create, read, and edit Google Documents programmatically.',
providerId: 'google-docs',
icon: (props) => GoogleDocsIcon(props),
scopes: ['https://www.googleapis.com/auth/documents'],
},
'google-sheets': {
id: 'google-sheets',
name: 'Google Sheets',
description: 'Manage and analyze data with Google Sheets integration.',
providerId: 'google-sheets',
icon: (props) => GoogleSheetsIcon(props),
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
},
'google-calendar': {
id: 'google-calendar',
name: 'Google Calendar',
description: 'Schedule and manage events with Google Calendar.',
providerId: 'google-calendar',
icon: (props) => GoogleCalendarIcon(props),
scopes: ['https://www.googleapis.com/auth/calendar'],
},
},
defaultService: 'gmail',
},
github: {
id: 'github',
name: 'GitHub',
icon: (props) => GithubIcon(props),
services: {
github: {
id: 'github',
name: 'GitHub',
description: 'Manage repositories, issues, and pull requests.',
providerId: 'github-repo',
icon: (props) => GithubIcon(props),
scopes: ['repo', 'user'],
},
'github-workflow': {
id: 'github-workflow',
name: 'GitHub Actions',
description: 'Trigger and manage GitHub Actions workflows.',
providerId: 'github-workflow',
icon: (props) => GithubIcon(props),
scopes: ['repo', 'workflow'],
},
},
defaultService: 'github',
},
twitter: {
id: 'twitter',
name: 'Twitter',
icon: (props) => TwitterIcon(props),
services: {
twitter: {
id: 'twitter',
name: 'Twitter',
description: 'Post tweets and interact with the Twitter API.',
providerId: 'twitter',
icon: (props) => TwitterIcon(props),
scopes: ['tweet.read', 'tweet.write', 'users.read'],
},
},
defaultService: 'twitter',
},
}
// Helper function to get a service by provider and service ID
export function getServiceByProviderAndId(
provider: OAuthProvider,
serviceId?: string
): OAuthServiceConfig {
const providerConfig = OAUTH_PROVIDERS[provider]
if (!providerConfig) {
throw new Error(`Provider ${provider} not found`)
}
if (!serviceId) {
return providerConfig.services[providerConfig.defaultService]
}
return (
providerConfig.services[serviceId] || providerConfig.services[providerConfig.defaultService]
)
}
// Helper function to determine service ID from scopes
export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]): string {
const providerConfig = OAUTH_PROVIDERS[provider]
if (!providerConfig) {
return provider
}
if (provider === 'google') {
if (scopes.some((scope) => scope.includes('gmail') || scope.includes('mail'))) {
return 'gmail'
} else if (scopes.some((scope) => scope.includes('drive'))) {
return 'google-drive'
} else if (scopes.some((scope) => scope.includes('docs'))) {
return 'google-docs'
} else if (scopes.some((scope) => scope.includes('sheets'))) {
return 'google-sheets'
} else if (scopes.some((scope) => scope.includes('calendar'))) {
return 'google-calendar'
}
} else if (provider === 'github') {
if (scopes.some((scope) => scope.includes('workflow'))) {
return 'github-workflow'
}
}
return providerConfig.defaultService
}
// Helper function to get provider ID from service ID
export function getProviderIdFromServiceId(serviceId: string): string {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
for (const [id, service] of Object.entries(provider.services)) {
if (id === serviceId) {
return service.providerId
}
}
}
// Default fallback
return serviceId
}
// Interface for credential objects
export interface Credential {
id: string
name: string
provider: OAuthProvider
serviceId?: string
lastUsed?: string
isDefault?: boolean
}
// Interface for provider configuration
export interface ProviderConfig {
baseProvider: string
featureType: string
}

View File

@@ -1,12 +1,6 @@
import { OAuthService } from '@/lib/oauth'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
export type OAuthProvider =
| 'google'
| 'google-email'
| 'google-drive'
| 'google-docs'
| 'google-sheets'
| 'github'
| 'twitter'
export interface ToolResponse {
success: boolean // Whether the tool execution was successful
@@ -16,7 +10,7 @@ export interface ToolResponse {
export interface OAuthConfig {
required: boolean // Whether this tool requires OAuth authentication
provider: OAuthProvider // The provider that needs to be authorized
provider: OAuthService // The service that needs to be authorized
additionalScopes?: string[] // Additional scopes required for the tool
}