feat: added file selector for google drive

This commit is contained in:
Waleed Latif
2025-03-10 01:24:29 -07:00
parent 7db6f8b273
commit 25cf7a55fc
7 changed files with 754 additions and 4 deletions

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { account } from '@/db/schema'
/**
* Get files from Google Drive
*/
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 credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType')
const query = searchParams.get('query') || ''
if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Check if the access token is valid
if (!credential.accessToken) {
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}
// Build the query parameters for Google Drive API
let queryParams = 'trashed=false'
// Add mimeType filter if provided
if (mimeType) {
queryParams += `&mimeType='${mimeType}'`
}
// Add search query if provided
if (query) {
queryParams += `&q=name contains '${query}'`
}
// Fetch files from Google Drive
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
{
headers: {
Authorization: `Bearer ${credential.accessToken}`,
},
}
)
if (!response.ok) {
const error = await response.json()
return NextResponse.json(
{ error: error.error?.message || 'Failed to fetch files from Google Drive' },
{ status: response.status }
)
}
const data = await response.json()
// Filter for Google Sheets files if mimeType is for spreadsheets
let files = data.files || []
if (mimeType === 'application/vnd.google-apps.spreadsheet') {
files = files.filter(
(file: any) => file.mimeType === 'application/vnd.google-apps.spreadsheet'
)
}
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
console.error('Error fetching files from Google Drive:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { client } from '@/lib/auth-client'
import { db } from '@/db'
import { account, workflow } from '@/db/schema'
@@ -153,3 +154,49 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* Get an OAuth token for a given credential ID
*/
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 credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the access token is valid
if (!credential.accessToken) {
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}
return NextResponse.json({ accessToken: credential.accessToken }, { status: 200 })
} catch (error) {
console.error('Error getting OAuth token:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,48 @@
'use client'
import { useEffect, useState } from 'react'
import { FileInfo, FileSelector } from '@/components/ui/file-selector'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { SubBlockConfig } from '@/blocks/types'
interface FileSelectorInputProps {
blockId: string
subBlock: SubBlockConfig
disabled?: boolean
}
export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const [selectedFileId, setSelectedFileId] = useState<string>('')
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null)
// Get the current value from the store
useEffect(() => {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedFileId(value)
}
}, [blockId, subBlock.id, getValue])
// Handle file selection
const handleFileChange = (fileId: string, info?: FileInfo) => {
setSelectedFileId(fileId)
setFileInfo(info || null)
setValue(blockId, subBlock.id, fileId)
}
return (
<FileSelector
value={selectedFileId}
onChange={handleFileChange}
provider={subBlock.provider || 'google-drive'}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={disabled}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
onFileInfoChange={setFileInfo}
/>
)
}

View File

@@ -10,6 +10,7 @@ import { CredentialSelector } from './components/credential-selector'
import { DateInput } from './components/date-input'
import { Dropdown } from './components/dropdown'
import { EvalInput } from './components/eval-input'
import { FileSelectorInput } from './components/file-selector-input'
import { LongInput } from './components/long-input'
import { ShortInput } from './components/short-input'
import { SliderInput } from './components/slider-input'
@@ -147,6 +148,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
serviceId={config.serviceId}
/>
)
case 'file-selector':
return <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
default:
return null
}

View File

