From 25cf7a55fcf6e7163adfc19e488cf5de3123ebcd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Mar 2025 01:24:29 -0700 Subject: [PATCH] feat: added file selector for google drive --- app/api/auth/oauth/drive/files/route.ts | 96 ++++ app/api/auth/oauth/token/route.ts | 47 ++ .../components/file-selector-input.tsx | 48 ++ .../components/sub-block/sub-block.tsx | 3 + blocks/blocks/sheets.ts | 27 +- blocks/types.ts | 3 + components/ui/file-selector.tsx | 534 ++++++++++++++++++ 7 files changed, 754 insertions(+), 4 deletions(-) create mode 100644 app/api/auth/oauth/drive/files/route.ts create mode 100644 app/w/[id]/components/workflow-block/components/sub-block/components/file-selector-input.tsx create mode 100644 components/ui/file-selector.tsx diff --git a/app/api/auth/oauth/drive/files/route.ts b/app/api/auth/oauth/drive/files/route.ts new file mode 100644 index 000000000..131824d08 --- /dev/null +++ b/app/api/auth/oauth/drive/files/route.ts @@ -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 }) + } +} diff --git a/app/api/auth/oauth/token/route.ts b/app/api/auth/oauth/token/route.ts index 261205b18..7890710d2 100644 --- a/app/api/auth/oauth/token/route.ts +++ b/app/api/auth/oauth/token/route.ts @@ -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 }) + } +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector-input.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector-input.tsx new file mode 100644 index 000000000..47df394f3 --- /dev/null +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector-input.tsx @@ -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('') + const [fileInfo, setFileInfo] = useState(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 ( + + ) +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index 7732540f0..d4ddac6bf 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -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 default: return null } diff --git a/blocks/blocks/sheets.ts b/blocks/blocks/sheets.ts index 59d0ee5c2..d493581fe 100644 --- a/blocks/blocks/sheets.ts +++ b/blocks/blocks/sheets.ts @@ -44,14 +44,28 @@ export const GoogleSheetsBlock: BlockConfig = { 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 = { } }, 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 = { 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 }, diff --git a/blocks/types.ts b/blocks/types.ts index 273ee5d4e..f03085c88 100644 --- a/blocks/types.ts +++ b/blocks/types.ts @@ -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 diff --git a/components/ui/file-selector.tsx b/components/ui/file-selector.tsx new file mode 100644 index 000000000..07ea41638 --- /dev/null +++ b/components/ui/file-selector.tsx @@ -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([]) + const [files, setFiles] = useState([]) + const [selectedCredentialId, setSelectedCredentialId] = useState('') + const [selectedFileId, setSelectedFileId] = useState(value) + const [selectedFile, setSelectedFile] = useState(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('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 = (providerName: OAuthProvider) => { + const { baseProvider } = parseProvider(providerName) + const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] + + if (!baseProviderConfig) { + return + } + + // 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 + } + return + } + + return ( + <> +
+ + + + + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ {getProviderIcon(provider)} + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + + )} +
+ )} + + + + + + {isLoading ? ( +
+ + Loading files... +
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a {getProviderName(provider)} account to continue. +

+
+ ) : ( +
+

No files found.

+

+ Try a different search or account. +

+
+ )} +
+ + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ {getProviderIcon(cred.provider)} + {cred.name} +
+ {cred.id === selectedCredentialId && } +
+ ))} +
+ )} + + {/* Files list */} + {files.length > 0 && ( + +
+ Files +
+ {files.map((file) => ( + handleSelectFile(file)} + > +
+ {getFileIcon(file, 'sm')} + {file.name} +
+ {file.id === selectedFileId && } +
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ {getProviderIcon(provider)} + Connect {getProviderName(provider)} account +
+
+
+ )} + + {/* Add another account option */} + {credentials.length > 0 && ( + + +
+ Connect Another Account +
+
+
+ )} +
+
+
+
+ + {/* File preview */} + {showPreview && selectedFile && ( +
+
+ +
+
+
+ {getFileIcon(selectedFile, 'sm')} +
+
+
+

{selectedFile.name}

+ {selectedFile.modifiedTime && ( + + {new Date(selectedFile.modifiedTime).toLocaleDateString()} + + )} +
+ {selectedFile.webViewLink ? ( + e.stopPropagation()} + > + Open in Drive + + + ) : ( + e.stopPropagation()} + > + Open in Drive + + + )} +
+
+
+ )} +
+ + {showOAuthModal && ( + setShowOAuthModal(false)} + provider={provider} + toolName={getProviderName(provider)} + requiredScopes={requiredScopes} + serviceId={getServiceId()} + /> + )} + + ) +}