improvement: use google file/folder picker instead of custom picker to remove requirement of restricted scopes. co-localize many input components and move them out of general components/ui/

This commit is contained in:
Waleed Latif
2025-03-16 13:56:29 -07:00
parent bdf123c2bd
commit aebc8e9578
18 changed files with 227 additions and 177 deletions
+4 -1
View File
@@ -9,4 +9,7 @@ BETTER_AUTH_URL=http://localhost:3000
RESEND_API_KEY=your_resend_api_key # Get from https://resend.com
# App URL (Required)
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
# StackBlitz (Webcontainer) API Key (Optional, for handling sandboxed code execution for functions/custom-tools)
WEBCONTAINER_CLIENT_ID=your_webcontainer_client_id
+53 -8
View File
@@ -113,15 +113,18 @@ export async function POST(request: NextRequest) {
}
/**
* Get an OAuth token for a given credential ID
* Get the access token for a specific credential
*/
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8) // Short request ID for correlation
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
@@ -130,30 +133,72 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
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) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Check if the access token is valid
if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`)
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
}
return NextResponse.json({ accessToken: credential.accessToken }, { status: 200 })
// Check if the token is expired and refresh if needed
const now = new Date()
const tokenExpiry = credential.accessTokenExpiresAt
let accessToken = credential.accessToken
if (tokenExpiry && tokenExpiry < now && credential.refreshToken) {
logger.info(`[${requestId}] Access token expired, attempting to refresh`)
try {
// Refresh the token using the centralized utility
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (refreshedToken) {
logger.info(`[${requestId}] Token refreshed successfully`)
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
accessToken = refreshedToken
}
} catch (refreshError) {
logger.error(`[${requestId}] Error refreshing token`, refreshError)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
}
// Return the access token
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
logger.error('Error getting OAuth token:', { error })
logger.error(`[${requestId}] Error fetching access token`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
+2 -2
View File
@@ -52,7 +52,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
layout: 'full',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'],
requiredScopes: [],
mimeType: 'application/vnd.google-apps.document',
placeholder: 'Select a document',
condition: { field: 'operation', value: 'read' },
@@ -65,7 +65,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
layout: 'full',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'],
requiredScopes: [],
mimeType: 'application/vnd.google-apps.document',
placeholder: 'Select a document',
condition: { field: 'operation', value: 'write' },
+1 -1
View File
@@ -52,7 +52,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
layout: 'full',
provider: 'google-drive',
serviceId: 'google-drive',
requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'],
requiredScopes: [],
mimeType: 'application/vnd.google-apps.spreadsheet',
placeholder: 'Select a spreadsheet',
},
+1 -4
View File
@@ -9,10 +9,7 @@ export const readTool: ToolConfig<GoogleDocsToolParams, GoogleDocsReadResponse>
oauth: {
required: true,
provider: 'google-docs',
additionalScopes: [
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/drive.readonly',
],
additionalScopes: ['https://www.googleapis.com/auth/documents'],
},
params: {
accessToken: { type: 'string', required: true },
+1 -4
View File
@@ -9,10 +9,7 @@ export const readTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsReadRespon
oauth: {
required: true,
provider: 'google-sheets',
additionalScopes: [
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.readonly',
],
additionalScopes: ['https://www.googleapis.com/auth/spreadsheets'],
},
params: {
accessToken: { type: 'string', required: true },
@@ -11,7 +11,6 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command'
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console-logger'
import {
@@ -23,6 +22,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../credential-selector/components/oauth-required-modal'
const logger = createLogger('CredentialSelector')
@@ -1,18 +1,17 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, FileIcon, RefreshCw, Search, X } from 'lucide-react'
import { Check, ChevronDown, ExternalLink, FileIcon, RefreshCw, X } from 'lucide-react'
import useDrivePicker from 'react-google-drive-picker'
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 { createLogger } from '@/lib/logs/console-logger'
import {
@@ -25,8 +24,9 @@ import {
parseProvider,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = createLogger('FileSelector')
const logger = createLogger('GoogleDrivePicker')
export interface FileInfo {
id: string
@@ -41,7 +41,7 @@ export interface FileInfo {
owners?: { displayName: string; emailAddress: string }[]
}
interface FileSelectorProps {
interface GoogleDrivePickerProps {
value: string
onChange: (value: string, fileInfo?: FileInfo) => void
provider: OAuthProvider
@@ -52,9 +52,11 @@ interface FileSelectorProps {
mimeTypeFilter?: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: FileInfo | null) => void
clientId: string
apiKey: string
}
export function FileSelector({
export function GoogleDrivePicker({
value,
onChange,
provider,
@@ -65,10 +67,11 @@ export function FileSelector({
mimeTypeFilter,
showPreview = true,
onFileInfoChange,
}: FileSelectorProps) {
clientId,
apiKey,
}: GoogleDrivePickerProps) {
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)
@@ -76,6 +79,7 @@ export function FileSelector({
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [openPicker, authResponse] = useDrivePicker()
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
@@ -162,58 +166,6 @@ export function FileSelector({
[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 {
logger.error('Error fetching files:', { error: await response.text() })
setFiles([])
}
} catch (error) {
logger.error('Error fetching files:', { error })
setFiles([])
} finally {
setIsLoading(false)
}
},
[selectedCredentialId, mimeTypeFilter, selectedFileId, onFileInfoChange, fetchFileById]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
@@ -222,44 +174,101 @@ export function FileSelector({
}
}, [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
// If we have a value but no file info, try to fetch it
if (value && selectedCredentialId && !selectedFile) {
fetchFileById(value)
}
}
}, [value, files, onFileInfoChange, selectedCredentialId, fetchFileById])
}, [value, selectedCredentialId, selectedFile, fetchFileById])
// Try to fetch the file by ID when credentials become available
useEffect(() => {
if (selectedCredentialId && selectedFileId && !selectedFile) {
fetchFileById(selectedFileId)
// Fetch the access token for the selected credential
const fetchAccessToken = async (): Promise<string | null> => {
if (!selectedCredentialId) {
logger.error('No credential ID selected for Google Drive Picker')
return null
}
}, [selectedCredentialId, selectedFileId, selectedFile, fetchFileById])
// Handle file selection
const handleSelectFile = (file: FileInfo) => {
setSelectedFileId(file.id)
setSelectedFile(file)
onChange(file.id, file)
onFileInfoChange?.(file)
setOpen(false)
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/token?credentialId=${selectedCredentialId}`)
if (!response.ok) {
throw new Error(`Failed to fetch access token: ${response.status}`)
}
const data = await response.json()
return data.accessToken || null
} catch (error) {
logger.error('Error fetching access token:', { error })
return null
} finally {
setIsLoading(false)
}
}
// Handle opening the Google Drive Picker
const handleOpenPicker = async () => {
try {
// First, get the access token for the selected credential
const accessToken = await fetchAccessToken()
if (!accessToken) {
logger.error('Failed to get access token for Google Drive Picker')
return
}
const viewIdForMimeType = () => {
// Return appropriate view based on mime type filter
if (mimeTypeFilter?.includes('spreadsheet')) {
return 'SPREADSHEETS'
} else if (mimeTypeFilter?.includes('document')) {
return 'DOCUMENTS'
}
return 'DOCS' // Default view
}
openPicker({
clientId,
developerKey: apiKey,
viewId: viewIdForMimeType(),
token: accessToken, // Use the fetched access token
showUploadView: true,
showUploadFolders: true,
supportDrives: true,
multiselect: false,
callbackFunction: (data) => {
if (data.action === 'picked') {
const file = data.docs[0]
if (file) {
const fileInfo: FileInfo = {
id: file.id,
name: file.name,
mimeType: file.mimeType,
iconLink: file.iconUrl,
webViewLink: file.url,
// thumbnailLink is not directly available from the picker
thumbnailLink: file.iconUrl, // Use iconUrl as fallback
modifiedTime: file.lastEditedUtc
? new Date(file.lastEditedUtc).toISOString()
: undefined,
}
setSelectedFileId(file.id)
setSelectedFile(fileInfo)
onChange(file.id, fileInfo)
onFileInfoChange?.(fileInfo)
}
}
},
})
} catch (error) {
logger.error('Error opening Google Drive Picker:', { error })
}
}
// Handle adding a new credential
@@ -278,6 +287,14 @@ export function FileSelector({
setOpen(false)
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -339,23 +356,6 @@ export function FileSelector({
}
}
// 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'
@@ -424,13 +424,12 @@ export function FileSelector({
)}
<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>
<span className="ml-2">Loading...</span>
</div>
) : credentials.length === 0 ? (
<div className="p-4 text-center">
@@ -441,9 +440,9 @@ export function FileSelector({
</div>
) : (
<div className="p-4 text-center">
<p className="text-sm font-medium">No files found.</p>
<p className="text-sm font-medium">Ready to select files.</p>
<p className="text-xs text-muted-foreground">
Try a different search or account.
Click the button below to open the file picker.
</p>
</div>
)}
@@ -471,25 +470,20 @@ export function FileSelector({
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
{/* Open picker button - only show if we have credentials */}
{credentials.length > 0 && selectedCredentialId && (
<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="p-2">
<Button
className="w-full"
onClick={() => {
setOpen(false)
handleOpenPicker()
}}
>
<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>
))}
Open Google Drive Picker
</Button>
</div>
</CommandGroup>
)}
@@ -504,17 +498,6 @@ export function FileSelector({
</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>
@@ -1,9 +1,9 @@
'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'
import { FileInfo, GoogleDrivePicker } from './components/google-drive-picker'
interface FileSelectorInputProps {
blockId: string
@@ -31,8 +31,11 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
setValue(blockId, subBlock.id, fileId)
}
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || ''
return (
<FileSelector
<GoogleDrivePicker
value={selectedFileId}
onChange={handleFileChange}
provider={subBlock.provider || 'google-drive'}
@@ -43,6 +46,8 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
onFileInfoChange={setFileInfo}
clientId={clientId}
apiKey={apiKey}
/>
)
}
@@ -1,9 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { FolderInfo, FolderSelector } from '@/components/ui/folder-selector'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { SubBlockConfig } from '@/blocks/types'
import { FolderInfo, FolderSelector } from '../folder-selector'
interface FolderSelectorInputProps {
blockId: string
@@ -12,7 +12,6 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command'
import { OAuthRequiredModal } from '@/components/ui/oauth-required-modal'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console-logger'
import {
@@ -22,6 +21,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '@/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
const logger = createLogger('FolderSelector')
@@ -24,12 +24,10 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console-logger'
import {
ProviderConfig,
WEBHOOK_PROVIDERS,
} from '@/app/w/[id]/components/workflow-block/components/sub-block/components/webhook-config'
import { ProviderConfig, WEBHOOK_PROVIDERS } from '../webhook-config'
const logger = createLogger('WebhookModal')
interface WebhookModalProps {
isOpen: boolean
onClose: () => void
@@ -3,9 +3,9 @@ import { useParams } from 'next/navigation'
import { CheckCircle2, ExternalLink } from 'lucide-react'
import { GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { WebhookModal } from '@/components/ui/webhook-modal'
import { createLogger } from '@/lib/logs/console-logger'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { WebhookModal } from './components/webhook-modal'
const logger = createLogger('WebhookConfig')
@@ -6,12 +6,12 @@ import { SubBlockConfig } from '@/blocks/types'
import { CheckboxList } from './components/checkbox-list'
import { Code } from './components/code'
import { ConditionInput } from './components/condition-input'
import { CredentialSelector } from './components/credential-selector'
import { CredentialSelector } from './components/credential-selector/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 { FolderSelectorInput } from './components/folder-selector-input'
import { FileSelectorInput } from './components/file-selector/file-selector-input'
import { FolderSelectorInput } from './components/folder-selector/components/folder-selector-input'
import { LongInput } from './components/long-input'
import { ShortInput } from './components/short-input'
import { SliderInput } from './components/slider-input'
@@ -19,7 +19,7 @@ import { Switch } from './components/switch'
import { Table } from './components/table'
import { TimeInput } from './components/time-input'
import { ToolInput } from './components/tool-input/tool-input'
import { WebhookConfig } from './components/webhook-config'
import { WebhookConfig } from './components/webhook/webhook-config'
interface SubBlockProps {
blockId: string
@@ -75,10 +75,6 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
/>
</div>
)
case 'webhook-config':
return (
<WebhookConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
)
case 'slider':
return (
<SliderInput
@@ -128,6 +124,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
return (
<TimeInput blockId={blockId} subBlockId={config.id} placeholder={config.placeholder} />
)
case 'webhook-config':
return (
<WebhookConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
)
case 'oauth-input':
return (
<CredentialSelector
+12 -2
View File
@@ -45,8 +45,8 @@ const nextConfig: NextConfig = {
],
},
{
// Cross-Origin Isolation headers (existing)
source: '/:path*',
// Apply Cross-Origin Isolation headers to all routes except those that use the Google Drive Picker
source: '/((?!w/.*|api/auth/oauth/drive).*)',
headers: [
{
key: 'Cross-Origin-Embedder-Policy',
@@ -58,6 +58,16 @@ const nextConfig: NextConfig = {
},
],
},
{
// For routes that use the Google Drive Picker, only apply COOP but not COEP
source: '/(w/.*|api/auth/oauth/drive)',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
],
},
]
},
}),
+11
View File
@@ -53,6 +53,7 @@
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-google-drive-picker": "^1.2.2",
"react-hook-form": "^7.54.2",
"react-simple-code-editor": "^0.14.1",
"reactflow": "^11.11.4",
@@ -8728,6 +8729,16 @@
"react": "^18.3.1"
}
},
"node_modules/react-google-drive-picker": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-google-drive-picker/-/react-google-drive-picker-1.2.2.tgz",
"integrity": "sha512-x30mYkt9MIwPCgL+fyK75HZ8E6G5L/WGW0bfMG6kbD4NG2kmdlmV9oH5lPa6P6d46y9hj5Y3btAMrZd4JRRkSA==",
"license": "MIT",
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/react-hook-form": {
"version": "7.54.2",
"license": "MIT",
+1
View File
@@ -65,6 +65,7 @@
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-google-drive-picker": "^1.2.2",
"react-hook-form": "^7.54.2",
"react-simple-code-editor": "^0.14.1",
"reactflow": "^11.11.4",