mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-25 09:34:44 +08:00
588 lines
21 KiB
TypeScript
588 lines
21 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { Check, ChevronDown, ExternalLink, FileIcon, RefreshCw, Search, X } from 'lucide-react'
|
|
import { GoogleDocsIcon, GoogleSheetsIcon } 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 {
|
|
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 [isLoadingSelectedFile, setIsLoadingSelectedFile] = 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 a single file by ID when we have a selectedFileId but no metadata
|
|
const fetchFileById = useCallback(
|
|
async (fileId: string) => {
|
|
if (!selectedCredentialId || !fileId) return null
|
|
|
|
setIsLoadingSelectedFile(true)
|
|
try {
|
|
// Construct query parameters
|
|
const queryParams = new URLSearchParams({
|
|
credentialId: selectedCredentialId,
|
|
fileId: fileId,
|
|
})
|
|
|
|
const response = await fetch(`/api/auth/oauth/drive/file?${queryParams.toString()}`)
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
if (data.file) {
|
|
setSelectedFile(data.file)
|
|
onFileInfoChange?.(data.file)
|
|
return data.file
|
|
}
|
|
} else {
|
|
console.error('Error fetching file by ID:', await response.text())
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
console.error('Error fetching file by ID:', error)
|
|
return null
|
|
} finally {
|
|
setIsLoadingSelectedFile(false)
|
|
}
|
|
},
|
|
[selectedCredentialId, onFileInfoChange]
|
|
)
|
|
|
|
// 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 try to fetch by ID if this is not a search query
|
|
// and we couldn't find the file in the list
|
|
fetchFileById(selectedFileId)
|
|
}
|
|
}
|
|
} 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, fetchFileById]
|
|
)
|
|
|
|
// 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)
|
|
} else if (value && selectedCredentialId) {
|
|
// If we have a value but no files loaded yet, try to fetch the file by ID
|
|
fetchFileById(value)
|
|
}
|
|
}
|
|
}, [value, files, onFileInfoChange, selectedCredentialId, fetchFileById])
|
|
|
|
// Try to fetch the file by ID when credentials become available
|
|
useEffect(() => {
|
|
if (selectedCredentialId && selectedFileId && !selectedFile) {
|
|
fetchFileById(selectedFileId)
|
|
}
|
|
}, [selectedCredentialId, selectedFileId, selectedFile, fetchFileById])
|
|
|
|
// 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} />
|
|
} else if (file.mimeType === 'application/vnd.google-apps.document') {
|
|
return <GoogleDocsIcon 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>
|
|
) : selectedFileId && (isLoadingSelectedFile || !selectedCredentialId) ? (
|
|
<div className="flex items-center gap-2">
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
<span className="text-muted-foreground">Loading document...</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()}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|