fix(oauth-block): race condition for rendering credential selectors and other subblocks + gdrive fixes (#1029)

* fix(oauth-block): race condition for rendering credential selectors and other subblocks

* fix import

* add dependsOn field to track cros-subblock deps

* remove redundant check

* remove redundant checks

* remove misleading comment

* fix

* fix jira

* fix

* fix

* confluence

* fix triggers

* fix

* fix

* make trigger creds collab supported

* fix for backwards compat

* fix trigger modal
This commit is contained in:
Vikhyath Mondreti
2025-08-19 21:21:09 -07:00
committed by Siddharth Ganesan
parent 2771c688ff
commit 9ad36c0e34
43 changed files with 655 additions and 485 deletions
+29 -1
View File
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
// Fetch the file from Google Drive API
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
}
// Resolve shortcuts transparently for UI stability
if (
file.mimeType === 'application/vnd.google-apps.shortcut' &&
file.shortcutDetails?.targetId
) {
const targetId = file.shortcutDetails.targetId
const shortcutResp = await fetch(
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (shortcutResp.ok) {
const targetFile = await shortcutResp.json()
file.id = targetFile.id
file.name = targetFile.name
file.mimeType = targetFile.mimeType
file.iconLink = targetFile.iconLink
file.webViewLink = targetFile.webViewLink
file.thumbnailLink = targetFile.thumbnailLink
file.createdTime = targetFile.createdTime
file.modifiedTime = targetFile.modifiedTime
file.size = targetFile.size
file.owners = targetFile.owners
file.exportLinks = targetFile.exportLinks
}
}
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
const format = exportFormats[file.mimeType] || 'application/pdf'
+23 -41
View File
@@ -1,10 +1,8 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType')
const query = searchParams.get('query') || ''
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
const workflowId = searchParams.get('workflowId') || undefined
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(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`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
// Authorize use of the credential (supports collaborator credentials via workflow)
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId!,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build the query parameters for Google Drive API
let queryParams = 'trashed=false'
// Add mimeType filter if provided
// Build Drive 'q' expression safely
const qParts: string[] = ['trashed = false']
if (folderId) {
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
}
if (mimeType) {
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
// Instead of using the mimeType parameter directly, we'll add it to the query
if (queryParams.includes('q=')) {
queryParams += ` and mimeType='${mimeType}'`
} else {
queryParams += `&q=mimeType='${mimeType}'`
}
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
}
// Add search query if provided
if (query) {
if (queryParams.includes('q=')) {
queryParams += ` and name contains '${query}'`
} else {
queryParams += `&q=name contains '${query}'`
}
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
}
const q = encodeURIComponent(qParts.join(' and '))
// Fetch files from Google Drive API
// Fetch files from Google Drive API with shared drives support
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
+2 -2
View File
@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
if (!success) {
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureOutlookPolling(
workflowRecord.userId,
savedWebhook,
@@ -6,9 +6,9 @@ import {
type SlackChannelInfo,
SlackChannelSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface ChannelSelectorInputProps {
blockId: string
@@ -29,8 +29,6 @@ export function ChannelSelectorInput({
isPreview = false,
previewValue,
}: ChannelSelectorInputProps) {
const { getValue } = useSubBlockStore()
// Use the proper hook to get the current value and setter (same as file-selector)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Reactive upstream fields
@@ -43,6 +41,8 @@ export function ChannelSelectorInput({
// Get provider-specific values
const provider = subBlock.provider || 'slack'
const isSlack = provider === 'slack'
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get the credential for the provider - use provided credential or fall back to reactive values
let credential: string
@@ -89,15 +89,10 @@ export function ChannelSelectorInput({
}}
credential={credential}
label={subBlock.placeholder || 'Select Slack channel'}
disabled={disabled || !credential}
disabled={finalDisabled}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select a Slack account or enter a bot token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('CredentialSelector')
@@ -217,17 +216,6 @@ export function CredentialSelector({
setSelectedId(credentialId)
if (!isPreview) {
setStoreValue(credentialId)
// If credential changed, clear other sub-block fields for a clean state
if (previousId && previousId !== credentialId) {
const wfId = (activeWorkflowId as string) || ''
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
const blockValues = workflowValues[blockId] || {}
Object.keys(blockValues).forEach((key) => {
if (key !== subBlock.id) {
collaborativeSetSubblockValue(blockId, key, '')
}
})
}
}
setOpen(false)
}
@@ -12,6 +12,7 @@ import {
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -65,6 +66,9 @@ export function DocumentSelector({
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const isDisabled = finalDisabled
// Fetch documents for the selected knowledge base
const fetchDocuments = useCallback(async () => {
if (!knowledgeBaseId) {
@@ -103,6 +107,7 @@ export function DocumentSelector({
// Handle dropdown open/close - fetch documents when opening
const handleOpenChange = (isOpen: boolean) => {
if (isPreview) return
if (isDisabled) return
setOpen(isOpen)
@@ -124,13 +129,14 @@ export function DocumentSelector({
// Sync selected document with value prop
useEffect(() => {
if (isDisabled) return
if (value && documents.length > 0) {
const docInfo = documents.find((doc) => doc.id === value)
setSelectedDocument(docInfo || null)
} else {
setSelectedDocument(null)
}
}, [value, documents])
}, [value, documents, isDisabled])
// Reset documents when knowledge base changes
useEffect(() => {
@@ -141,10 +147,10 @@ export function DocumentSelector({
// Fetch documents when knowledge base is available
useEffect(() => {
if (knowledgeBaseId && !isPreview) {
if (knowledgeBaseId && !isPreview && !isDisabled) {
fetchDocuments()
}
}, [knowledgeBaseId, isPreview, fetchDocuments])
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
const formatDocumentName = (document: DocumentData) => {
return document.filename
@@ -166,9 +172,6 @@ export function DocumentSelector({
const label = subBlock.placeholder || 'Select document'
// Show disabled state if no knowledge base is selected
const isDisabled = disabled || isPreview || !knowledgeBaseId
return (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -376,6 +376,14 @@ export function ConfluenceFileSelector({
}
}, [value])
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
useEffect(() => {
if (!value) {
setSelectedFile(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Handle file selection
const handleSelectFile = (file: ConfluenceFileInfo) => {
setSelectedFileId(file.id)
@@ -547,7 +555,7 @@ export function ConfluenceFileSelector({
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
@@ -414,6 +414,13 @@ export function GoogleDrivePicker({
return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
@@ -440,7 +447,7 @@ export function GoogleDrivePicker({
}}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
@@ -460,7 +467,7 @@ export function GoogleDrivePicker({
</Button>
{/* File preview */}
{showPreview && selectedFile && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
@@ -727,6 +727,13 @@ export function MicrosoftFileSelector({
})
: availableFiles
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
@@ -750,7 +757,7 @@ export function MicrosoftFileSelector({
}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
@@ -925,7 +932,7 @@ export function MicrosoftFileSelector({
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
@@ -1,33 +1,24 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getEnv } from '@/lib/env'
import {
type ConfluenceFileInfo,
ConfluenceFileSelector,
type DiscordChannelInfo,
DiscordChannelSelector,
type FileInfo,
type GoogleCalendarInfo,
GoogleCalendarSelector,
GoogleDrivePicker,
type JiraIssueInfo,
JiraIssueSelector,
type MicrosoftFileInfo,
MicrosoftFileSelector,
type TeamsMessageInfo,
TeamsMessageSelector,
WealthboxFileSelector,
type WealthboxItemInfo,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface FileSelectorInputProps {
blockId: string
@@ -46,11 +37,12 @@ export function FileSelectorInput({
previewValue,
previewContextValues,
}: FileSelectorInputProps) {
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
// Central dependsOn gating for this selector instance
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Helper to coerce various preview value shapes into a string ID
const coerceToIdString = (val: unknown): string => {
@@ -72,18 +64,13 @@ export function FileSelectorInput({
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const [selectedFileId, setSelectedFileId] = useState<string>('')
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
const [_issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(null)
const [selectedMessageId, setSelectedMessageId] = useState<string>('')
const [messageInfo, setMessageInfo] = useState<TeamsMessageInfo | null>(null)
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('')
const [calendarInfo, setCalendarInfo] = useState<GoogleCalendarInfo | null>(null)
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
const [domainValue] = useSubBlockValue(blockId, 'domain')
const [projectIdValue] = useSubBlockValue(blockId, 'projectId')
const [planIdValue] = useSubBlockValue(blockId, 'planId')
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
const [operationValue] = useSubBlockValue(blockId, 'operation')
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
const { isForeignCredential } = useForeignCredential(
@@ -104,128 +91,29 @@ export function FileSelectorInput({
const isWealthbox = provider === 'wealthbox'
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
const isMicrosoftPlanner = provider === 'microsoft-planner'
// For Confluence and Jira, we need the domain and credentials
const domain =
isConfluence || isJira
? (isPreview && previewContextValues?.domain?.value) ||
(getValue(blockId, 'domain') as string) ||
''
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
: ''
const jiraCredential = isJira
? (isPreview && previewContextValues?.credential?.value) ||
(getValue(blockId, 'credential') as string) ||
(connectedCredential as string) ||
''
: ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord
? (isPreview && previewContextValues?.botToken?.value) ||
(getValue(blockId, 'botToken') as string) ||
''
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
: ''
const serverId = isDiscord
? (isPreview && previewContextValues?.serverId?.value) ||
(getValue(blockId, 'serverId') as string) ||
''
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
: ''
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Keep local selection in sync with store value (and preview)
useEffect(() => {
const raw = isPreview && previewValue !== undefined ? previewValue : storeValue
const effective = coerceToIdString(raw)
if (effective) {
if (isJira) {
setSelectedIssueId(effective)
} else if (isDiscord) {
setSelectedChannelId(effective)
} else if (isMicrosoftTeams) {
setSelectedMessageId(effective)
} else if (isGoogleCalendar) {
setSelectedCalendarId(effective)
} else if (isWealthbox) {
setSelectedWealthboxItemId(effective)
} else if (isMicrosoftSharePoint) {
setSelectedFileId(effective)
} else {
setSelectedFileId(effective)
}
} else {
// Clear when value becomes empty
if (isJira) {
setSelectedIssueId('')
} else if (isDiscord) {
setSelectedChannelId('')
} else if (isMicrosoftTeams) {
setSelectedMessageId('')
} else if (isGoogleCalendar) {
setSelectedCalendarId('')
} else if (isWealthbox) {
setSelectedWealthboxItemId('')
} else if (isMicrosoftSharePoint) {
setSelectedFileId('')
} else {
setSelectedFileId('')
}
}
}, [
isPreview,
previewValue,
storeValue,
isJira,
isDiscord,
isMicrosoftTeams,
isGoogleCalendar,
isWealthbox,
isMicrosoftSharePoint,
])
// Handle file selection
const handleFileChange = (fileId: string, info?: any) => {
setSelectedFileId(fileId)
setFileInfo(info || null)
setStoreValue(fileId)
}
// Handle issue selection
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
setSelectedIssueId(issueKey)
setIssueInfo(info || null)
setStoreValue(issueKey)
// Clear the fields when a new issue is selected
if (isJira) {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
// Also clear the manual issue key when cleared
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}
}
// Handle channel selection
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
setStoreValue(channelId)
}
// Handle calendar selection
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
setSelectedCalendarId(calendarId)
setCalendarInfo(info || null)
setStoreValue(calendarId)
}
// Handle Wealthbox item selection
const handleWealthboxItemChange = (itemId: string, info?: WealthboxItemInfo) => {
setSelectedWealthboxItemId(itemId)
setWealthboxItemInfo(info || null)
setStoreValue(itemId)
}
// For Google Drive
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
@@ -245,25 +133,17 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedCalendarId(val)
setCalendarInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
label={subBlock.placeholder || 'Select Google Calendar'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onCalendarInfoChange={setCalendarInfo}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Google Calendar credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -277,21 +157,18 @@ export function FileSelectorInput({
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordChannelSelector
value={selectedChannelId}
onChange={handleChannelChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(channelId) => setStoreValue(channelId)}
botToken={botToken}
serverId={serverId}
label={subBlock.placeholder || 'Select Discord channel'}
disabled={disabled || !botToken || !serverId}
disabled={finalDisabled}
showPreview={true}
/>
</div>
</TooltipTrigger>
{(!botToken || !serverId) && (
<TooltipContent side='top'>
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -311,9 +188,7 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
@@ -321,20 +196,14 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={disabled || !domain}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
credentialId={credential}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!domain && (
<TooltipContent side='top'>
<p>Please enter a Confluence domain first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -353,168 +222,139 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedIssueId(val)
setIssueInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
onChange={(issueKey) => {
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
// Clear related fields when a new issue is selected
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
}
disabled={finalDisabled}
showPreview={true}
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
credentialId={credential}
projectId={(getValue(blockId, 'projectId') as string) || ''}
projectId={(projectIdValue as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</TooltipTrigger>
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
) : !credential ? (
<TooltipContent side='top'>
<p>Please select Jira credentials first</p>
</TooltipContent>
) : !(getValue(blockId, 'projectId') as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira project first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)
}
if (isMicrosoftExcel) {
// Get credential reactively
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-excel'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Excel file'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Excel credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft Word selector
// Microsoft Word selector
if (isMicrosoftWord) {
// Get credential reactively
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-word'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Word document'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Word credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft OneDrive selector
// Microsoft OneDrive selector
if (isMicrosoftOneDrive) {
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select OneDrive folder'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft SharePoint selector
// Microsoft SharePoint selector
if (isMicrosoftSharePoint) {
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={coerceToIdString(selectedFileId)}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
@@ -531,26 +371,26 @@ export function FileSelectorInput({
)
}
// Handle Microsoft Planner task selector
// Microsoft Planner task selector
if (isMicrosoftPlanner) {
const credential = (connectedCredential as string) || ''
const planId = (getValue(blockId, 'planId') as string) || ''
const planId = (planIdValue as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-planner'
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={disabled || !credential || !planId}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
@@ -572,32 +412,22 @@ export function FileSelectorInput({
)
}
// Handle Microsoft Teams selector
// Microsoft Teams selector
if (isMicrosoftTeams) {
// Get credential using the same pattern as other tools
const credential = (connectedCredential as string) || ''
// Determine the selector type based on the subBlock ID
// Determine the selector type based on the subBlock ID / operation
let selectionType: 'team' | 'channel' | 'chat' = 'team'
if (subBlock.id === 'teamId') {
selectionType = 'team'
} else if (subBlock.id === 'channelId') {
selectionType = 'channel'
} else if (subBlock.id === 'chatId') {
selectionType = 'chat'
} else {
// Fallback: look at the operation to determine the selection type
const operation = (getValue(blockId, 'operation') as string) || ''
if (operation.includes('chat')) {
selectionType = 'chat'
} else if (operation.includes('channel')) {
selectionType = 'channel'
}
if (subBlock.id === 'teamId') selectionType = 'team'
else if (subBlock.id === 'channelId') selectionType = 'channel'
else if (subBlock.id === 'chatId') selectionType = 'chat'
else {
const operation = (operationValue as string) || ''
if (operation.includes('chat')) selectionType = 'chat'
else if (operation.includes('channel')) selectionType = 'channel'
}
// Get the teamId from workflow parameters for channel selector
const selectedTeamId = (getValue(blockId, 'teamId') as string) || ''
const selectedTeamId = (teamIdValue as string) || ''
return (
<TooltipProvider>
@@ -610,10 +440,8 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(value, info) => {
setSelectedMessageId(value)
setMessageInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, value)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='microsoft-teams'
requiredScopes={subBlock.requiredScopes || []}
@@ -621,7 +449,6 @@ export function FileSelectorInput({
label={subBlock.placeholder || 'Select Teams message location'}
disabled={disabled || !credential}
showPreview={true}
onMessageInfoChange={setMessageInfo}
credential={credential}
selectionType={selectionType}
initialTeamId={selectedTeamId}
@@ -640,15 +467,11 @@ export function FileSelectorInput({
)
}
// Render Wealthbox selector
// Wealthbox selector
if (isWealthbox) {
// Get credential reactively
const credential = (connectedCredential as string) || ''
// Only handle contacts now - both notes and tasks use short-input
if (subBlock.id === 'contactId') {
const itemType = 'contact'
return (
<TooltipProvider>
<Tooltip>
@@ -660,9 +483,7 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedWealthboxItemId(val)
setWealthboxItemInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='wealthbox'
@@ -671,7 +492,6 @@ export function FileSelectorInput({
label={subBlock.placeholder || `Select ${itemType}`}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setWealthboxItemInfo}
credentialId={credential}
itemType={itemType}
/>
@@ -686,7 +506,7 @@ export function FileSelectorInput({
</TooltipProvider>
)
}
// If it's noteId or taskId, we should not render the file selector since they now use short-input
// noteId or taskId now use short-input
return null
}
@@ -705,9 +525,7 @@ export function FileSelectorInput({
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
@@ -717,7 +535,6 @@ export function FileSelectorInput({
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
onFileInfoChange={setFileInfo}
clientId={clientId}
apiKey={apiKey}
credentialId={credential}
@@ -5,6 +5,7 @@ import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -37,8 +38,13 @@ export function FolderSelectorInput({
(connectedCredential as string) || ''
)
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
// When gated/disabled, do not set defaults or write to store
if (finalDisabled) return
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
return
@@ -54,7 +60,15 @@ export function FolderSelectorInput({
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
}
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
}, [
blockId,
subBlock.id,
storeValue,
collaborativeSetSubblockValue,
isPreview,
previewValue,
finalDisabled,
])
// Handle folder selection
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
@@ -72,7 +86,7 @@ export function FolderSelectorInput({
provider={subBlock.provider || 'google-email'}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select folder'}
disabled={disabled}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
onFolderInfoChange={setFolderInfo}
credentialId={(connectedCredential as string) || ''}
@@ -279,29 +279,33 @@ export function FolderSelector({
// Fetch credentials on initial mount
useEffect(() => {
if (disabled) return
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
}, [fetchCredentials, disabled])
// Fetch folders when credential is selected
useEffect(() => {
if (disabled) return
if (selectedCredentialId) {
fetchFolders()
}
}, [selectedCredentialId, fetchFolders])
}, [selectedCredentialId, fetchFolders, disabled])
// Keep internal selectedFolderId in sync with the value prop
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? previewValue : value
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [value, isPreview, previewValue])
}, [value, isPreview, previewValue, disabled])
// Fetch the selected folder metadata once credentials are ready or value changes
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? (previewValue as string) : (value as string)
if (
currentValue &&
@@ -310,7 +314,15 @@ export function FolderSelector({
) {
fetchFolderById(currentValue)
}
}, [value, selectedCredentialId, selectedFolder, fetchFolderById, isPreview, previewValue])
}, [
value,
selectedCredentialId,
selectedFolder,
fetchFolderById,
isPreview,
previewValue,
disabled,
])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
@@ -353,6 +353,14 @@ export function JiraProjectSelector({
}
}, [value])
// Clear local preview when value is cleared remotely or via collaborator
useEffect(() => {
if (!value) {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
}, [value, onProjectInfoChange])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
@@ -387,6 +395,8 @@ export function JiraProjectSelector({
onProjectInfoChange?.(null)
}
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
return (
<>
<div className='space-y-2'>
@@ -399,7 +409,7 @@ export function JiraProjectSelector({
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{selectedProject ? (
{canShowPreview ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
@@ -546,7 +556,7 @@ export function JiraProjectSelector({
</Popover>
{/* Project preview */}
{showPreview && selectedProject && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
@@ -18,6 +18,7 @@ import {
type LinearTeamInfo,
LinearTeamSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -51,16 +52,11 @@ export function ProjectSelectorInput({
subBlock.provider || subBlock.serviceId || 'jira',
(connectedCredential as string) || ''
)
// Local setters for related Jira fields to ensure immediate UI clearing
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
blockId,
'manualIssueKey'
)
// Reactive dependencies from store for Linear
const [linearCredential] = useSubBlockValue(blockId, 'credential')
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get provider-specific values
const provider = subBlock.provider || 'jira'
@@ -95,27 +91,6 @@ export function ProjectSelectorInput({
setProjectInfo(info || null)
setStoreValue(projectId)
// Clear the issue-related fields when a new project is selected
if (provider === 'jira') {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
// Clear both the basic and advanced issue key fields to ensure UI resets
collaborativeSetSubblockValue(blockId, 'issueKey', '')
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
// Also clear locally for immediate UI feedback on this client
setIssueKeyValue('')
setManualIssueKeyValue('')
} else if (provider === 'discord') {
collaborativeSetSubblockValue(blockId, 'channelId', '')
} else if (provider === 'linear') {
if (subBlock.id === 'teamId') {
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
collaborativeSetSubblockValue(blockId, 'projectId', '')
} else if (subBlock.id === 'projectId') {
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
}
}
onProjectSelect?.(projectId)
}
@@ -163,7 +138,7 @@ export function ProjectSelectorInput({
}}
credential={(linearCredential as string) || ''}
label={subBlock.placeholder || 'Select Linear team'}
disabled={disabled || !(linearCredential as string)}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
/>
@@ -171,7 +146,7 @@ export function ProjectSelectorInput({
(() => {
const credential = (linearCredential as string) || ''
const teamId = (linearTeamId as string) || ''
const isDisabled = disabled || !credential || !teamId
const isDisabled = finalDisabled
return (
<LinearProjectSelector
value={selectedProjectId}
@@ -213,7 +188,7 @@ export function ProjectSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={disabled || !domain || !(jiraCredential as string)}
disabled={finalDisabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
@@ -222,15 +197,6 @@ export function ProjectSelectorInput({
/>
</div>
</TooltipTrigger>
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
) : !(jiraCredential as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira account first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -46,18 +46,51 @@ export function TriggerModal({
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
const [isSaving, setIsSaving] = useState(false)
// Track if config has changed from initial values
// Snapshot initial values at open for stable dirty-checking across collaborators
const initialConfigRef = useRef<Record<string, any>>(initialConfig)
const initialCredentialRef = useRef<string | null>(null)
// Capture initial credential on first detect
useEffect(() => {
if (initialCredentialRef.current !== null) return
const subBlockStore = useSubBlockStore.getState()
const cred = (subBlockStore.getValue(blockId, 'triggerCredentials') as string | null) || null
initialCredentialRef.current = cred
}, [blockId])
// Track if config has changed from initial snapshot
const hasConfigChanged = useMemo(() => {
return JSON.stringify(config) !== JSON.stringify(initialConfig)
}, [config, initialConfig])
return JSON.stringify(config) !== JSON.stringify(initialConfigRef.current)
}, [config])
// Track if credential has changed from initial snapshot (computed later once selectedCredentialId is declared)
let hasCredentialChanged = false
const [isDeleting, setIsDeleting] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [generatedPath, setGeneratedPath] = useState('')
const [hasCredentials, setHasCredentials] = useState(false)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
hasCredentialChanged = selectedCredentialId !== initialCredentialRef.current
const [dynamicOptions, setDynamicOptions] = useState<
Record<string, Array<{ id: string; name: string }>>
>({})
const lastCredentialIdRef = useRef<string | null>(null)
// Reset provider-dependent config fields when credentials change
const resetFieldsForCredentialChange = () => {
setConfig((prev) => {
const next = { ...prev }
if (triggerDef.provider === 'gmail') {
if (Array.isArray(next.labelIds)) next.labelIds = []
} else if (triggerDef.provider === 'outlook') {
if (Array.isArray(next.folderIds)) next.folderIds = []
} else if (triggerDef.provider === 'airtable') {
if (typeof next.baseId === 'string') next.baseId = ''
if (typeof next.tableId === 'string') next.tableId = ''
}
return next
})
}
// Initialize config with default values from trigger definition
useEffect(() => {
@@ -79,35 +112,71 @@ export function TriggerModal({
}
}, [triggerDef.configFields, initialConfig])
// Monitor credential selection
// Monitor credential selection across collaborators; clear options on change/clear
useEffect(() => {
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
// Check if credentials are selected by monitoring the sub-block store
const checkCredentials = () => {
const subBlockStore = useSubBlockStore.getState()
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials')
const hasCredential = Boolean(credentialValue)
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
| string
| null
const currentCredentialId = credentialValue || null
const hasCredential = Boolean(currentCredentialId)
setHasCredentials(hasCredential)
// If credential changed and it's a Gmail trigger, load labels
if (hasCredential && credentialValue !== selectedCredentialId) {
setSelectedCredentialId(credentialValue)
// If credential was cleared by another user, reset local state and dynamic options
if (!hasCredential) {
if (selectedCredentialId !== null) {
setSelectedCredentialId(null)
}
// Clear provider-specific dynamic options
setDynamicOptions({})
// Per requirements: only clear dependent selections on actual credential CHANGE,
// not when it becomes empty. So we do NOT reset fields here.
lastCredentialIdRef.current = null
return
}
// If credential changed, clear options immediately and load for new cred
const previousCredentialId = lastCredentialIdRef.current
// First detection (prev null → current non-null): do not clear selections
if (previousCredentialId === null) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
if (typeof currentCredentialId === 'string') {
if (triggerDef.provider === 'gmail') {
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
void loadOutlookFolders(currentCredentialId)
}
}
return
}
// Real change (prev non-null → different non-null): clear dependent selections
if (
typeof currentCredentialId === 'string' &&
currentCredentialId !== previousCredentialId
) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
// Clear stale options before loading new ones
setDynamicOptions({})
// Clear any selected values that depend on the credential
resetFieldsForCredentialChange()
if (triggerDef.provider === 'gmail') {
loadGmailLabels(credentialValue)
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
loadOutlookFolders(credentialValue)
void loadOutlookFolders(currentCredentialId)
}
}
}
checkCredentials()
// Set up a subscription to monitor changes
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
return unsubscribe
}
// If credentials aren't required, set to true
setHasCredentials(true)
}, [
blockId,
@@ -367,10 +436,14 @@ export function TriggerModal({
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isConfigValid() || (!hasConfigChanged && !!triggerId)}
disabled={
isSaving ||
!isConfigValid() ||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
}
className={cn(
'h-10',
isConfigValid() && (hasConfigChanged || !triggerId)
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
? 'bg-primary hover:bg-primary/90'
: '',
isSaving &&
@@ -172,6 +172,11 @@ export function TriggerConfig({
// Map trigger ID to webhook provider name
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
// Include selected credential from the modal (if any)
const selectedCredentialId =
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
null
// For credential-based triggers (like Gmail), create webhook entry for polling service but no webhook URL
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
// Gmail polling service requires a webhook database entry to find the configuration
@@ -185,7 +190,10 @@ export function TriggerConfig({
blockId,
path: '', // Empty path - API will generate dummy path for Gmail
provider: webhookProvider,
providerConfig: config,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
}),
})
@@ -225,7 +233,10 @@ export function TriggerConfig({
blockId,
path,
provider: webhookProvider,
providerConfig: config,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
}),
})
@@ -0,0 +1,54 @@
'use client'
import { useMemo } from 'react'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
* Centralized dependsOn gating for sub-block components.
* - Computes dependency values from the active workflow/block
* - Returns a stable disabled flag to pass to inputs and to guard effects
*/
export function useDependsOnGate(
blockId: string,
subBlock: SubBlockConfig,
opts?: { disabled?: boolean; isPreview?: boolean }
) {
const disabledProp = opts?.disabled ?? false
const isPreview = opts?.isPreview ?? false
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
// Use only explicit dependsOn from block config. No inference.
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
const dependencyValues = useSubBlockStore((state) => {
if (dependsOn.length === 0) return [] as any[]
if (!activeWorkflowId) return dependsOn.map(() => null)
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
return dependsOn.map((depKey) => (blockValues as any)[depKey] ?? null)
}) as any[]
const depsSatisfied = useMemo(() => {
if (dependsOn.length === 0) return true
return dependencyValues.every((v) =>
typeof v === 'string' ? v.trim().length > 0 : v !== null && v !== undefined && v !== ''
)
}, [dependencyValues, dependsOn])
// Block everything except the credential field itself until dependencies are set
const blocked =
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
const finalDisabled = disabledProp || isPreview || blocked
return {
dependsOn,
dependencyValues,
depsSatisfied,
blocked,
finalDisabled,
}
}
@@ -37,6 +37,10 @@ export function useSubBlockValue<T = any>(
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
// Subscribe to active workflow id to avoid races where the workflow id is set after mount.
// This ensures our selector recomputes when the active workflow changes.
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
@@ -56,9 +60,16 @@ export function useSubBlockValue<T = any>(
const streamingValueRef = useRef<T | null>(null)
const wasStreamingRef = useRef<boolean>(false)
// Get value from subblock store - always call this hook unconditionally
// Get value from subblock store, keyed by active workflow id
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
const storeValue = useSubBlockStore(
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
useCallback(
(state) => {
if (!activeWorkflowId) return null
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]
)
)
// Check if we're in diff mode and get diff value if available
@@ -123,12 +134,10 @@ export function useSubBlockValue<T = any>(
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[useWorkflowRegistry.getState().activeWorkflowId || '']: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || ''],
[activeWorkflowId || '']: {
...state.workflowValues[activeWorkflowId || ''],
[blockId]: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || '']?.[
blockId
],
...state.workflowValues[activeWorkflowId || '']?.[blockId],
[subBlockId]: newValue,
},
},
@@ -190,6 +199,7 @@ export function useSubBlockValue<T = any>(
isStreaming,
emitValue,
isShowingDiff,
activeWorkflowId,
]
)
+2
View File
@@ -45,6 +45,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
dependsOn: ['credential'],
required: true,
},
{
@@ -53,6 +54,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
dependsOn: ['credential', 'baseId'],
required: true,
},
{
+1
View File
@@ -58,6 +58,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
provider: 'confluence',
serviceId: 'confluence',
placeholder: 'Select Confluence page',
dependsOn: ['credential', 'domain'],
mode: 'basic',
},
// Manual page ID input (advanced mode)
+2
View File
@@ -43,6 +43,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord server',
dependsOn: ['botToken'],
mode: 'basic',
condition: {
field: 'operation',
@@ -71,6 +72,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
provider: 'discord',
serviceId: 'discord',
placeholder: 'Select Discord channel',
dependsOn: ['botToken', 'serverId'],
mode: 'basic',
condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] },
},
+1
View File
@@ -106,6 +106,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
'https://www.googleapis.com/auth/gmail.labels',
],
placeholder: 'Select Gmail label/folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'read_gmail' },
},
@@ -48,6 +48,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
placeholder: 'Select calendar',
dependsOn: ['credential'],
mode: 'basic',
},
// Manual calendar ID input (advanced mode)
+4
View File
@@ -49,6 +49,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.google-apps.document',
placeholder: 'Select a document',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['read', 'write'] },
},
@@ -59,6 +60,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter document ID',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: ['read', 'write'] },
},
@@ -83,6 +85,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'create' },
},
@@ -93,6 +96,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'create' },
},
+3
View File
@@ -82,6 +82,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'upload' },
},
{
@@ -155,6 +156,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'create_folder' },
},
// Manual Folder ID input (advanced mode)
@@ -179,6 +181,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a folder to list files from',
mode: 'basic',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'list' },
},
// Manual Folder ID input (advanced mode)
+2
View File
@@ -50,6 +50,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.google-apps.spreadsheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],
mode: 'basic',
},
// Manual Spreadsheet ID (advanced mode)
@@ -59,6 +60,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'ID of the spreadsheet (from URL)',
dependsOn: ['credential'],
mode: 'advanced',
},
// Range
+4
View File
@@ -62,6 +62,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira project',
dependsOn: ['credential', 'domain'],
mode: 'basic',
},
// Manual project ID input (advanced mode)
@@ -71,6 +72,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter Jira project ID',
dependsOn: ['credential', 'domain'],
mode: 'advanced',
},
// Issue selector (basic mode)
@@ -82,6 +84,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
provider: 'jira',
serviceId: 'jira',
placeholder: 'Select Jira issue',
dependsOn: ['credential', 'domain', 'projectId'],
condition: { field: 'operation', value: ['read', 'update'] },
mode: 'basic',
},
@@ -92,6 +95,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter Jira issue key',
dependsOn: ['credential', 'domain', 'projectId'],
condition: { field: 'operation', value: ['read', 'update'] },
mode: 'advanced',
},
+1
View File
@@ -66,6 +66,7 @@ export const KnowledgeBlock: BlockConfig = {
type: 'document-selector',
layout: 'full',
placeholder: 'Select document',
dependsOn: ['knowledgeBaseId'],
required: true,
condition: { field: 'operation', value: 'upload_chunk' },
},
+2
View File
@@ -42,6 +42,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a team',
dependsOn: ['credential'],
mode: 'basic',
},
{
@@ -52,6 +53,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
provider: 'linear',
serviceId: 'linear',
placeholder: 'Select a project',
dependsOn: ['credential', 'teamId'],
mode: 'basic',
},
// Manual team ID input (advanced mode)
@@ -46,6 +46,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
requiredScopes: [],
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],
mode: 'basic',
},
{
@@ -54,6 +55,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter spreadsheet ID',
dependsOn: ['credential'],
mode: 'advanced',
},
{
@@ -61,6 +61,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
layout: 'full',
placeholder: 'Enter the plan ID',
condition: { field: 'operation', value: ['create_task', 'read_task'] },
dependsOn: ['credential'],
},
{
id: 'taskId',
@@ -70,6 +71,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
placeholder: 'Select a task',
provider: 'microsoft-planner',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'basic',
},
@@ -81,6 +83,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
layout: 'full',
placeholder: 'Enter the task ID',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
mode: 'advanced',
},
@@ -61,6 +61,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
serviceId: 'microsoft-teams',
requiredScopes: [],
placeholder: 'Select a team',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
},
@@ -82,6 +83,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
serviceId: 'microsoft-teams',
requiredScopes: [],
placeholder: 'Select a chat',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['read_chat', 'write_chat'] },
},
@@ -103,6 +105,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
serviceId: 'microsoft-teams',
requiredScopes: [],
placeholder: 'Select a channel',
dependsOn: ['credential', 'teamId'],
mode: 'basic',
condition: { field: 'operation', value: ['read_channel', 'write_channel'] },
},
+6
View File
@@ -78,6 +78,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'upload' },
},
@@ -87,6 +88,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'upload' },
},
@@ -115,6 +117,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'create_folder' },
},
@@ -125,6 +128,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter parent folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'create_folder' },
},
@@ -146,6 +150,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a folder to list files from',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'list' },
},
@@ -156,6 +161,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter folder ID (leave empty for root folder)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'list' },
},
+1
View File
@@ -124,6 +124,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
serviceId: 'outlook',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
placeholder: 'Select Outlook folder',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'read_outlook' },
},
+2
View File
@@ -61,6 +61,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a site',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['create_page', 'read_page', 'list_sites'] },
},
@@ -99,6 +100,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter site ID (leave empty for root site)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'create_page' },
},
+3
View File
@@ -171,6 +171,9 @@ export interface SubBlockConfig {
// Trigger-specific configuration
availableTriggers?: string[] // List of trigger IDs available for this subblock
triggerProvider?: string // Which provider's triggers to show
// Declarative dependency hints for cross-field clearing or invalidation
// Example: dependsOn: ['credential'] means this field should be cleared when credential changes
dependsOn?: string[]
}
// Main block definition
+23 -1
View File
@@ -804,7 +804,7 @@ export function useCollaborativeWorkflow() {
)
const collaborativeSetSubblockValue = useCallback(
(blockId: string, subblockId: string, value: any) => {
(blockId: string, subblockId: string, value: any, options?: { _visited?: Set<string> }) => {
if (isApplyingRemoteChange.current) return
// Skip socket operations when in diff mode
@@ -840,6 +840,28 @@ export function useCollaborativeWorkflow() {
// Apply locally first (immediate UI feedback)
subBlockStore.setValue(blockId, subblockId, value)
// Declarative clearing: clear sub-blocks that depend on this subblockId
try {
const visited = options?._visited || new Set<string>()
if (visited.has(subblockId)) return
visited.add(subblockId)
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
const blockConfig = blockType ? getBlock(blockType) : null
if (blockConfig?.subBlocks && Array.isArray(blockConfig.subBlocks)) {
const dependents = blockConfig.subBlocks.filter(
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
)
for (const dep of dependents) {
// Skip clearing if the dependent is the same field
if (!dep?.id || dep.id === subblockId) continue
// Cascade using the same collaborative path so it emits and further cascades
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
}
}
} catch {
// Best-effort; do not block on clearing
}
},
[
subBlockStore,
+27 -10
View File
@@ -3,9 +3,9 @@ import { nanoid } from 'nanoid'
import { Logger } from '@/lib/logs/console/logger'
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
import { getBaseUrl } from '@/lib/urls/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
import { account, webhook } from '@/db/schema'
const logger = new Logger('GmailPollingService')
@@ -81,20 +81,37 @@ export async function pollGmailWebhooks() {
const requestId = nanoid()
try {
// Extract user ID from webhook metadata if available
// Extract metadata
const metadata = webhookData.providerConfig as any
const userId = metadata?.userId
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
if (!userId) {
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
return { success: false, webhookId, error: 'No user ID' }
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
return { success: false, webhookId, error: 'Missing credentialId and userId' }
}
// Get OAuth token for Gmail API
const accessToken = await getOAuthToken(userId, 'google-email')
// Resolve owner and token
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}`
)
return { success: false, webhookId, error: 'Credential not found' }
}
const ownerUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
} else if (userId) {
// Backward-compat fallback to workflow owner token
accessToken = await getOAuthToken(userId, 'google-email')
}
if (!accessToken) {
logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`)
logger.error(
`[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)`
)
return { success: false, webhookId, error: 'No access token' }
}
@@ -3,9 +3,9 @@ import { nanoid } from 'nanoid'
import { Logger } from '@/lib/logs/console/logger'
import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
import { getBaseUrl } from '@/lib/urls/utils'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
import { account, webhook } from '@/db/schema'
const logger = new Logger('OutlookPollingService')
@@ -108,28 +108,37 @@ export async function pollOutlookWebhooks() {
try {
logger.info(`[${requestId}] Processing Outlook webhook: ${webhookId}`)
// Extract user ID from webhook metadata if available
// Extract credentialId and/or userId
const metadata = webhookData.providerConfig as any
const userId = metadata?.userId
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
// Debug: Webhook metadata extraction
logger.debug(
`[${requestId}] Webhook ${webhookId} providerConfig:`,
JSON.stringify(metadata, null, 2)
)
logger.debug(`[${requestId}] Extracted userId:`, userId)
if (!userId) {
logger.error(`[${requestId}] No user ID found for webhook ${webhookId}`)
logger.debug(`[${requestId}] No userId found in providerConfig for webhook ${webhookId}`)
return { success: false, webhookId, error: 'No user ID' }
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
return { success: false, webhookId, error: 'Missing credentialId and userId' }
}
// Get OAuth token for Outlook API
const accessToken = await getOAuthToken(userId, 'outlook')
// Resolve access token
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!rows.length) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for webhook ${webhookId}`
)
return { success: false, webhookId, error: 'Credential not found' }
}
const ownerUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
} else if (userId) {
// Backward-compat fallback to workflow owner token
accessToken = await getOAuthToken(userId, 'outlook')
}
if (!accessToken) {
logger.error(`[${requestId}] Failed to get Outlook access token for webhook ${webhookId}`)
logger.error(
`[${requestId}] Failed to get Outlook access token for webhook ${webhookId} (cred or fallback)`
)
return { success: false, webhookId, error: 'No access token' }
}
+128 -38
View File
@@ -1,9 +1,9 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { webhook } from '@/db/schema'
import { account, webhook } from '@/db/schema'
const logger = createLogger('WebhookUtils')
@@ -860,6 +860,31 @@ export async function fetchAndProcessAirtablePayloads(
return // Exit early
}
// Require credentialId
const credentialId: string | undefined = localProviderConfig.credentialId
if (!credentialId) {
logger.error(
`[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.`
)
return
}
// Resolve owner and access token strictly via credentialId (no fallback)
let ownerUserId: string | null = null
try {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
ownerUserId = rows.length ? rows[0].userId : null
} catch (_e) {
ownerUserId = null
}
if (!ownerUserId) {
logger.error(
`[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}`
)
return
}
// --- Retrieve Stored Cursor from localProviderConfig ---
const storedCursor = localProviderConfig.externalWebhookCursor
@@ -908,14 +933,13 @@ export async function fetchAndProcessAirtablePayloads(
)
}
// --- Get OAuth Token ---
// --- Get OAuth Token (strict via credentialId) ---
let accessToken: string | null = null
try {
accessToken = await getOAuthToken(workflowData.userId, 'airtable')
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain valid Airtable access token. Cannot proceed.`,
{ userId: workflowData.userId }
`[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.`
)
throw new Error('Airtable access token not found.')
}
@@ -923,11 +947,11 @@ export async function fetchAndProcessAirtablePayloads(
logger.info(`[${requestId}] Successfully obtained Airtable access token`)
} catch (tokenError: any) {
logger.error(
`[${requestId}] Failed to get Airtable OAuth token for user ${workflowData.userId}`,
`[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`,
{
error: tokenError.message,
stack: tokenError.stack,
userId: workflowData.userId,
credentialId,
}
)
// Error logging handled by logging session
@@ -1102,7 +1126,7 @@ export async function fetchAndProcessAirtablePayloads(
// DEBUG: Log totals for this batch
logger.debug(
`[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount}`,
`[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount})`,
{
currentMapSize: consolidatedChangesMap.size,
}
@@ -1257,14 +1281,47 @@ export async function configureGmailPolling(
logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`)
try {
const accessToken = await getOAuthToken(userId, 'google-email')
if (!accessToken) {
logger.error(`[${requestId}] Failed to retrieve Gmail access token for user ${userId}`)
return false
}
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const credentialId: string | undefined = providerConfig.credentialId
let effectiveUserId: string | null = null
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
)
return false
}
effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
)
return false
}
} else {
// Backward-compat: fall back to workflow owner
if (!userId) {
logger.error(
`[${requestId}] Missing credentialId and userId for Gmail webhook ${webhookData.id}`
)
return false
}
effectiveUserId = userId
accessToken = await getOAuthToken(effectiveUserId, 'google-email')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain Gmail token for user ${effectiveUserId} (fallback)`
)
return false
}
}
const maxEmailsPerPoll =
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
@@ -1282,7 +1339,8 @@ export async function configureGmailPolling(
.set({
providerConfig: {
...providerConfig,
userId, // Store user ID for OAuth access during polling
userId: effectiveUserId,
...(credentialId ? { credentialId } : {}),
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
@@ -1323,23 +1381,48 @@ export async function configureOutlookPolling(
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
try {
const accessToken = await getOAuthToken(userId, 'outlook')
if (!accessToken) {
logger.error(`[${requestId}] Failed to retrieve Outlook access token for user ${userId}`)
return false
}
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const maxEmailsPerPoll =
typeof providerConfig.maxEmailsPerPoll === 'string'
? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25
: providerConfig.maxEmailsPerPoll || 25
const credentialId: string | undefined = providerConfig.credentialId
const pollingInterval =
typeof providerConfig.pollingInterval === 'string'
? Number.parseInt(providerConfig.pollingInterval, 10) || 5
: providerConfig.pollingInterval || 5
let effectiveUserId: string | null = null
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
)
return false
}
effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
)
return false
}
} else {
// Backward-compat: fall back to workflow owner
if (!userId) {
logger.error(
`[${requestId}] Missing credentialId and userId for Outlook webhook ${webhookData.id}`
)
return false
}
effectiveUserId = userId
accessToken = await getOAuthToken(effectiveUserId, 'outlook')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain Outlook token for user ${effectiveUserId} (fallback)`
)
return false
}
}
const providerCfg = (webhookData.providerConfig as Record<string, any>) || {}
const now = new Date()
@@ -1347,14 +1430,21 @@ export async function configureOutlookPolling(
.update(webhook)
.set({
providerConfig: {
...providerConfig,
userId, // Store user ID for OAuth access during polling
maxEmailsPerPoll,
pollingInterval,
markAsRead: providerConfig.markAsRead || false,
includeRawEmail: providerConfig.includeRawEmail || false,
folderIds: providerConfig.folderIds || ['inbox'],
folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE',
...providerCfg,
userId: effectiveUserId,
...(credentialId ? { credentialId } : {}),
maxEmailsPerPoll:
typeof providerCfg.maxEmailsPerPoll === 'string'
? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25
: providerCfg.maxEmailsPerPoll || 25,
pollingInterval:
typeof providerCfg.pollingInterval === 'string'
? Number.parseInt(providerCfg.pollingInterval, 10) || 5
: providerCfg.pollingInterval || 5,
markAsRead: providerCfg.markAsRead || false,
includeRawEmail: providerCfg.includeRawEmail || false,
folderIds: providerCfg.folderIds || ['inbox'],
folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE',
lastCheckedTimestamp: now.toISOString(),
setupCompleted: true,
},
+1 -1
View File
@@ -41,7 +41,7 @@ export const createFolderTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUplo
},
request: {
url: 'https://www.googleapis.com/drive/v3/files',
url: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
+4
View File
@@ -59,6 +59,10 @@ export const listTool: ToolConfig<GoogleDriveToolParams, GoogleDriveListResponse
'fields',
'files(id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents),nextPageToken'
)
// Ensure shared drives support
url.searchParams.append('supportsAllDrives', 'true')
url.searchParams.append('includeItemsFromAllDrives', 'true')
url.searchParams.append('spaces', 'drive')
// Build the query conditions
const conditions = ['trashed = false'] // Always exclude trashed files
+4 -4
View File
@@ -57,7 +57,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
},
request: {
url: 'https://www.googleapis.com/drive/v3/files',
url: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
@@ -115,7 +115,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
})
const uploadResponse = await fetch(
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media&supportsAllDrives=true`,
{
method: 'PATCH',
headers: {
@@ -144,7 +144,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
})
const updateNameResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}`,
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true`,
{
method: 'PATCH',
headers: {
@@ -167,7 +167,7 @@ export const uploadTool: ToolConfig<GoogleDriveToolParams, GoogleDriveUploadResp
// Get the final file data
const finalFileResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
`https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true&fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`,
{
headers: {
Authorization: authHeader,