@@ -44,14 +44,28 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
requiredScopes: ['https://www.googleapis.com/auth/spreadsheets'],
placeholder: 'Select Google Sheets account',
},
// Common Fields
// Spreadsheet Selector
{
id: 'spreadsheetId',
title: 'Spreadsheet ID',
title: 'Select Sheet',
type: 'file-selector',
layout: 'full',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'],
mimeType: 'application/vnd.google-apps.spreadsheet',
placeholder: 'Select a spreadsheet',
},
// Manual Spreadsheet ID (hidden by default)
{
id: 'manualSpreadsheetId',
title: 'Or Enter Spreadsheet ID Manually',
type: 'short-input',
layout: 'full',
placeholder: 'ID of the spreadsheet (from URL)',
condition: { field: 'spreadsheetId', value: '' },
},
// Range
{
id: 'range',
title: 'Range',
@@ -116,13 +130,17 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
}
},
params: (params) => {
const { credential, values, ...rest } = params
const { credential, values, spreadsheetId, manualSpreadsheetId, ...rest } = params
// Parse values from JSON string to array if it exists
const parsedValues = values ? JSON.parse(values as string) : undefined
// Use the selected spreadsheet ID or the manually entered one
const effectiveSpreadsheetId = spreadsheetId || manualSpreadsheetId
return {
...rest,
spreadsheetId: effectiveSpreadsheetId,
values: parsedValues,
credential,
}
@@ -132,7 +150,8 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
inputs: {
operation: { type: 'string', required: true },
credential: { type: 'string', required: true },
spreadsheetId: { type: 'string', required: true },
spreadsheetId: { type: 'string', required: false },
manualSpreadsheetId: { type: 'string', required: false },
range: { type: 'string', required: false },
// Write/Update operation inputs
values: { type: 'string', required: false },

View File

@@ -27,6 +27,7 @@ export type SubBlockType =
| 'time-input' // Time input
| 'oauth-input' // OAuth credential selector
| 'webhook-config' // Webhook configuration
| 'file-selector' // File selector for Google Drive, etc.
// Component width setting
export type SubBlockLayout = 'full' | 'half'
@@ -102,6 +103,8 @@ export interface SubBlockConfig {
provider?: string
serviceId?: string
requiredScopes?: string[]
// File selector specific properties
mimeType?: string
}
// Main block definition

View File

@@ -0,0 +1,534 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, FileIcon, RefreshCw, Search, X } from 'lucide-react'
import { GoogleSheetsIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Credential,
OAUTH_PROVIDERS,
OAuthProvider,
getProviderIdFromServiceId,
getServiceByProviderAndId,
getServiceIdFromScopes,
parseProvider,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
export interface FileInfo {
id: string
name: string
mimeType: string
iconLink?: string
webViewLink?: string
thumbnailLink?: string
createdTime?: string
modifiedTime?: string
size?: string
owners?: { displayName: string; emailAddress: string }[]
}
interface FileSelectorProps {
value: string
onChange: (value: string, fileInfo?: FileInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
mimeTypeFilter?: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: FileInfo | null) => void
}
export function FileSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select file',
disabled = false,
serviceId,
mimeTypeFilter,
showPreview = true,
onFileInfoChange,
}: FileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [files, setFiles] = useState<FileInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
// If we already have a selected credential ID, check if it's valid
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
// Otherwise, select the default or first credential
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedCredentialId(defaultCred.id)
} else if (data.credentials.length === 1) {
setSelectedCredentialId(data.credentials[0].id)
}
}
}
}
} catch (error) {
console.error('Error fetching credentials:', error)
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch files from Google Drive
const fetchFiles = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId) return
setIsLoading(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
})
if (mimeTypeFilter) {
queryParams.append('mimeType', mimeTypeFilter)
}
if (searchQuery) {
queryParams.append('query', searchQuery)
}
const response = await fetch(`/api/auth/oauth/drive/files?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
setFiles(data.files || [])
// If we have a selected file ID, find the file info
if (selectedFileId) {
const fileInfo = data.files.find((file: FileInfo) => file.id === selectedFileId)
if (fileInfo) {
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
} else if (!searchQuery) {
// Only reset if this is not a search query
setSelectedFile(null)
onFileInfoChange?.(null)
}
}
} else {
console.error('Error fetching files:', await response.text())
setFiles([])
}
} catch (error) {
console.error('Error fetching files:', error)
setFiles([])
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, mimeTypeFilter, selectedFileId, onFileInfoChange]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Fetch files when credential is selected
useEffect(() => {
if (selectedCredentialId) {
fetchFiles()
}
}, [selectedCredentialId, fetchFiles])
// Update selected file when value changes externally
useEffect(() => {
if (value !== selectedFileId) {
setSelectedFileId(value)
// Find file info if we have files loaded
if (files.length > 0) {
const fileInfo = files.find((file) => file.id === value) || null
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
}
}
}, [value, files, onFileInfoChange])
// Handle file selection
const handleSelectFile = (file: FileInfo) => {
setSelectedFileId(file.id)
setSelectedFile(file)
onChange(file.id, file)
onFileInfoChange?.(file)
setOpen(false)
}
// Handle adding a new credential
const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// 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)
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className="h-4 w-4" />
}
// For compound providers, find the specific service
if (providerName.includes('-')) {
for (const service of Object.values(baseProviderConfig.services)) {
if (service.providerId === providerName) {
return service.icon({ className: 'h-4 w-4' })
}
}
}
// Fallback to base provider icon
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
// Get provider name
const getProviderName = (providerName: OAuthProvider) => {
const effectiveServiceId = getServiceId()
try {
// First try to get the service by provider and service ID
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
return service.name
} catch (error) {
// If that fails, try to get the service by parsing the provider
try {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
// For compound providers like 'google-drive', try to find the specific service
if (providerName.includes('-')) {
const serviceKey = providerName.split('-')[1] || ''
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
if (key === serviceKey || key === providerName || service.providerId === providerName) {
return service.name
}
}
}
// Fallback to provider name if service not found
if (baseProviderConfig) {
return baseProviderConfig.name
}
} catch (parseError) {
// Ignore parse error and continue to final fallback
}
// Final fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
// Handle search
const handleSearch = (value: string) => {
if (value.length > 2) {
fetchFiles(value)
} else if (value.length === 0) {
fetchFiles()
}
}
// Get file icon based on mime type
const getFileIcon = (file: FileInfo, size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
if (file.mimeType === 'application/vnd.google-apps.spreadsheet') {
return <GoogleSheetsIcon className={iconSize} />
}
return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
return (
<>
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedFile ? (
<div className="flex items-center gap-2 overflow-hidden">
{getFileIcon(selectedFile, 'sm')}
<span className="font-normal truncate">{selectedFile.name}</span>
</div>
) : (
<div className="flex items-center gap-2">
{getProviderIcon(provider)}
<span className="text-muted-foreground">{label}</span>
</div>
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className="px-3 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
{getProviderIcon(provider)}
<span className="text-xs text-muted-foreground">
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder="Search files..." onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="ml-2">Loading files...</span>
</div>
) : credentials.length === 0 ? (
<div className="p-4 text-center">
<p className="text-sm font-medium">No accounts connected.</p>
<p className="text-xs text-muted-foreground">
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : (
<div className="p-4 text-center">
<p className="text-sm font-medium">No files found.</p>
<p className="text-xs text-muted-foreground">
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className="flex items-center gap-2">
{getProviderIcon(cred.provider)}
<span className="font-normal">{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Files
</div>
{files.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleSelectFile(file)}
>
<div className="flex items-center gap-2 overflow-hidden">
{getFileIcon(file, 'sm')}
<span className="font-normal truncate">{file.name}</span>
</div>
{file.id === selectedFileId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
{/* Add another account option */}
{credentials.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
<span>Connect Another Account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-muted"
onClick={handleClearSelection}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-3 pr-4">
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded">
{getFileIcon(selectedFile, 'sm')}
</div>
<div className="overflow-hidden flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-xs font-medium truncate">{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink ? (
<a
href={selectedFile.webViewLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<span>Open in Drive</span>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<a
href={`https://drive.google.com/file/d/${selectedFile.id}/view`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<span>Open in Drive</span>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}