Deploy your workflow to see a preview
@@ -304,6 +294,51 @@ export function GeneralDeploy({
+
+ {workflowToShow && (
+
{
+ if (!open) {
+ setExpandedSelectedBlockId(null)
+ }
+ setShowExpandedPreview(open)
+ }}
+ >
+
+
+ {previewMode === 'selected' && selectedVersionInfo
+ ? selectedVersionInfo.name || `v${selectedVersion}`
+ : 'Live Workflow'}
+
+
+
+
+ {
+ setExpandedSelectedBlockId(
+ expandedSelectedBlockId === blockId ? null : blockId
+ )
+ }}
+ cursorStyle='pointer'
+ />
+
+ {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
+
setExpandedSelectedBlockId(null)}
+ />
+ )}
+
+
+
+
+ )}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx
index f5b15f522c..3bd4301250 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx
@@ -18,7 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
-import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
+import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import {
useCreateTemplate,
useDeleteTemplate,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index d902200dd2..4a4f112e23 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -2,8 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ExternalLink } from 'lucide-react'
+import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
+import { getSubscriptionStatus } from '@/lib/billing/client'
+import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -15,7 +17,11 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
+import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
+import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
+import { useOrganizations } from '@/hooks/queries/organization'
+import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -45,6 +51,19 @@ export function CredentialSelector({
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId || ''
+ const supportsCredentialSets = subBlock.supportsCredentialSets || false
+
+ const { data: organizationsData } = useOrganizations()
+ const { data: subscriptionData } = useSubscriptionData()
+ const activeOrganization = organizationsData?.activeOrganization
+ const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
+ const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
+ const canUseCredentialSets = supportsCredentialSets && hasTeamPlan && !!activeOrganization?.id
+
+ const { data: credentialSets = [] } = useCredentialSets(
+ activeOrganization?.id,
+ canUseCredentialSets
+ )
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const hasDependencies = dependsOn.length > 0
@@ -52,7 +71,12 @@ export function CredentialSelector({
const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied)
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
- const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
+ const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
+ const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET.PREFIX)
+ const selectedId = isCredentialSetSelected ? '' : rawSelectedId
+ const selectedCredentialSetId = isCredentialSetSelected
+ ? rawSelectedId.slice(CREDENTIAL_SET.PREFIX.length)
+ : ''
const effectiveProviderId = useMemo(
() => getProviderIdFromServiceId(serviceId) as OAuthProvider,
@@ -87,11 +111,20 @@ export function CredentialSelector({
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
+ const selectedCredentialSet = useMemo(
+ () => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
+ [credentialSets, selectedCredentialSetId]
+ )
+
+ const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
+
const resolvedLabel = useMemo(() => {
+ if (selectedCredentialSet) return selectedCredentialSet.name
+ if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name
- if (isForeign) return 'Saved by collaborator'
+ if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
- }, [selectedCredential, isForeign])
+ }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
useEffect(() => {
if (!isEditing) {
@@ -148,6 +181,15 @@ export function CredentialSelector({
[isPreview, setStoreValue]
)
+ const handleCredentialSetSelect = useCallback(
+ (credentialSetId: string) => {
+ if (isPreview) return
+ setStoreValue(`${CREDENTIAL_SET.PREFIX}${credentialSetId}`)
+ setIsEditing(false)
+ },
+ [isPreview, setStoreValue]
+ )
+
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
@@ -176,7 +218,56 @@ export function CredentialSelector({
.join(' ')
}, [])
- const comboboxOptions = useMemo(() => {
+ const { comboboxOptions, comboboxGroups } = useMemo(() => {
+ const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId)
+ // Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility
+ const matchesProvider = (csProviderId: string | null) => {
+ if (!csProviderId || !pollingProviderId) return false
+ if (csProviderId === pollingProviderId) return true
+ // Handle legacy 'gmail' mapping to 'google-email'
+ if (pollingProviderId === 'google-email' && csProviderId === 'gmail') return true
+ return false
+ }
+ const filteredCredentialSets = pollingProviderId
+ ? credentialSets.filter((cs) => matchesProvider(cs.providerId))
+ : []
+
+ if (canUseCredentialSets && filteredCredentialSets.length > 0) {
+ const groups = []
+
+ groups.push({
+ section: 'Polling Groups',
+ items: filteredCredentialSets.map((cs) => ({
+ label: cs.name,
+ value: `${CREDENTIAL_SET.PREFIX}${cs.id}`,
+ })),
+ })
+
+ const credentialItems = credentials.map((cred) => ({
+ label: cred.name,
+ value: cred.id,
+ }))
+
+ if (credentialItems.length > 0) {
+ groups.push({
+ section: 'Personal Credential',
+ items: credentialItems,
+ })
+ } else {
+ groups.push({
+ section: 'Personal Credential',
+ items: [
+ {
+ label: `Connect ${getProviderName(provider)} account`,
+ value: '__connect_account__',
+ },
+ ],
+ })
+ }
+
+ return { comboboxOptions: [], comboboxGroups: groups }
+ }
+
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
@@ -189,14 +280,32 @@ export function CredentialSelector({
})
}
- return options
- }, [credentials, provider, getProviderName])
+ return { comboboxOptions: options, comboboxGroups: undefined }
+ }, [
+ credentials,
+ provider,
+ effectiveProviderId,
+ getProviderName,
+ canUseCredentialSets,
+ credentialSets,
+ ])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
+ if (isCredentialSetSelected && selectedCredentialSet) {
+ return (
+
+ )
+ }
+
return (
@@ -205,7 +314,13 @@ export function CredentialSelector({
{inputValue}
)
- }, [getProviderIcon, inputValue, selectedCredentialProvider])
+ }, [
+ getProviderIcon,
+ inputValue,
+ selectedCredentialProvider,
+ isCredentialSetSelected,
+ selectedCredentialSet,
+ ])
const handleComboboxChange = useCallback(
(value: string) => {
@@ -214,6 +329,16 @@ export function CredentialSelector({
return
}
+ if (value.startsWith(CREDENTIAL_SET.PREFIX)) {
+ const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
+ const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
+ if (matchedSet) {
+ setInputValue(matchedSet.name)
+ handleCredentialSetSelect(credentialSetId)
+ return
+ }
+ }
+
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
setInputValue(matchedCred.name)
@@ -224,15 +349,16 @@ export function CredentialSelector({
setIsEditing(true)
setInputValue(value)
},
- [credentials, handleAddCredential, handleSelect]
+ [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
)
return (
{needsUpdate && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx
index 541fb538aa..2cd1b039b3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input.tsx
@@ -332,7 +332,10 @@ export function LongInput({
/>
{formattedText}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx
index 8b947a6af3..673669356e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slider-input/slider-input.tsx
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { Slider } from '@/components/emcn/components/slider/slider'
+import { cn } from '@/lib/core/utils/cn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface SliderInputProps {
@@ -58,15 +59,17 @@ export function SliderInput({
const percentage = ((normalizedValue - min) / (max - min)) * 100
+ const isDisabled = isPreview || disabled
+
return (
-
+
{
if (selectedCredential) return selectedCredential.name
- if (isForeign) return 'Saved by collaborator'
+ if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, isForeign])
@@ -210,7 +211,7 @@ export function ToolCredentialSelector({
placeholder={label}
disabled={disabled}
editable={true}
- filterOptions={true}
+ filterOptions={!isForeign}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index 1d7ead5c53..42b8484dc1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -949,7 +949,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
)}
-
+ {!data.isPreview && (
+
+ )}
{shouldShowDefaultHandles &&
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
index 14e64108c8..02d31bd926 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
@@ -55,9 +55,11 @@ const WorkflowEdgeComponent = ({
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
- const edgeRunStatus = lastRunEdges.get(id)
+ const previewExecutionStatus = (
+ data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
+ )?.executionStatus
+ const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
- // Memoize diff status calculation to avoid recomputing on every render
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
if (data?.isDeleted) return 'deleted'
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
@@ -84,21 +86,39 @@ const WorkflowEdgeComponent = ({
targetHandle,
])
- // Memoize edge style to prevent object recreation
const edgeStyle = useMemo(() => {
let color = 'var(--workflow-edge)'
- if (edgeDiffStatus === 'deleted') color = 'var(--text-error)'
- else if (isErrorEdge) color = 'var(--text-error)'
- else if (edgeDiffStatus === 'new') color = 'var(--brand-tertiary)'
- else if (edgeRunStatus === 'success') color = 'var(--border-success)'
- else if (edgeRunStatus === 'error') color = 'var(--text-error)'
+ let opacity = 1
+
+ if (edgeDiffStatus === 'deleted') {
+ color = 'var(--text-error)'
+ opacity = 0.7
+ } else if (isErrorEdge) {
+ color = 'var(--text-error)'
+ } else if (edgeDiffStatus === 'new') {
+ color = 'var(--brand-tertiary)'
+ } else if (edgeRunStatus === 'success') {
+ color = 'var(--border-success)'
+ } else if (edgeRunStatus === 'error') {
+ color = 'var(--text-error)'
+ }
+
+ if (isSelected) {
+ opacity = 0.5
+ }
return {
...(style ?? {}),
- strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2,
+ strokeWidth: edgeDiffStatus
+ ? 3
+ : edgeRunStatus === 'success' || edgeRunStatus === 'error'
+ ? 2.5
+ : isSelected
+ ? 2.5
+ : 2,
stroke: color,
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
- opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1,
+ opacity,
}
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
@@ -137,7 +157,6 @@ const WorkflowEdgeComponent = ({
e.stopPropagation()
if (data?.onDelete) {
- // Pass this specific edge's ID to the delete function
data.onDelete(id)
}
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
index 61af9f0624..07cfd79fc2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts
@@ -885,6 +885,7 @@ export function useWorkflowExecution() {
const activeBlocksSet = new Set
()
const streamedContent = new Map()
+ const accumulatedBlockLogs: BlockLog[] = []
// Execute the workflow
try {
@@ -933,14 +934,30 @@ export function useWorkflowExecution() {
// Edges already tracked in onBlockStarted, no need to track again
+ const startedAt = new Date(Date.now() - data.durationMs).toISOString()
+ const endedAt = new Date().toISOString()
+
+ // Accumulate block log for the execution result
+ accumulatedBlockLogs.push({
+ blockId: data.blockId,
+ blockName: data.blockName || 'Unknown Block',
+ blockType: data.blockType || 'unknown',
+ input: data.input || {},
+ output: data.output,
+ success: true,
+ durationMs: data.durationMs,
+ startedAt,
+ endedAt,
+ })
+
// Add to console
addConsole({
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
- startedAt: new Date(Date.now() - data.durationMs).toISOString(),
- endedAt: new Date().toISOString(),
+ startedAt,
+ endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
@@ -967,6 +984,24 @@ export function useWorkflowExecution() {
// Track failed block execution in run path
setBlockRunStatus(data.blockId, 'error')
+
+ const startedAt = new Date(Date.now() - data.durationMs).toISOString()
+ const endedAt = new Date().toISOString()
+
+ // Accumulate block error log for the execution result
+ accumulatedBlockLogs.push({
+ blockId: data.blockId,
+ blockName: data.blockName || 'Unknown Block',
+ blockType: data.blockType || 'unknown',
+ input: data.input || {},
+ output: {},
+ success: false,
+ error: data.error,
+ durationMs: data.durationMs,
+ startedAt,
+ endedAt,
+ })
+
// Add error to console
addConsole({
input: data.input || {},
@@ -974,8 +1009,8 @@ export function useWorkflowExecution() {
success: false,
error: data.error,
durationMs: data.durationMs,
- startedAt: new Date(Date.now() - data.durationMs).toISOString(),
- endedAt: new Date().toISOString(),
+ startedAt,
+ endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
@@ -1029,7 +1064,7 @@ export function useWorkflowExecution() {
startTime: data.startTime,
endTime: data.endTime,
},
- logs: [],
+ logs: accumulatedBlockLogs,
}
},
@@ -1041,7 +1076,7 @@ export function useWorkflowExecution() {
metadata: {
duration: data.duration,
},
- logs: [],
+ logs: accumulatedBlockLogs,
}
// Only add workflow-level error if no blocks have executed yet
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
index e19068fac3..c072628253 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
@@ -31,7 +31,6 @@ export async function executeWorkflowWithFullLogging(
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = useExecutionStore.getState()
const workflowEdges = useWorkflowStore.getState().edges
- // Track active blocks for pulsing animation
const activeBlocksSet = new Set()
const payload: any = {
@@ -59,7 +58,6 @@ export async function executeWorkflowWithFullLogging(
throw new Error('No response body')
}
- // Parse SSE stream
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
@@ -89,11 +87,9 @@ export async function executeWorkflowWithFullLogging(
switch (event.type) {
case 'block:started': {
- // Add block to active set for pulsing animation
activeBlocksSet.add(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
- // Track edges that led to this block as soon as execution starts
const incomingEdges = workflowEdges.filter(
(edge) => edge.target === event.data.blockId
)
@@ -104,11 +100,9 @@ export async function executeWorkflowWithFullLogging(
}
case 'block:completed':
- // Remove block from active set
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
- // Track successful block execution in run path
setBlockRunStatus(event.data.blockId, 'success')
addConsole({
@@ -134,11 +128,9 @@ export async function executeWorkflowWithFullLogging(
break
case 'block:error':
- // Remove block from active set
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
- // Track failed block execution in run path
setBlockRunStatus(event.data.blockId, 'error')
addConsole({
@@ -183,7 +175,6 @@ export async function executeWorkflowWithFullLogging(
}
} finally {
reader.releaseLock()
- // Clear active blocks when execution ends
setActiveBlocks(new Set())
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 7b4b56ff47..b906664058 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -1337,6 +1337,11 @@ const WorkflowContent = React.memo(() => {
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
+ const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
+ blockType: type,
+ targetParentId: null,
+ })
+
addBlock(
id,
type,
@@ -1349,7 +1354,7 @@ const WorkflowContent = React.memo(() => {
},
undefined,
undefined,
- undefined
+ autoConnectEdge
)
return
@@ -1368,6 +1373,12 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
+ const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
+ blockType: type,
+ enableTriggerMode,
+ targetParentId: null,
+ })
+
addBlock(
id,
type,
@@ -1376,7 +1387,7 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
undefined,
- undefined,
+ autoConnectEdge,
enableTriggerMode
)
}
@@ -1395,6 +1406,7 @@ const WorkflowContent = React.memo(() => {
addBlock,
effectivePermissions.canEdit,
checkTriggerConstraints,
+ tryCreateAutoConnectEdge,
])
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx
new file mode 100644
index 0000000000..2db914d741
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx
@@ -0,0 +1,680 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { ChevronDown as ChevronDownIcon, X } from 'lucide-react'
+import { ReactFlowProvider } from 'reactflow'
+import { Badge, Button, ChevronDown, Code } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
+import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
+import { getBlock } from '@/blocks'
+import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
+import { normalizeName } from '@/executor/constants'
+import { navigatePath } from '@/executor/variables/resolvers/reference'
+import type { BlockState } from '@/stores/workflows/workflow/types'
+
+/**
+ * Evaluate whether a subblock's condition is met based on current values.
+ */
+function evaluateCondition(
+ condition: SubBlockConfig['condition'],
+ subBlockValues: Record
+): boolean {
+ if (!condition) return true
+
+ const actualCondition = typeof condition === 'function' ? condition() : condition
+
+ const fieldValueObj = subBlockValues[actualCondition.field]
+ const fieldValue =
+ fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
+ ? (fieldValueObj as { value: unknown }).value
+ : fieldValueObj
+
+ const conditionValues = Array.isArray(actualCondition.value)
+ ? actualCondition.value
+ : [actualCondition.value]
+
+ let isMatch = conditionValues.some((v) => v === fieldValue)
+
+ if (actualCondition.not) {
+ isMatch = !isMatch
+ }
+
+ if (actualCondition.and && isMatch) {
+ const andFieldValueObj = subBlockValues[actualCondition.and.field]
+ const andFieldValue =
+ andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
+ ? (andFieldValueObj as { value: unknown }).value
+ : andFieldValueObj
+
+ const andConditionValues = Array.isArray(actualCondition.and.value)
+ ? actualCondition.and.value
+ : [actualCondition.and.value]
+
+ let andMatch = andConditionValues.some((v) => v === andFieldValue)
+
+ if (actualCondition.and.not) {
+ andMatch = !andMatch
+ }
+
+ isMatch = isMatch && andMatch
+ }
+
+ return isMatch
+}
+
+/**
+ * Format a value for display as JSON string
+ */
+function formatValueAsJson(value: unknown): string {
+ if (value === null || value === undefined || value === '') {
+ return '—'
+ }
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch {
+ return String(value)
+ }
+ }
+ return String(value)
+}
+
+interface ResolvedConnection {
+ blockId: string
+ blockName: string
+ blockType: string
+ fields: Array<{ path: string; value: string; tag: string }>
+}
+
+/**
+ * Extract all variable references from nested subblock values
+ */
+function extractAllReferencesFromSubBlocks(subBlockValues: Record): string[] {
+ const refs = new Set()
+
+ const processValue = (value: unknown) => {
+ if (typeof value === 'string') {
+ const extracted = extractReferencePrefixes(value)
+ extracted.forEach((ref) => refs.add(ref.raw))
+ } else if (Array.isArray(value)) {
+ value.forEach(processValue)
+ } else if (value && typeof value === 'object') {
+ if ('value' in value) {
+ processValue((value as { value: unknown }).value)
+ } else {
+ Object.values(value).forEach(processValue)
+ }
+ }
+ }
+
+ Object.values(subBlockValues).forEach(processValue)
+ return Array.from(refs)
+}
+
+/**
+ * Format a value for inline display (single line, truncated)
+ */
+function formatInlineValue(value: unknown): string {
+ if (value === null || value === undefined) return 'null'
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return String(value)
+ }
+ }
+ return String(value)
+}
+
+interface ExecutionDataSectionProps {
+ title: string
+ data: unknown
+ isError?: boolean
+}
+
+/**
+ * Collapsible section for execution data (input/output)
+ * Uses Code.Viewer for proper syntax highlighting matching the logs UI
+ */
+function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) {
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ const jsonString = useMemo(() => {
+ if (!data) return ''
+ return formatValueAsJson(data)
+ }, [data])
+
+ const isEmpty = jsonString === '—' || jsonString === ''
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ setIsExpanded(!isExpanded)
+ }
+ }}
+ role='button'
+ tabIndex={0}
+ aria-expanded={isExpanded}
+ aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${title.toLowerCase()}`}
+ >
+
+ {title}
+
+
+
+
+ {isExpanded && (
+ <>
+ {isEmpty ? (
+
+ No data
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ )
+}
+
+/**
+ * Section showing resolved variable references - styled like the connections section in editor
+ */
+function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) {
+ const [isCollapsed, setIsCollapsed] = useState(false)
+ const [expandedBlocks, setExpandedBlocks] = useState>(new Set())
+
+ useEffect(() => {
+ setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
+ }, [connections])
+
+ if (connections.length === 0) return null
+
+ const toggleBlock = (blockId: string) => {
+ setExpandedBlocks((prev) => {
+ const next = new Set(prev)
+ if (next.has(blockId)) {
+ next.delete(blockId)
+ } else {
+ next.add(blockId)
+ }
+ return next
+ })
+ }
+
+ return (
+
+ {/* Header with Chevron */}
+
setIsCollapsed(!isCollapsed)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ setIsCollapsed(!isCollapsed)
+ }
+ }}
+ role='button'
+ tabIndex={0}
+ aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'}
+ >
+
+
Connections
+
+
+ {/* Content - styled like ConnectionBlocks */}
+ {!isCollapsed && (
+
+ {connections.map((connection) => {
+ const blockConfig = getBlock(connection.blockType)
+ const Icon = blockConfig?.icon
+ const bgColor = blockConfig?.bgColor || '#6B7280'
+ const isExpanded = expandedBlocks.has(connection.blockId)
+ const hasFields = connection.fields.length > 0
+
+ return (
+
+ {/* Block header - styled like ConnectionItem */}
+
hasFields && toggleBlock(connection.blockId)}
+ >
+
+ {Icon && (
+
+ )}
+
+
+ {connection.blockName}
+
+ {hasFields && (
+
+ )}
+
+
+ {/* Fields - styled like FieldItem but showing resolved values */}
+ {isExpanded && hasFields && (
+
+
+ {connection.fields.map((field) => (
+
+
+ {field.path}
+
+
+ {field.value}
+
+
+ ))}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+/**
+ * Icon component for rendering block icons
+ */
+function IconComponent({
+ icon: Icon,
+ className,
+}: {
+ icon: BlockIcon | undefined
+ className?: string
+}) {
+ if (!Icon) return null
+ return
+}
+
+interface ExecutionData {
+ input?: unknown
+ output?: unknown
+ status?: string
+ durationMs?: number
+}
+
+interface BlockDetailsSidebarProps {
+ block: BlockState
+ executionData?: ExecutionData
+ /** All block execution data for resolving variable references */
+ allBlockExecutions?: Record
+ /** All workflow blocks for mapping block names to IDs */
+ workflowBlocks?: Record
+ /** When true, shows "Not Executed" badge if no executionData is provided */
+ isExecutionMode?: boolean
+ /** Optional close handler - if not provided, no close button is shown */
+ onClose?: () => void
+}
+
+/**
+ * Format duration for display
+ */
+function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`
+ return `${(ms / 1000).toFixed(2)}s`
+}
+
+/**
+ * Readonly sidebar panel showing block configuration using SubBlock components.
+ */
+function BlockDetailsSidebarContent({
+ block,
+ executionData,
+ allBlockExecutions,
+ workflowBlocks,
+ isExecutionMode = false,
+ onClose,
+}: BlockDetailsSidebarProps) {
+ const blockConfig = getBlock(block.type) as BlockConfig | undefined
+ const subBlockValues = block.subBlocks || {}
+
+ const blockNameToId = useMemo(() => {
+ const map = new Map()
+ if (workflowBlocks) {
+ for (const [blockId, blockData] of Object.entries(workflowBlocks)) {
+ if (blockData.name) {
+ map.set(normalizeName(blockData.name), blockId)
+ }
+ }
+ }
+ return map
+ }, [workflowBlocks])
+
+ const resolveReference = useMemo(() => {
+ return (reference: string): unknown => {
+ if (!allBlockExecutions || !workflowBlocks) return undefined
+ if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
+
+ const inner = reference.slice(1, -1) // Remove < and >
+ const parts = inner.split('.')
+ if (parts.length < 1) return undefined
+
+ const [blockName, ...pathParts] = parts
+ const normalizedBlockName = normalizeName(blockName)
+
+ const blockId = blockNameToId.get(normalizedBlockName)
+ if (!blockId) return undefined
+
+ const blockExecution = allBlockExecutions[blockId]
+ if (!blockExecution?.output) return undefined
+
+ if (pathParts.length === 0) {
+ return blockExecution.output
+ }
+
+ return navigatePath(blockExecution.output, pathParts)
+ }
+ }, [allBlockExecutions, workflowBlocks, blockNameToId])
+
+ // Group resolved variables by source block for display
+ const resolvedConnections = useMemo((): ResolvedConnection[] => {
+ if (!allBlockExecutions || !workflowBlocks) return []
+
+ const allRefs = extractAllReferencesFromSubBlocks(subBlockValues)
+ const seen = new Set()
+ const blockMap = new Map()
+
+ for (const ref of allRefs) {
+ if (seen.has(ref)) continue
+
+ // Parse reference:
+ const inner = ref.slice(1, -1)
+ const parts = inner.split('.')
+ if (parts.length < 1) continue
+
+ const [blockName, ...pathParts] = parts
+ const normalizedBlockName = normalizeName(blockName)
+ const blockId = blockNameToId.get(normalizedBlockName)
+ if (!blockId) continue
+
+ const sourceBlock = workflowBlocks[blockId]
+ if (!sourceBlock) continue
+
+ const resolvedValue = resolveReference(ref)
+ if (resolvedValue === undefined) continue
+
+ seen.add(ref)
+
+ // Get or create block entry
+ if (!blockMap.has(blockId)) {
+ blockMap.set(blockId, {
+ blockId,
+ blockName: sourceBlock.name || blockName,
+ blockType: sourceBlock.type,
+ fields: [],
+ })
+ }
+
+ const connection = blockMap.get(blockId)!
+ connection.fields.push({
+ path: pathParts.join('.') || 'output',
+ value: formatInlineValue(resolvedValue),
+ tag: ref,
+ })
+ }
+
+ return Array.from(blockMap.values())
+ }, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference])
+
+ if (!blockConfig) {
+ return (
+
+
+
+
+ {block.name || 'Unknown Block'}
+
+
+
+
Block configuration not found.
+
+
+ )
+ }
+
+ const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
+ if (subBlock.hidden || subBlock.hideFromPreview) return false
+ if (subBlock.mode === 'trigger') return false
+ if (subBlock.condition) {
+ return evaluateCondition(subBlock.condition, subBlockValues)
+ }
+ return true
+ })
+
+ const statusVariant =
+ executionData?.status === 'error'
+ ? 'red'
+ : executionData?.status === 'success'
+ ? 'green'
+ : 'gray'
+
+ return (
+
+ {/* Header - styled like editor */}
+
+
+
+
+
+ {block.name || blockConfig.name}
+
+ {block.enabled === false && (
+
+ Disabled
+
+ )}
+ {onClose && (
+
+ )}
+
+
+ {/* Scrollable content */}
+
+ {/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
+ {isExecutionMode && !executionData && (
+
+ )}
+
+ {/* Execution Input/Output (if provided) */}
+ {executionData &&
+ (executionData.input !== undefined || executionData.output !== undefined) ? (
+
+ {/* Execution Status & Duration Header */}
+ {(executionData.status || executionData.durationMs !== undefined) && (
+
+ {executionData.status && (
+
+ {executionData.status}
+
+ )}
+ {executionData.durationMs !== undefined && (
+
+ {formatDuration(executionData.durationMs)}
+
+ )}
+
+ )}
+
+ {/* Divider between Status/Duration and Input/Output */}
+ {(executionData.status || executionData.durationMs !== undefined) &&
+ (executionData.input !== undefined || executionData.output !== undefined) && (
+
+ )}
+
+ {/* Input Section */}
+ {executionData.input !== undefined && (
+
+ )}
+
+ {/* Divider between Input and Output */}
+ {executionData.input !== undefined && executionData.output !== undefined && (
+
+ )}
+
+ {/* Output Section */}
+ {executionData.output !== undefined && (
+
+ )}
+
+ ) : null}
+
+ {/* Subblock Values - Using SubBlock components in preview mode */}
+
+ {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
+
+ {visibleSubBlocks.length > 0 ? (
+
+ {visibleSubBlocks.map((subBlockConfig, index) => (
+
+
+ {index < visibleSubBlocks.length - 1 && (
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+ No configurable fields for this block.
+
+
+ )}
+
+
+
+ {/* Resolved Variables Section - Pinned at bottom, outside scrollable area */}
+ {resolvedConnections.length > 0 && (
+
+ )}
+
+ )
+}
+
+/**
+ * Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
+ */
+export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx
similarity index 95%
rename from apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx
index d8986c7c47..423ad95032 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx
@@ -3,7 +3,7 @@
import { memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
-import { getBlock } from '@/blocks/registry'
+import { getBlock } from '@/blocks'
interface WorkflowPreviewBlockData {
type: string
@@ -29,10 +29,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
}
const IconComponent = blockConfig.icon
- // Hide input handle for triggers, starters, or blocks in trigger mode
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
- // Get visible subblocks from config (no fetching, just config structure)
const visibleSubBlocks = useMemo(() => {
if (!blockConfig.subBlocks) return []
@@ -48,7 +46,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
const hasSubBlocks = visibleSubBlocks.length > 0
const showErrorRow = !isStarterOrTrigger
- // Handle styles based on orientation
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx
similarity index 96%
rename from apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx
rename to apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx
index a292d661ea..67befddbda 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx
@@ -26,11 +26,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps
}
/**
@@ -105,10 +110,9 @@ export function WorkflowPreview({
onNodeClick,
lightweight = false,
cursorStyle = 'grab',
+ executedBlocks,
}: WorkflowPreviewProps) {
- // Use lightweight node types for better performance in template cards
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
- // Check if the workflow state is valid
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
@@ -178,9 +182,7 @@ export function WorkflowPreview({
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
- // Lightweight mode: create minimal node data for performance
if (lightweight) {
- // Handle loops and parallels as subflow nodes
if (block.type === 'loop' || block.type === 'parallel') {
nodeArray.push({
id: blockId,
@@ -197,7 +199,6 @@ export function WorkflowPreview({
return
}
- // Regular blocks
nodeArray.push({
id: blockId,
type: 'workflowBlock',
@@ -214,10 +215,9 @@ export function WorkflowPreview({
return
}
- // Full mode: create detailed node data for interactive previews
if (block.type === 'loop') {
nodeArray.push({
- id: block.id,
+ id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
@@ -238,7 +238,7 @@ export function WorkflowPreview({
if (block.type === 'parallel') {
nodeArray.push({
- id: block.id,
+ id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
@@ -265,11 +265,31 @@ export function WorkflowPreview({
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
+ let executionStatus: ExecutionStatus | undefined
+ if (executedBlocks) {
+ const blockExecution = executedBlocks[blockId]
+ if (blockExecution) {
+ if (blockExecution.status === 'error') {
+ executionStatus = 'error'
+ } else if (blockExecution.status === 'success') {
+ executionStatus = 'success'
+ } else {
+ executionStatus = 'not-executed'
+ }
+ } else {
+ executionStatus = 'not-executed'
+ }
+ }
+
nodeArray.push({
id: blockId,
type: nodeType,
position: absolutePosition,
draggable: false,
+ className:
+ executionStatus && executionStatus !== 'not-executed'
+ ? `execution-${executionStatus}`
+ : undefined,
data: {
type: block.type,
config: blockConfig,
@@ -278,43 +298,9 @@ export function WorkflowPreview({
canEdit: false,
isPreview: true,
subBlockValues: block.subBlocks ?? {},
+ executionStatus,
},
})
-
- if (block.type === 'loop') {
- const childBlocks = Object.entries(workflowState.blocks || {}).filter(
- ([_, childBlock]) => childBlock.data?.parentId === blockId
- )
-
- childBlocks.forEach(([childId, childBlock]) => {
- const childConfig = getBlock(childBlock.type)
-
- if (childConfig) {
- const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock'
-
- nodeArray.push({
- id: childId,
- type: childNodeType,
- position: {
- x: block.position.x + 50,
- y: block.position.y + (childBlock.position?.y || 100),
- },
- data: {
- type: childBlock.type,
- config: childConfig,
- name: childBlock.name,
- blockState: childBlock,
- showSubBlocks,
- isChild: true,
- parentId: blockId,
- canEdit: false,
- isPreview: true,
- },
- draggable: false,
- })
- }
- })
- }
})
return nodeArray
@@ -326,21 +312,42 @@ export function WorkflowPreview({
workflowState.blocks,
isValidWorkflowState,
lightweight,
+ executedBlocks,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
- return (workflowState.edges || []).map((edge) => ({
- id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
- }))
- }, [edgesStructure, workflowState.edges, isValidWorkflowState])
+ return (workflowState.edges || []).map((edge) => {
+ let executionStatus: ExecutionStatus | undefined
+ if (executedBlocks) {
+ const sourceExecuted = executedBlocks[edge.source]
+ const targetExecuted = executedBlocks[edge.target]
+
+ if (sourceExecuted && targetExecuted) {
+ if (targetExecuted.status === 'error') {
+ executionStatus = 'error'
+ } else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') {
+ executionStatus = 'success'
+ } else {
+ executionStatus = 'not-executed'
+ }
+ } else {
+ executionStatus = 'not-executed'
+ }
+ }
+
+ return {
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle,
+ targetHandle: edge.targetHandle,
+ data: executionStatus ? { executionStatus } : undefined,
+ }
+ })
+ }, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
- // Handle migrated logs that don't have complete workflow state
if (!isValidWorkflowState) {
return (
- {cursorStyle && (
-
- )}
+
k.providerId === providerId)
}
+ // Show enterprise-only gate if BYOK is not enabled
+ if (!isLoading && !byokEnabled) {
+ return (
+
+
+
+
+
+
Enterprise Feature
+
+ Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
+ use your own API keys and eliminate the 2x cost multiplier.
+
+
+
+
+ )
+ }
+
const handleSave = async () => {
if (!editingProvider || !apiKeyInput.trim()) return
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
new file mode 100644
index 0000000000..79f3cf895a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx
@@ -0,0 +1,1170 @@
+'use client'
+
+import { type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { Paperclip, Plus, Search, X } from 'lucide-react'
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ Badge,
+ Button,
+ Input,
+ Label,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from '@/components/emcn'
+import { GmailIcon, OutlookIcon } from '@/components/icons'
+import { Input as BaseInput, Skeleton } from '@/components/ui'
+import { useSession } from '@/lib/auth/auth-client'
+import { getSubscriptionStatus } from '@/lib/billing/client'
+import { cn } from '@/lib/core/utils/cn'
+import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers'
+import { quickValidateEmail } from '@/lib/messaging/email/validation'
+import { getUserColor } from '@/lib/workspaces/colors'
+import { getUserRole } from '@/lib/workspaces/organization'
+import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
+import {
+ type CredentialSet,
+ useAcceptCredentialSetInvitation,
+ useCancelCredentialSetInvitation,
+ useCreateCredentialSet,
+ useCreateCredentialSetInvitation,
+ useCredentialSetInvitations,
+ useCredentialSetInvitationsDetail,
+ useCredentialSetMembers,
+ useCredentialSetMemberships,
+ useCredentialSets,
+ useDeleteCredentialSet,
+ useLeaveCredentialSet,
+ useRemoveCredentialSetMember,
+ useResendCredentialSetInvitation,
+} from '@/hooks/queries/credential-sets'
+import { useOrganizations } from '@/hooks/queries/organization'
+import { useSubscriptionData } from '@/hooks/queries/subscription'
+
+const logger = createLogger('EmailPolling')
+
+function CredentialSetsSkeleton() {
+ return (
+
+ )
+}
+
+export function CredentialSets() {
+ const { data: session } = useSession()
+ const { data: organizationsData } = useOrganizations()
+ const { data: subscriptionData } = useSubscriptionData()
+
+ const activeOrganization = organizationsData?.activeOrganization
+ const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
+ const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
+ const userRole = getUserRole(activeOrganization, session?.user?.email)
+ const isAdmin = userRole === 'admin' || userRole === 'owner'
+ const canManageCredentialSets = hasTeamPlan && isAdmin && !!activeOrganization?.id
+
+ const { data: memberships = [], isPending: membershipsLoading } = useCredentialSetMemberships()
+ const { data: invitations = [], isPending: invitationsLoading } = useCredentialSetInvitations()
+ const { data: ownedSets = [], isPending: ownedSetsLoading } = useCredentialSets(
+ activeOrganization?.id,
+ canManageCredentialSets
+ )
+
+ const acceptInvitation = useAcceptCredentialSetInvitation()
+ const createCredentialSet = useCreateCredentialSet()
+ const createInvitation = useCreateCredentialSetInvitation()
+
+ const [searchTerm, setSearchTerm] = useState('')
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [viewingSet, setViewingSet] = useState(null)
+ const [newSetName, setNewSetName] = useState('')
+ const [newSetDescription, setNewSetDescription] = useState('')
+ const [newSetProvider, setNewSetProvider] = useState('google-email')
+ const [createError, setCreateError] = useState(null)
+ const [emails, setEmails] = useState([])
+ const [invalidEmails, setInvalidEmails] = useState([])
+ const [duplicateEmails, setDuplicateEmails] = useState([])
+ const [inputValue, setInputValue] = useState('')
+ const [isDragging, setIsDragging] = useState(false)
+ const fileInputRef = useRef(null)
+ const [leavingMembership, setLeavingMembership] = useState<{
+ credentialSetId: string
+ name: string
+ } | null>(null)
+
+ const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id)
+ const { data: pendingInvitations = [], isPending: pendingInvitationsLoading } =
+ useCredentialSetInvitationsDetail(viewingSet?.id)
+ const removeMember = useRemoveCredentialSetMember()
+ const leaveCredentialSet = useLeaveCredentialSet()
+ const deleteCredentialSet = useDeleteCredentialSet()
+ const cancelInvitation = useCancelCredentialSetInvitation()
+ const resendInvitation = useResendCredentialSetInvitation()
+
+ const [deletingSet, setDeletingSet] = useState<{ id: string; name: string } | null>(null)
+ const [deletingSetIds, setDeletingSetIds] = useState>(new Set())
+ const [cancellingInvitations, setCancellingInvitations] = useState>(new Set())
+ const [resendingInvitations, setResendingInvitations] = useState>(new Set())
+ const [resendCooldowns, setResendCooldowns] = useState>({})
+
+ const extractEmailsFromText = useCallback((text: string): string[] => {
+ const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
+ const matches = text.match(emailRegex) || []
+ return [...new Set(matches.map((e) => e.toLowerCase()))]
+ }, [])
+
+ const addEmail = useCallback(
+ (email: string) => {
+ if (!email.trim()) return false
+
+ const normalized = email.trim().toLowerCase()
+ const validation = quickValidateEmail(normalized)
+ const isValid = validation.isValid
+
+ if (
+ emails.includes(normalized) ||
+ invalidEmails.includes(normalized) ||
+ duplicateEmails.includes(normalized)
+ ) {
+ return false
+ }
+
+ const isPendingInvitation = pendingInvitations.some(
+ (inv) => inv.email?.toLowerCase() === normalized
+ )
+ if (isPendingInvitation) {
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return false
+ }
+
+ const isActiveMember = members.some(
+ (m) => m.userEmail?.toLowerCase() === normalized && m.status === 'active'
+ )
+ if (isActiveMember) {
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return false
+ }
+
+ if (!isValid) {
+ setInvalidEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return false
+ }
+
+ setEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
+ setInputValue('')
+ return true
+ },
+ [emails, invalidEmails, duplicateEmails, pendingInvitations, members]
+ )
+
+ const removeEmail = useCallback((index: number) => {
+ setEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const removeInvalidEmail = useCallback((index: number) => {
+ setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const removeDuplicateEmail = useCallback((index: number) => {
+ setDuplicateEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const handleEmailKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (inputValue.trim()) {
+ addEmail(inputValue)
+ }
+ return
+ }
+
+ if ([',', ' '].includes(e.key) && inputValue.trim()) {
+ e.preventDefault()
+ addEmail(inputValue)
+ }
+
+ if (e.key === 'Backspace' && !inputValue) {
+ if (duplicateEmails.length > 0) {
+ removeDuplicateEmail(duplicateEmails.length - 1)
+ } else if (invalidEmails.length > 0) {
+ removeInvalidEmail(invalidEmails.length - 1)
+ } else if (emails.length > 0) {
+ removeEmail(emails.length - 1)
+ }
+ }
+ },
+ [
+ inputValue,
+ addEmail,
+ duplicateEmails,
+ invalidEmails,
+ emails,
+ removeDuplicateEmail,
+ removeInvalidEmail,
+ removeEmail,
+ ]
+ )
+
+ const handleEmailPaste = useCallback(
+ (e: React.ClipboardEvent) => {
+ e.preventDefault()
+ const pastedText = e.clipboardData.getData('text')
+ const pastedEmails = extractEmailsFromText(pastedText)
+
+ pastedEmails.forEach((email) => {
+ addEmail(email)
+ })
+ },
+ [addEmail, extractEmailsFromText]
+ )
+
+ const handleFileDrop = useCallback(
+ async (file: File) => {
+ try {
+ const text = await file.text()
+ const extractedEmails = extractEmailsFromText(text)
+ extractedEmails.forEach((email) => {
+ addEmail(email)
+ })
+ } catch (error) {
+ logger.error('Error reading dropped file', error)
+ }
+ },
+ [extractEmailsFromText, addEmail]
+ )
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'copy'
+ setIsDragging(true)
+ }, [])
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+ }, [])
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+
+ const files = Array.from(e.dataTransfer.files)
+ const validFiles = files.filter(
+ (f) =>
+ f.type === 'text/csv' ||
+ f.type === 'text/plain' ||
+ f.name.endsWith('.csv') ||
+ f.name.endsWith('.txt')
+ )
+
+ for (const file of validFiles) {
+ await handleFileDrop(file)
+ }
+ },
+ [handleFileDrop]
+ )
+
+ const handleFileInputChange = useCallback(
+ async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files) return
+
+ for (const file of Array.from(files)) {
+ await handleFileDrop(file)
+ }
+
+ // Reset input so the same file can be selected again
+ e.target.value = ''
+ },
+ [handleFileDrop]
+ )
+
+ const handleRemoveMember = useCallback(
+ async (memberId: string) => {
+ if (!viewingSet) return
+ try {
+ await removeMember.mutateAsync({
+ credentialSetId: viewingSet.id,
+ memberId,
+ })
+ } catch (error) {
+ logger.error('Failed to remove member', error)
+ }
+ },
+ [viewingSet, removeMember]
+ )
+
+ const handleLeave = useCallback((credentialSetId: string, name: string) => {
+ setLeavingMembership({ credentialSetId, name })
+ }, [])
+
+ const confirmLeave = useCallback(async () => {
+ if (!leavingMembership) return
+ try {
+ await leaveCredentialSet.mutateAsync(leavingMembership.credentialSetId)
+ setLeavingMembership(null)
+ } catch (error) {
+ logger.error('Failed to leave polling group', error)
+ }
+ }, [leavingMembership, leaveCredentialSet])
+
+ const handleAcceptInvitation = useCallback(
+ async (token: string) => {
+ try {
+ await acceptInvitation.mutateAsync(token)
+ } catch (error) {
+ logger.error('Failed to accept invitation', error)
+ }
+ },
+ [acceptInvitation]
+ )
+
+ const handleCreateCredentialSet = useCallback(async () => {
+ if (!newSetName.trim() || !activeOrganization?.id) return
+ setCreateError(null)
+ try {
+ const result = await createCredentialSet.mutateAsync({
+ organizationId: activeOrganization.id,
+ name: newSetName.trim(),
+ description: newSetDescription.trim() || undefined,
+ providerId: newSetProvider,
+ })
+ setShowCreateModal(false)
+ setNewSetName('')
+ setNewSetDescription('')
+ setNewSetProvider('google-email')
+
+ // Open detail view for the newly created group
+ if (result?.credentialSet) {
+ setViewingSet(result.credentialSet)
+ }
+ } catch (error) {
+ logger.error('Failed to create polling group', error)
+ if (error instanceof Error) {
+ setCreateError(error.message)
+ } else {
+ setCreateError('Failed to create polling group')
+ }
+ }
+ }, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet])
+
+ const handleInviteMembers = useCallback(async () => {
+ if (!viewingSet?.id) return
+
+ // Add any pending input value first
+ if (inputValue.trim()) {
+ addEmail(inputValue)
+ }
+
+ if (emails.length === 0) return
+
+ try {
+ for (const email of emails) {
+ await createInvitation.mutateAsync({
+ credentialSetId: viewingSet.id,
+ email,
+ })
+ }
+ setEmails([])
+ setInvalidEmails([])
+ setDuplicateEmails([])
+ setInputValue('')
+ } catch (error) {
+ logger.error('Failed to create invitations', error)
+ }
+ }, [viewingSet?.id, emails, inputValue, addEmail, createInvitation])
+
+ const handleCloseCreateModal = useCallback(() => {
+ setShowCreateModal(false)
+ setNewSetName('')
+ setNewSetDescription('')
+ setNewSetProvider('google-email')
+ setCreateError(null)
+ }, [])
+
+ const handleBackToList = useCallback(() => {
+ setViewingSet(null)
+ setEmails([])
+ setInvalidEmails([])
+ setDuplicateEmails([])
+ setInputValue('')
+ }, [])
+
+ const handleCancelInvitation = useCallback(
+ async (invitationId: string) => {
+ if (!viewingSet?.id) return
+
+ setCancellingInvitations((prev) => new Set([...prev, invitationId]))
+ try {
+ await cancelInvitation.mutateAsync({
+ credentialSetId: viewingSet.id,
+ invitationId,
+ })
+ } catch (error) {
+ logger.error('Failed to cancel invitation', error)
+ } finally {
+ setCancellingInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ },
+ [viewingSet?.id, cancelInvitation]
+ )
+
+ const handleResendInvitation = useCallback(
+ async (invitationId: string, email: string) => {
+ if (!viewingSet?.id) return
+
+ const secondsLeft = resendCooldowns[invitationId]
+ if (secondsLeft && secondsLeft > 0) return
+
+ setResendingInvitations((prev) => new Set([...prev, invitationId]))
+ try {
+ await resendInvitation.mutateAsync({
+ credentialSetId: viewingSet.id,
+ invitationId,
+ email,
+ })
+
+ // Start 60s cooldown
+ setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
+ const interval = setInterval(() => {
+ setResendCooldowns((prev) => {
+ const current = prev[invitationId]
+ if (current === undefined) return prev
+ if (current <= 1) {
+ const next = { ...prev }
+ delete next[invitationId]
+ clearInterval(interval)
+ return next
+ }
+ return { ...prev, [invitationId]: current - 1 }
+ })
+ }, 1000)
+ } catch (error) {
+ logger.error('Failed to resend invitation', error)
+ } finally {
+ setResendingInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ },
+ [viewingSet?.id, resendInvitation, resendCooldowns]
+ )
+
+ const handleDeleteClick = useCallback((set: CredentialSet) => {
+ setDeletingSet({ id: set.id, name: set.name })
+ }, [])
+
+ const confirmDelete = useCallback(async () => {
+ if (!deletingSet || !activeOrganization?.id) return
+ setDeletingSetIds((prev) => new Set(prev).add(deletingSet.id))
+ try {
+ await deleteCredentialSet.mutateAsync({
+ credentialSetId: deletingSet.id,
+ organizationId: activeOrganization.id,
+ })
+ setDeletingSet(null)
+ } catch (error) {
+ logger.error('Failed to delete polling group', error)
+ } finally {
+ setDeletingSetIds((prev) => {
+ const next = new Set(prev)
+ next.delete(deletingSet.id)
+ return next
+ })
+ }
+ }, [deletingSet, activeOrganization?.id, deleteCredentialSet])
+
+ const getProviderIcon = (providerId: string | null) => {
+ if (providerId === 'outlook') return
+ return
+ }
+
+ // All hooks must be called before any early returns
+ const activeMemberships = useMemo(
+ () => memberships.filter((m) => m.status === 'active'),
+ [memberships]
+ )
+
+ const filteredInvitations = useMemo(() => {
+ if (!searchTerm.trim()) return invitations
+ const searchLower = searchTerm.toLowerCase()
+ return invitations.filter(
+ (inv) =>
+ inv.credentialSetName.toLowerCase().includes(searchLower) ||
+ inv.organizationName.toLowerCase().includes(searchLower)
+ )
+ }, [invitations, searchTerm])
+
+ const filteredMemberships = useMemo(() => {
+ if (!searchTerm.trim()) return activeMemberships
+ const searchLower = searchTerm.toLowerCase()
+ return activeMemberships.filter(
+ (m) =>
+ m.credentialSetName.toLowerCase().includes(searchLower) ||
+ m.organizationName.toLowerCase().includes(searchLower)
+ )
+ }, [activeMemberships, searchTerm])
+
+ const filteredOwnedSets = useMemo(() => {
+ if (!searchTerm.trim()) return ownedSets
+ const searchLower = searchTerm.toLowerCase()
+ return ownedSets.filter((set) => set.name.toLowerCase().includes(searchLower))
+ }, [ownedSets, searchTerm])
+
+ const hasNoContent =
+ invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0
+ const hasNoResults =
+ searchTerm.trim() &&
+ filteredInvitations.length === 0 &&
+ filteredMemberships.length === 0 &&
+ filteredOwnedSets.length === 0 &&
+ !hasNoContent
+
+ // Early returns AFTER all hooks
+ if (membershipsLoading || invitationsLoading) {
+ return
+ }
+
+ // Detail view for a polling group
+ if (viewingSet) {
+ const activeMembers = members.filter((m) => m.status === 'active')
+ const totalCount = activeMembers.length + pendingInvitations.length
+
+ return (
+ <>
+
+
+
+ {/* Group Info */}
+
+
+
+ Group Name
+
+
+ {viewingSet.name}
+
+
+
+
+
+ Provider
+
+
+ {getProviderIcon(viewingSet.providerId)}
+
+ {getProviderDisplayName(viewingSet.providerId as PollingProvider)}
+
+
+
+
+
+ {/* Invite Section - Email Tags Input */}
+
+
+
+ {isDragging && (
+
+
+ Drop file here
+
+
+ )}
+ {invalidEmails.map((email, index) => (
+
removeInvalidEmail(index)}
+ disabled={createInvitation.isPending}
+ isInvalid={true}
+ />
+ ))}
+ {duplicateEmails.map((email, index) => (
+
+ {email}
+ duplicate
+ {!createInvitation.isPending && (
+
+ )}
+
+ ))}
+ {emails.map((email, index) => (
+ removeEmail(index)}
+ disabled={createInvitation.isPending}
+ />
+ ))}
+
+
setInputValue(e.target.value)}
+ onKeyDown={handleEmailKeyDown}
+ onPaste={handleEmailPaste}
+ onBlur={() => inputValue.trim() && addEmail(inputValue)}
+ placeholder={
+ emails.length > 0 || invalidEmails.length > 0 || duplicateEmails.length > 0
+ ? 'Add another email'
+ : 'Enter email addresses'
+ }
+ className='h-6 min-w-[140px] flex-1 border-none bg-transparent p-0 pl-[4px] text-[13px] outline-none placeholder:text-[var(--text-tertiary)]'
+ disabled={createInvitation.isPending}
+ />
+
+
+
+
+
+
+ {/* Members List - styled like team members */}
+
+
Members
+
+ {membersLoading || pendingInvitationsLoading ? (
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+ ) : totalCount === 0 ? (
+
+ No members yet. Send invitations above.
+
+ ) : (
+
+ {/* Active Members */}
+ {activeMembers.map((member) => {
+ const name = member.userName || 'Unknown'
+ const avatarInitial = name.charAt(0).toUpperCase()
+
+ return (
+
+
+
+ {member.userImage && (
+
+ )}
+
+ {avatarInitial}
+
+
+
+
+
+
+ {name}
+
+ {member.credentials.length === 0 && (
+
+ Disconnected
+
+ )}
+
+
+ {member.userEmail}
+
+
+
+
+
+
+
+
+ )
+ })}
+
+ {/* Pending Invitations */}
+ {pendingInvitations.map((invitation) => {
+ const email = invitation.email || 'Unknown'
+ const emailPrefix = email.split('@')[0]
+ const avatarInitial = emailPrefix.charAt(0).toUpperCase()
+
+ return (
+
+
+
+
+ {avatarInitial}
+
+
+
+
+
+
+ {emailPrefix}
+
+
+ Pending
+
+
+
+ {email}
+
+
+
+
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ {/* Footer Actions */}
+
+
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
+ />
+
+ {canManageCredentialSets && (
+
+ )}
+
+
+
+ {hasNoContent && !canManageCredentialSets ? (
+
+ You're not a member of any polling groups yet. When someone invites you, it will
+ appear here.
+
+ ) : hasNoResults ? (
+
+ No results found matching "{searchTerm}"
+
+ ) : (
+
+ {filteredInvitations.length > 0 && (
+
+
+ Pending Invitations
+
+ {filteredInvitations.map((invitation) => (
+
+
+
+ {getProviderIcon(invitation.providerId)}
+
+
+
+ {invitation.credentialSetName}
+
+
+ {invitation.organizationName}
+
+
+
+
+
+ ))}
+
+ )}
+
+ {filteredMemberships.length > 0 && (
+
+
+ My Memberships
+
+ {filteredMemberships.map((membership) => (
+
+
+
+ {getProviderIcon(membership.providerId)}
+
+
+
+ {membership.credentialSetName}
+
+
+ {membership.organizationName}
+
+
+
+
+
+ ))}
+
+ )}
+
+ {canManageCredentialSets &&
+ (filteredOwnedSets.length > 0 ||
+ ownedSetsLoading ||
+ (!searchTerm.trim() && ownedSets.length === 0)) && (
+
+
+ Manage
+
+ {ownedSetsLoading ? (
+ <>
+ {[1, 2].map((i) => (
+
+ ))}
+ >
+ ) : !searchTerm.trim() && ownedSets.length === 0 ? (
+
+ No polling groups created yet
+
+ ) : (
+ filteredOwnedSets.map((set) => (
+
+
+
+ {getProviderIcon(set.providerId)}
+
+
+ {set.name}
+
+ {set.memberCount} member{set.memberCount !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+ )}
+
+ )}
+
+
+
+ {/* Create Polling Group Modal */}
+
+
+ Create Polling Group
+
+
+
+
+ {
+ setNewSetName(e.target.value)
+ if (createError) setCreateError(null)
+ }}
+ placeholder='e.g., Marketing Team'
+ />
+
+
+
+ setNewSetDescription(e.target.value)}
+ placeholder='e.g., Poll emails for marketing automations'
+ />
+
+
+
+
+
+
+
+
+ Members will connect their {getProviderDisplayName(newSetProvider)} account
+
+
+ {createError &&
{createError}
}
+
+
+
+
+
+
+
+
+
+ {/* Leave Confirmation Modal */}
+ setLeavingMembership(null)}>
+
+ Leave Polling Group
+
+
+ Are you sure you want to leave{' '}
+
+ {leavingMembership?.name}
+
+ ? Your email account will no longer be polled in workflows using this group.
+
+
+
+
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ setDeletingSet(null)}>
+
+ Delete Polling Group
+
+
+ Are you sure you want to delete{' '}
+ {deletingSet?.name}?{' '}
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx
index d8ad59069d..3e78cf5185 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx
@@ -480,7 +480,7 @@ export function General({ onOpenChange }: GeneralProps) {
-
+
-
-
- Invitation sent successfully
- {selectedCount > 0 &&
- ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
-
-
+
+ Invitation sent successfully
+ {selectedCount > 0 &&
+ ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx
index d5d1016b59..4e62f6e462 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx
@@ -3,9 +3,13 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn'
+import { getUserColor } from '@/lib/workspaces/colors'
import type { Invitation, Member, Organization } from '@/lib/workspaces/organization'
-import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
-import { useCancelInvitation, useOrganizationMembers } from '@/hooks/queries/organization'
+import {
+ useCancelInvitation,
+ useOrganizationMembers,
+ useResendInvitation,
+} from '@/hooks/queries/organization'
const logger = createLogger('TeamMembers')
@@ -46,12 +50,16 @@ export function TeamMembers({
onRemoveMember,
}: TeamMembersProps) {
const [cancellingInvitations, setCancellingInvitations] = useState
>(new Set())
+ const [resendingInvitations, setResendingInvitations] = useState>(new Set())
+ const [resentInvitations, setResentInvitations] = useState>(new Set())
+ const [resendCooldowns, setResendCooldowns] = useState>({})
const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers(
organization?.id || ''
)
const cancelInvitationMutation = useCancelInvitation()
+ const resendInvitationMutation = useResendInvitation()
const memberUsageData: Record = {}
if (memberUsageResponse?.data) {
@@ -140,6 +148,54 @@ export function TeamMembers({
}
}
+ const handleResendInvitation = async (invitationId: string) => {
+ if (!organization?.id) return
+
+ const secondsLeft = resendCooldowns[invitationId]
+ if (secondsLeft && secondsLeft > 0) return
+
+ setResendingInvitations((prev) => new Set([...prev, invitationId]))
+ try {
+ await resendInvitationMutation.mutateAsync({
+ invitationId,
+ orgId: organization.id,
+ })
+
+ setResentInvitations((prev) => new Set([...prev, invitationId]))
+ setTimeout(() => {
+ setResentInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }, 4000)
+
+ // Start 60s cooldown
+ setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
+ const interval = setInterval(() => {
+ setResendCooldowns((prev) => {
+ const current = prev[invitationId]
+ if (current === undefined) return prev
+ if (current <= 1) {
+ const next = { ...prev }
+ delete next[invitationId]
+ clearInterval(interval)
+ return next
+ }
+ return { ...prev, [invitationId]: current - 1 }
+ })
+ }, 1000)
+ } catch (error) {
+ logger.error('Failed to resend invitation', { error })
+ } finally {
+ setResendingInvitations((prev) => {
+ const next = new Set(prev)
+ next.delete(invitationId)
+ return next
+ })
+ }
+ }
+
return (
{/* Header */}
@@ -148,13 +204,13 @@ export function TeamMembers({
{/* Members list */}
-
+
{teamItems.map((item) => (
{/* Left section: Avatar + Name/Role + Action buttons */}
{/* Avatar */}
-
+
{item.avatarUrl && }
)}
{item.type === 'invitation' && (
-
+
Pending
)}
-
{item.email}
+
{item.email}
- {/* Action buttons */}
- {isAdminOrOwner && (
- <>
- {/* Admin/Owner can remove other members */}
- {item.type === 'member' &&
- item.role !== 'owner' &&
- item.email !== currentUserEmail && (
-
- )}
-
- {/* Admin can cancel invitations */}
- {item.type === 'invitation' && (
+ {/* Action buttons for members */}
+ {isAdminOrOwner &&
+ item.type === 'member' &&
+ item.role !== 'owner' &&
+ item.email !== currentUserEmail && (
+
+ )}
+
+
+ {/* Right section */}
+ {isAdminOrOwner && (
+
+ {item.type === 'member' ? (
+ <>
+
Usage
+
+ {isLoadingUsage ? (
+
+ ) : (
+ item.usage
+ )}
+
+ >
+ ) : (
+
+
- )}
- >
- )}
-
-
- {/* Right section: Usage column (right-aligned) */}
- {isAdminOrOwner && (
-
-
Usage
-
- {isLoadingUsage && item.type === 'member' ? (
-
- ) : (
- item.usage
- )}
-
+
+ )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
index b6521f7434..63c6748519 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
-import { Files, KeySquare, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
+import { Files, KeySquare, LogIn, Mail, Server, Settings, User, Users, Wrench } from 'lucide-react'
import {
Card,
Connections,
@@ -32,6 +32,7 @@ import {
ApiKeys,
BYOK,
Copilot,
+ CredentialSets,
CustomTools,
EnvironmentVariables,
FileUploads,
@@ -52,6 +53,7 @@ import { useSettingsModalStore } from '@/stores/settings-modal/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
+const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED'))
interface SettingsModalProps {
open: boolean
@@ -63,6 +65,7 @@ type SettingsSection =
| 'environment'
| 'template-profile'
| 'integrations'
+ | 'credential-sets'
| 'apikeys'
| 'byok'
| 'files'
@@ -84,8 +87,8 @@ type NavigationItem = {
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
requiresEnterprise?: boolean
- requiresOwner?: boolean
requiresHosted?: boolean
+ selfHostedOverride?: boolean
}
const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -111,11 +114,20 @@ const allNavigationItems: NavigationItem[] = [
icon: Users,
section: 'subscription',
hideWhenBillingDisabled: true,
+ requiresHosted: true,
requiresTeam: true,
},
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
+ {
+ id: 'credential-sets',
+ label: 'Email Polling',
+ icon: Mail,
+ section: 'system',
+ requiresHosted: true,
+ selfHostedOverride: isCredentialSetsEnabled,
+ },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
@@ -125,6 +137,7 @@ const allNavigationItems: NavigationItem[] = [
icon: KeySquare,
section: 'system',
requiresHosted: true,
+ requiresEnterprise: true,
},
{
id: 'copilot',
@@ -139,9 +152,9 @@ const allNavigationItems: NavigationItem[] = [
label: 'Single Sign-On',
icon: LogIn,
section: 'system',
- requiresTeam: true,
+ requiresHosted: true,
requiresEnterprise: true,
- requiresOwner: true,
+ selfHostedOverride: isSSOEnabled,
},
]
@@ -164,8 +177,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const userRole = getUserRole(activeOrganization, userEmail)
const isOwner = userRole === 'owner'
const isAdmin = userRole === 'admin'
- const canManageSSO = isOwner || isAdmin
+ const isOrgAdminOrOwner = isOwner || isAdmin
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
+ const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
@@ -183,29 +197,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
- // SSO has special logic that must be checked before requiresTeam
- if (item.id === 'sso') {
- if (isHosted) {
- return hasOrganization && hasEnterprisePlan && canManageSSO
+ if (item.selfHostedOverride && !isHosted) {
+ if (item.id === 'sso') {
+ const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
+ return !hasProviders || isSSOProviderOwner === true
}
- // For self-hosted, only show SSO tab if explicitly enabled via environment variable
- if (!isSSOEnabled) return false
- // Show tab if user is the SSO provider owner, or if no providers exist yet (to allow initial setup)
- const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
- return !hasProviders || isSSOProviderOwner === true
+ return true
}
- if (item.requiresTeam) {
- const isMember = userRole === 'member' || isAdmin
- const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
-
- if (isMember) return true
- if (isOwner && hasTeamPlan) return true
-
+ if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
return false
}
- if (item.requiresEnterprise && !hasEnterprisePlan) {
+ if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
return false
}
@@ -213,24 +217,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
- if (item.requiresOwner && !isOwner) {
- return false
- }
-
return true
})
}, [
hasOrganization,
+ hasTeamPlan,
hasEnterprisePlan,
- canManageSSO,
+ isOrgAdminOrOwner,
isSSOProviderOwner,
isSSOEnabled,
ssoProvidersData?.providers?.length,
isOwner,
- isAdmin,
- userRole,
- subscriptionStatus.isTeam,
- subscriptionStatus.isEnterprise,
])
// Memoized callbacks to prevent infinite loops in child components
@@ -462,6 +459,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
+ {activeSection === 'credential-sets' && }
{activeSection === 'apikeys' && }
{activeSection === 'files' && }
{isBillingEnabled && activeSection === 'subscription' && }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx
index 410722f3d5..685787bc92 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx
@@ -4,7 +4,7 @@ import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
-import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
+import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
interface AvatarsProps {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/index.ts
deleted file mode 100644
index da6e909475..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from './email-tag'
-export * from './permission-selector'
-export * from './permissions-table'
-export * from './permissions-table-skeleton'
-export * from './types'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts
new file mode 100644
index 0000000000..dccad2d109
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts
@@ -0,0 +1,6 @@
+export { EmailTag } from './components/email-tag'
+export { PermissionSelector } from './components/permission-selector'
+export { PermissionsTable } from './components/permissions-table'
+export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
+export type { PermissionType, UserPermissions } from './components/types'
+export { InviteModal } from './invite-modal'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
index 62450150f7..fe4f670b7e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
@@ -2,6 +2,7 @@
import React, { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
+import { Paperclip, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -17,9 +18,10 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/email-tag'
+import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
import { API_ENDPOINTS } from '@/stores/constants'
-import type { PermissionType, UserPermissions } from './components'
-import { EmailTag, PermissionsTable } from './components'
+import type { PermissionType, UserPermissions } from './components/types'
const logger = createLogger('InviteModal')
@@ -40,9 +42,12 @@ interface PendingInvitation {
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef(null)
+ const fileInputRef = useRef(null)
const [inputValue, setInputValue] = useState('')
const [emails, setEmails] = useState([])
const [invalidEmails, setInvalidEmails] = useState([])
+ const [duplicateEmails, setDuplicateEmails] = useState([])
+ const [isDragging, setIsDragging] = useState(false)
const [userPermissions, setUserPermissions] = useState([])
const [pendingInvitations, setPendingInvitations] = useState([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
@@ -134,13 +139,20 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const validation = quickValidateEmail(normalized)
const isValid = validation.isValid
- if (emails.includes(normalized) || invalidEmails.includes(normalized)) {
+ if (
+ emails.includes(normalized) ||
+ invalidEmails.includes(normalized) ||
+ duplicateEmails.includes(normalized)
+ ) {
return false
}
const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized)
if (hasPendingInvitation) {
- setErrorMessage(`${normalized} already has a pending invitation`)
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setInputValue('')
return false
}
@@ -149,7 +161,10 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
(user) => user.email === normalized
)
if (isExistingMember) {
- setErrorMessage(`${normalized} is already a member of this workspace`)
+ setDuplicateEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setInputValue('')
return false
}
@@ -161,13 +176,19 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
}
if (!isValid) {
- setInvalidEmails((prev) => [...prev, normalized])
+ setInvalidEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setInputValue('')
return false
}
setErrorMessage(null)
- setEmails((prev) => [...prev, normalized])
+ setEmails((prev) => {
+ if (prev.includes(normalized)) return prev
+ return [...prev, normalized]
+ })
setUserPermissions((prev) => [
...prev,
@@ -180,7 +201,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInputValue('')
return true
},
- [emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email]
+ [
+ emails,
+ invalidEmails,
+ duplicateEmails,
+ pendingInvitations,
+ workspacePermissions?.users,
+ session?.user?.email,
+ ]
)
const removeEmail = useCallback(
@@ -196,6 +224,80 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
+ const removeDuplicateEmail = useCallback((index: number) => {
+ setDuplicateEmails((prev) => prev.filter((_, i) => i !== index))
+ }, [])
+
+ const extractEmailsFromText = useCallback((text: string): string[] => {
+ const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
+ const matches = text.match(emailRegex) || []
+ return [...new Set(matches.map((e) => e.toLowerCase()))]
+ }, [])
+
+ const handleFileDrop = useCallback(
+ async (file: File) => {
+ try {
+ const text = await file.text()
+ const extractedEmails = extractEmailsFromText(text)
+ extractedEmails.forEach((email) => {
+ addEmail(email)
+ })
+ } catch (error) {
+ logger.error('Error reading dropped file', error)
+ }
+ },
+ [extractEmailsFromText, addEmail]
+ )
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = 'copy'
+ setIsDragging(true)
+ }, [])
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+ }, [])
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+
+ const files = Array.from(e.dataTransfer.files)
+ const validFiles = files.filter(
+ (f) =>
+ f.type === 'text/csv' ||
+ f.type === 'text/plain' ||
+ f.name.endsWith('.csv') ||
+ f.name.endsWith('.txt')
+ )
+
+ for (const file of validFiles) {
+ await handleFileDrop(file)
+ }
+ },
+ [handleFileDrop]
+ )
+
+ const handleFileInputChange = useCallback(
+ async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files) return
+
+ for (const file of Array.from(files)) {
+ await handleFileDrop(file)
+ }
+
+ e.target.value = ''
+ },
+ [handleFileDrop]
+ )
+
const handlePermissionChange = useCallback(
(identifier: string, permissionType: PermissionType) => {
const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier)
@@ -204,11 +306,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setExistingUserPermissionChanges((prev) => {
const newChanges = { ...prev }
- // If the new permission matches the original, remove the change entry
if (existingUser.permissionType === permissionType) {
delete newChanges[identifier]
} else {
- // Otherwise, track the change
newChanges[identifier] = { permissionType }
}
@@ -297,7 +397,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setErrorMessage(null)
try {
- // Verify the user exists in workspace permissions
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
@@ -322,7 +421,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
throw new Error(data.error || 'Failed to remove member')
}
- // Update the workspace permissions to remove the user
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
@@ -333,7 +431,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
})
}
- // Clear any pending changes for this user
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
@@ -384,7 +481,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
throw new Error(data.error || 'Failed to cancel invitation')
}
- // Remove the invitation from the pending invitations list
setPendingInvitations((prev) =>
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
)
@@ -452,7 +548,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
delete next[invitationId]
return next
})
- // Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
@@ -474,40 +569,52 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
- if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (inputValue.trim()) {
+ addEmail(inputValue)
+ }
+ return
+ }
+
+ if ([',', ' '].includes(e.key) && inputValue.trim()) {
e.preventDefault()
addEmail(inputValue)
}
if (e.key === 'Backspace' && !inputValue) {
- if (invalidEmails.length > 0) {
+ if (duplicateEmails.length > 0) {
+ removeDuplicateEmail(duplicateEmails.length - 1)
+ } else if (invalidEmails.length > 0) {
removeInvalidEmail(invalidEmails.length - 1)
} else if (emails.length > 0) {
removeEmail(emails.length - 1)
}
}
},
- [inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail]
+ [
+ inputValue,
+ addEmail,
+ duplicateEmails,
+ invalidEmails,
+ emails,
+ removeDuplicateEmail,
+ removeInvalidEmail,
+ removeEmail,
+ ]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
- const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
+ const pastedEmails = extractEmailsFromText(pastedText)
- let addedCount = 0
pastedEmails.forEach((email) => {
- if (addEmail(email)) {
- addedCount++
- }
+ addEmail(email)
})
-
- if (addedCount === 0 && pastedEmails.length === 1) {
- setInputValue(inputValue + pastedEmails[0])
- }
},
- [addEmail, inputValue]
+ [addEmail, extractEmailsFromText]
)
const handleSubmit = useCallback(
@@ -518,7 +625,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
addEmail(inputValue)
}
- // Clear messages at start of submission
setErrorMessage(null)
setSuccessMessage(null)
@@ -644,10 +750,11 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
)
const resetState = useCallback(() => {
- // Batch state updates using React's automatic batching in React 18+
setInputValue('')
setEmails([])
setInvalidEmails([])
+ setDuplicateEmails([])
+ setIsDragging(false)
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
@@ -718,7 +825,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
tabIndex={-1}
readOnly
/>
-
+
+
+ {isDragging && (
+
+
+ Drop file here
+
+
+ )}
{invalidEmails.map((email, index) => (
))}
+ {duplicateEmails.map((email, index) => (
+
+ {email}
+ duplicate
+ {!isSubmitting && userPerms.canAdmin && (
+
+ )}
+
+ ))}
{emails.map((email, index) => (
))}
-
setInputValue(e.target.value)}
- onKeyDown={handleKeyDown}
- onPaste={handlePaste}
- onBlur={() => inputValue.trim() && addEmail(inputValue)}
- placeholder={
- !userPerms.canAdmin
- ? 'Only administrators can invite new members'
- : emails.length > 0 || invalidEmails.length > 0
- ? 'Add another email'
- : 'Enter emails'
- }
- className={cn(
- 'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
- emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]'
+
+
setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ onBlur={() => inputValue.trim() && addEmail(inputValue)}
+ placeholder={
+ !userPerms.canAdmin
+ ? 'Only administrators can invite new members'
+ : emails.length > 0 ||
+ invalidEmails.length > 0 ||
+ duplicateEmails.length > 0
+ ? 'Add another email'
+ : 'Enter emails'
+ }
+ className={cn(
+ 'h-6 min-w-[140px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0',
+ emails.length > 0 || invalidEmails.length > 0 || duplicateEmails.length > 0
+ ? 'pl-[4px]'
+ : 'pl-[4px]'
+ )}
+ autoFocus={userPerms.canAdmin}
+ disabled={isSubmitting || !userPerms.canAdmin}
+ autoComplete='off'
+ autoCorrect='off'
+ autoCapitalize='off'
+ spellCheck={false}
+ data-lpignore='true'
+ data-form-type='other'
+ aria-autocomplete='none'
+ />
+ {userPerms.canAdmin && (
+
)}
- autoFocus={userPerms.canAdmin}
- disabled={isSubmitting || !userPerms.canAdmin}
- autoComplete='off'
- autoCorrect='off'
- autoCapitalize='off'
- spellCheck={false}
- data-lpignore='true'
- data-form-type='other'
- aria-autocomplete='none'
- />
+
{errorMessage && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index 7a908c333e..37c3a9b0e5 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -17,7 +17,7 @@ import {
} from '@/components/emcn'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
-import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal'
+import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
const logger = createLogger('WorkspaceHeader')
diff --git a/apps/sim/app/workspace/[workspaceId]/w/utils/get-user-color.ts b/apps/sim/app/workspace/[workspaceId]/w/utils/get-user-color.ts
deleted file mode 100644
index 95e99c0919..0000000000
--- a/apps/sim/app/workspace/[workspaceId]/w/utils/get-user-color.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * User color palette matching terminal.tsx RUN_ID_COLORS
- * These colors are used consistently across cursors, avatars, and terminal run IDs
- */
-export const USER_COLORS = [
- '#4ADE80', // Green
- '#F472B6', // Pink
- '#60C5FF', // Blue
- '#FF8533', // Orange
- '#C084FC', // Purple
- '#FCD34D', // Yellow
-] as const
-
-/**
- * Hash a user ID to generate a consistent numeric index
- *
- * @param userId - The user ID to hash
- * @returns A positive integer
- */
-function hashUserId(userId: string): number {
- return Math.abs(Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0))
-}
-
-/**
- * Gets a consistent color for a user based on their ID.
- * The same user will always get the same color across cursors, avatars, and terminal.
- *
- * @param userId - The unique user identifier
- * @returns A hex color string
- */
-export function getUserColor(userId: string): string {
- const hash = hashUserId(userId)
- return USER_COLORS[hash % USER_COLORS.length]
-}
-
-/**
- * Creates a stable mapping of user IDs to color indices for a list of users.
- * Useful when you need to maintain consistent color assignments across renders.
- *
- * @param userIds - Array of user IDs to map
- * @returns Map of user ID to color index
- */
-export function createUserColorMap(userIds: string[]): Map {
- const colorMap = new Map()
- let colorIndex = 0
-
- for (const userId of userIds) {
- if (!colorMap.has(userId)) {
- colorMap.set(userId, colorIndex++)
- }
- }
-
- return colorMap
-}
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts
index 20389689f5..b0447e8a00 100644
--- a/apps/sim/background/webhook-execution.ts
+++ b/apps/sim/background/webhook-execution.ts
@@ -95,6 +95,7 @@ export type WebhookExecutionPayload = {
testMode?: boolean
executionTarget?: 'deployed' | 'live'
credentialId?: string
+ credentialAccountUserId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -241,6 +242,7 @@ async function executeWebhookJobInternal(
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
+ credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -499,6 +501,7 @@ async function executeWebhookJobInternal(
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
+ credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -508,7 +511,9 @@ async function executeWebhookJobInternal(
},
}
- const snapshot = new ExecutionSnapshot(metadata, workflow, input || {}, workflowVariables, [])
+ const triggerInput = input || {}
+
+ const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
const executionResult = await executeWorkflowCore({
snapshot,
diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts
index 06f81a4768..88b727415a 100644
--- a/apps/sim/blocks/blocks/agent.ts
+++ b/apps/sim/blocks/blocks/agent.ts
@@ -94,7 +94,6 @@ export const AgentBlock: BlockConfig = {
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
- searchable: true,
options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
@@ -329,6 +328,43 @@ export const AgentBlock: BlockConfig = {
value: providers.vertex.models,
},
},
+ {
+ id: 'bedrockAccessKeyId',
+ title: 'AWS Access Key ID',
+ type: 'short-input',
+ password: true,
+ placeholder: 'Enter your AWS Access Key ID',
+ connectionDroppable: false,
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.bedrock.models,
+ },
+ },
+ {
+ id: 'bedrockSecretKey',
+ title: 'AWS Secret Access Key',
+ type: 'short-input',
+ password: true,
+ placeholder: 'Enter your AWS Secret Access Key',
+ connectionDroppable: false,
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.bedrock.models,
+ },
+ },
+ {
+ id: 'bedrockRegion',
+ title: 'AWS Region',
+ type: 'short-input',
+ placeholder: 'us-east-1',
+ connectionDroppable: false,
+ condition: {
+ field: 'model',
+ value: providers.bedrock.models,
+ },
+ },
{
id: 'tools',
title: 'Tools',
@@ -343,11 +379,11 @@ export const AgentBlock: BlockConfig = {
password: true,
connectionDroppable: false,
required: true,
- // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
+ // Hide API key for hosted models, Ollama models, vLLM models, Vertex models (uses OAuth), and Bedrock (uses AWS credentials)
condition: isHosted
? {
field: 'model',
- value: [...getHostedModels(), ...providers.vertex.models],
+ value: [...getHostedModels(), ...providers.vertex.models, ...providers.bedrock.models],
not: true, // Show for all models EXCEPT those listed
}
: () => ({
@@ -356,8 +392,9 @@ export const AgentBlock: BlockConfig = {
...getCurrentOllamaModels(),
...getCurrentVLLMModels(),
...providers.vertex.models,
+ ...providers.bedrock.models,
],
- not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
+ not: true, // Show for all models EXCEPT Ollama, vLLM, Vertex, and Bedrock models
}),
},
{
@@ -634,6 +671,9 @@ Example 3 (Array Input):
azureApiVersion: { type: 'string', description: 'Azure API version' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
+ bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },
+ bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' },
+ bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' },
responseFormat: {
type: 'json',
description: 'JSON response format schema',
diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts
index 402957bbdb..5d584d171e 100644
--- a/apps/sim/blocks/blocks/evaluator.ts
+++ b/apps/sim/blocks/blocks/evaluator.ts
@@ -1,27 +1,14 @@
import { createLogger } from '@sim/logger'
import { ChartBarIcon } from '@/components/icons'
-import { isHosted } from '@/lib/core/config/feature-flags'
import type { BlockConfig, ParamType } from '@/blocks/types'
+import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
import type { ProviderId } from '@/providers/types'
-import {
- getBaseModelProviders,
- getHostedModels,
- getProviderIcon,
- providers,
-} from '@/providers/utils'
+import { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ToolResponse } from '@/tools/types'
const logger = createLogger('EvaluatorBlock')
-const getCurrentOllamaModels = () => {
- return useProvidersStore.getState().providers.ollama.models
-}
-
-const getCurrentVLLMModels = () => {
- return useProvidersStore.getState().providers.vllm.models
-}
-
interface Metric {
name: string
description: string
@@ -204,91 +191,7 @@ export const EvaluatorBlock: BlockConfig = {
})
},
},
- {
- id: 'vertexCredential',
- title: 'Google Cloud Account',
- type: 'oauth-input',
- serviceId: 'vertex-ai',
- requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
- placeholder: 'Select Google Cloud account',
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your API key',
- password: true,
- connectionDroppable: false,
- required: true,
- // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
- condition: isHosted
- ? {
- field: 'model',
- value: [...getHostedModels(), ...providers.vertex.models],
- not: true, // Show for all models EXCEPT those listed
- }
- : () => ({
- field: 'model',
- value: [
- ...getCurrentOllamaModels(),
- ...getCurrentVLLMModels(),
- ...providers.vertex.models,
- ],
- not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
- }),
- },
- {
- id: 'azureEndpoint',
- title: 'Azure OpenAI Endpoint',
- type: 'short-input',
- password: true,
- placeholder: 'https://your-resource.openai.azure.com',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'azureApiVersion',
- title: 'Azure API Version',
- type: 'short-input',
- placeholder: '2024-07-01-preview',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'vertexProject',
- title: 'Vertex AI Project',
- type: 'short-input',
- placeholder: 'your-gcp-project-id',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'vertexLocation',
- title: 'Vertex AI Location',
- type: 'short-input',
- placeholder: 'us-central1',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
+ ...getProviderCredentialSubBlocks(),
{
id: 'temperature',
title: 'Temperature',
@@ -403,21 +306,7 @@ export const EvaluatorBlock: BlockConfig = {
},
},
model: { type: 'string' as ParamType, description: 'AI model to use' },
- apiKey: { type: 'string' as ParamType, description: 'Provider API key' },
- azureEndpoint: { type: 'string' as ParamType, description: 'Azure OpenAI endpoint URL' },
- azureApiVersion: { type: 'string' as ParamType, description: 'Azure API version' },
- vertexProject: {
- type: 'string' as ParamType,
- description: 'Google Cloud project ID for Vertex AI',
- },
- vertexLocation: {
- type: 'string' as ParamType,
- description: 'Google Cloud location for Vertex AI',
- },
- vertexCredential: {
- type: 'string' as ParamType,
- description: 'Google Cloud OAuth credential ID for Vertex AI',
- },
+ ...PROVIDER_CREDENTIAL_INPUTS,
temperature: {
type: 'number' as ParamType,
description: 'Response randomness level (low for consistent evaluation)',
diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts
index 39914ced74..4ccf1ccecf 100644
--- a/apps/sim/blocks/blocks/guardrails.ts
+++ b/apps/sim/blocks/blocks/guardrails.ts
@@ -1,15 +1,10 @@
import { ShieldCheckIcon } from '@/components/icons'
-import { isHosted } from '@/lib/core/config/feature-flags'
import type { BlockConfig } from '@/blocks/types'
-import { getHostedModels, getProviderIcon } from '@/providers/utils'
+import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
+import { getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ToolResponse } from '@/tools/types'
-const getCurrentOllamaModels = () => {
- const providersState = useProvidersStore.getState()
- return providersState.providers.ollama.models
-}
-
export interface GuardrailsResponse extends ToolResponse {
output: {
passed: boolean
@@ -120,8 +115,11 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
+ const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
- const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels]))
+ const allModels = Array.from(
+ new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
+ )
return allModels.map((model) => {
const icon = getProviderIcon(model)
@@ -160,44 +158,19 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
value: ['hallucination'],
},
},
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your API key',
- password: true,
- connectionDroppable: false,
- required: true,
- // Show API key field only for hallucination validation
- // Hide for hosted models and Ollama models
- condition: () => {
- const baseCondition = {
- field: 'validationType' as const,
- value: ['hallucination'],
- }
-
- if (isHosted) {
- // In hosted mode, hide for hosted models
- return {
- ...baseCondition,
- and: {
- field: 'model' as const,
- value: getHostedModels(),
- not: true, // Show for all models EXCEPT hosted ones
- },
+ // Provider credential subblocks - only shown for hallucination validation
+ ...getProviderCredentialSubBlocks().map((subBlock) => ({
+ ...subBlock,
+ // Combine with hallucination condition
+ condition: subBlock.condition
+ ? {
+ field: 'validationType' as const,
+ value: ['hallucination'],
+ and:
+ typeof subBlock.condition === 'function' ? subBlock.condition() : subBlock.condition,
}
- }
- // In self-hosted mode, hide for Ollama models
- return {
- ...baseCondition,
- and: {
- field: 'model' as const,
- value: getCurrentOllamaModels(),
- not: true, // Show for all models EXCEPT Ollama ones
- },
- }
- },
- },
+ : { field: 'validationType' as const, value: ['hallucination'] },
+ })),
{
id: 'piiEntityTypes',
title: 'PII Types to Detect',
@@ -332,10 +305,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
type: 'string',
description: 'LLM model for hallucination scoring (default: gpt-4o-mini)',
},
- apiKey: {
- type: 'string',
- description: 'API key for LLM provider (optional if using hosted)',
- },
+ ...PROVIDER_CREDENTIAL_INPUTS,
piiEntityTypes: {
type: 'json',
description: 'PII entity types to detect (array of strings, empty = detect all)',
diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts
index 6b88caac6b..3eedb35c5e 100644
--- a/apps/sim/blocks/blocks/linear.ts
+++ b/apps/sim/blocks/blocks/linear.ts
@@ -77,7 +77,6 @@ export const LinearBlock: BlockConfig = {
// Project Update Operations
{ label: 'Create Project Update', id: 'linear_create_project_update' },
{ label: 'List Project Updates', id: 'linear_list_project_updates' },
- { label: 'Create Project Link', id: 'linear_create_project_link' },
// Notification Operations
{ label: 'List Notifications', id: 'linear_list_notifications' },
{ label: 'Update Notification', id: 'linear_update_notification' },
@@ -227,6 +226,7 @@ export const LinearBlock: BlockConfig = {
'linear_update_project',
'linear_archive_project',
'linear_delete_project',
+ 'linear_create_project_update',
'linear_list_project_updates',
],
},
@@ -239,6 +239,7 @@ export const LinearBlock: BlockConfig = {
'linear_update_project',
'linear_archive_project',
'linear_delete_project',
+ 'linear_create_project_update',
'linear_list_project_updates',
'linear_list_project_labels',
],
@@ -261,7 +262,6 @@ export const LinearBlock: BlockConfig = {
'linear_delete_project',
'linear_create_project_update',
'linear_list_project_updates',
- 'linear_create_project_link',
],
},
condition: {
@@ -275,7 +275,6 @@ export const LinearBlock: BlockConfig = {
'linear_delete_project',
'linear_create_project_update',
'linear_list_project_updates',
- 'linear_create_project_link',
'linear_list_project_labels',
],
},
@@ -625,7 +624,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
required: true,
condition: {
field: 'operation',
- value: ['linear_create_attachment', 'linear_create_project_link'],
+ value: ['linear_create_attachment'],
},
},
// Attachment title
@@ -1221,6 +1220,36 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
value: ['linear_create_project_status'],
},
},
+ {
+ id: 'projectStatusType',
+ title: 'Status Type',
+ type: 'dropdown',
+ options: [
+ { label: 'Backlog', id: 'backlog' },
+ { label: 'Planned', id: 'planned' },
+ { label: 'Started', id: 'started' },
+ { label: 'Paused', id: 'paused' },
+ { label: 'Completed', id: 'completed' },
+ { label: 'Canceled', id: 'canceled' },
+ ],
+ value: () => 'started',
+ required: true,
+ condition: {
+ field: 'operation',
+ value: ['linear_create_project_status'],
+ },
+ },
+ {
+ id: 'projectStatusPosition',
+ title: 'Position',
+ type: 'short-input',
+ placeholder: 'Enter position (e.g. 0, 1, 2...)',
+ required: true,
+ condition: {
+ field: 'operation',
+ value: ['linear_create_project_status'],
+ },
+ },
{
id: 'projectStatusId',
title: 'Status ID',
@@ -1326,7 +1355,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
'linear_list_favorites',
'linear_create_project_update',
'linear_list_project_updates',
- 'linear_create_project_link',
'linear_list_notifications',
'linear_update_notification',
'linear_create_customer',
@@ -1772,17 +1800,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
projectId: effectiveProjectId,
}
- case 'linear_create_project_link':
- if (!effectiveProjectId || !params.url?.trim()) {
- throw new Error('Project ID and URL are required.')
- }
- return {
- ...baseParams,
- projectId: effectiveProjectId,
- url: params.url.trim(),
- label: params.name,
- }
-
case 'linear_list_notifications':
return baseParams
@@ -2033,22 +2050,22 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}
case 'linear_add_label_to_project':
- if (!effectiveProjectId || !params.projectLabelId?.trim()) {
+ if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
throw new Error('Project ID and label ID are required.')
}
return {
...baseParams,
- projectId: effectiveProjectId,
+ projectId: params.projectIdForMilestone.trim(),
labelId: params.projectLabelId.trim(),
}
case 'linear_remove_label_from_project':
- if (!effectiveProjectId || !params.projectLabelId?.trim()) {
+ if (!params.projectIdForMilestone?.trim() || !params.projectLabelId?.trim()) {
throw new Error('Project ID and label ID are required.')
}
return {
...baseParams,
- projectId: effectiveProjectId,
+ projectId: params.projectIdForMilestone.trim(),
labelId: params.projectLabelId.trim(),
}
@@ -2097,13 +2114,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Project Status Operations
case 'linear_create_project_status':
- if (!params.projectStatusName?.trim() || !params.statusColor?.trim()) {
- throw new Error('Project status name and color are required.')
+ if (
+ !params.projectStatusName?.trim() ||
+ !params.projectStatusType?.trim() ||
+ !params.statusColor?.trim() ||
+ !params.projectStatusPosition?.trim()
+ ) {
+ throw new Error('Project status name, type, color, and position are required.')
}
return {
...baseParams,
name: params.projectStatusName.trim(),
+ type: params.projectStatusType.trim(),
color: params.statusColor.trim(),
+ position: Number.parseFloat(params.projectStatusPosition.trim()),
description: params.projectStatusDescription?.trim() || undefined,
indefinite: params.projectStatusIndefinite === 'true',
}
@@ -2270,7 +2294,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Project update outputs
update: { type: 'json', description: 'Project update data' },
updates: { type: 'json', description: 'Project updates list' },
- link: { type: 'json', description: 'Project link data' },
// Notification outputs
notification: { type: 'json', description: 'Notification data' },
notifications: { type: 'json', description: 'Notifications list' },
diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts
index 727c3c4682..ae6672a309 100644
--- a/apps/sim/blocks/blocks/router.ts
+++ b/apps/sim/blocks/blocks/router.ts
@@ -1,24 +1,11 @@
import { ConnectIcon } from '@/components/icons'
-import { isHosted } from '@/lib/core/config/feature-flags'
import { AuthMode, type BlockConfig } from '@/blocks/types'
+import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
import type { ProviderId } from '@/providers/types'
-import {
- getBaseModelProviders,
- getHostedModels,
- getProviderIcon,
- providers,
-} from '@/providers/utils'
+import { getBaseModelProviders, getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ToolResponse } from '@/tools/types'
-const getCurrentOllamaModels = () => {
- return useProvidersStore.getState().providers.ollama.models
-}
-
-const getCurrentVLLMModels = () => {
- return useProvidersStore.getState().providers.vllm.models
-}
-
interface RouterResponse extends ToolResponse {
output: {
prompt: string
@@ -168,23 +155,6 @@ const getModelOptions = () => {
})
}
-/**
- * Helper to get API key condition for both router versions.
- */
-const getApiKeyCondition = () => {
- return isHosted
- ? {
- field: 'model',
- value: [...getHostedModels(), ...providers.vertex.models],
- not: true,
- }
- : () => ({
- field: 'model',
- value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models],
- not: true,
- })
-}
-
/**
* Legacy Router Block (block-based routing).
* Hidden from toolbar but still supported for existing workflows.
@@ -221,76 +191,7 @@ export const RouterBlock: BlockConfig = {
defaultValue: 'claude-sonnet-4-5',
options: getModelOptions,
},
- {
- id: 'vertexCredential',
- title: 'Google Cloud Account',
- type: 'oauth-input',
- serviceId: 'vertex-ai',
- requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
- placeholder: 'Select Google Cloud account',
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your API key',
- password: true,
- connectionDroppable: false,
- required: true,
- condition: getApiKeyCondition(),
- },
- {
- id: 'azureEndpoint',
- title: 'Azure OpenAI Endpoint',
- type: 'short-input',
- password: true,
- placeholder: 'https://your-resource.openai.azure.com',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'azureApiVersion',
- title: 'Azure API Version',
- type: 'short-input',
- placeholder: '2024-07-01-preview',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'vertexProject',
- title: 'Vertex AI Project',
- type: 'short-input',
- placeholder: 'your-gcp-project-id',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'vertexLocation',
- title: 'Vertex AI Location',
- type: 'short-input',
- placeholder: 'us-central1',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
+ ...getProviderCredentialSubBlocks(),
{
id: 'temperature',
title: 'Temperature',
@@ -335,15 +236,7 @@ export const RouterBlock: BlockConfig = {
inputs: {
prompt: { type: 'string', description: 'Routing prompt content' },
model: { type: 'string', description: 'AI model to use' },
- apiKey: { type: 'string', description: 'Provider API key' },
- azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
- azureApiVersion: { type: 'string', description: 'Azure API version' },
- vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
- vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
- vertexCredential: {
- type: 'string',
- description: 'Google Cloud OAuth credential ID for Vertex AI',
- },
+ ...PROVIDER_CREDENTIAL_INPUTS,
temperature: {
type: 'number',
description: 'Response randomness level (low for consistent routing)',
@@ -422,76 +315,7 @@ export const RouterV2Block: BlockConfig = {
defaultValue: 'claude-sonnet-4-5',
options: getModelOptions,
},
- {
- id: 'vertexCredential',
- title: 'Google Cloud Account',
- type: 'oauth-input',
- serviceId: 'vertex-ai',
- requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
- placeholder: 'Select Google Cloud account',
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your API key',
- password: true,
- connectionDroppable: false,
- required: true,
- condition: getApiKeyCondition(),
- },
- {
- id: 'azureEndpoint',
- title: 'Azure OpenAI Endpoint',
- type: 'short-input',
- password: true,
- placeholder: 'https://your-resource.openai.azure.com',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'azureApiVersion',
- title: 'Azure API Version',
- type: 'short-input',
- placeholder: '2024-07-01-preview',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'vertexProject',
- title: 'Vertex AI Project',
- type: 'short-input',
- placeholder: 'your-gcp-project-id',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'vertexLocation',
- title: 'Vertex AI Location',
- type: 'short-input',
- placeholder: 'us-central1',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
+ ...getProviderCredentialSubBlocks(),
],
tools: {
access: [
@@ -520,15 +344,7 @@ export const RouterV2Block: BlockConfig = {
context: { type: 'string', description: 'Context for routing decision' },
routes: { type: 'json', description: 'Route definitions with descriptions' },
model: { type: 'string', description: 'AI model to use' },
- apiKey: { type: 'string', description: 'Provider API key' },
- azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
- azureApiVersion: { type: 'string', description: 'Azure API version' },
- vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
- vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
- vertexCredential: {
- type: 'string',
- description: 'Google Cloud OAuth credential ID for Vertex AI',
- },
+ ...PROVIDER_CREDENTIAL_INPUTS,
},
outputs: {
context: { type: 'string', description: 'Context used for routing' },
diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts
index 44c646608a..d0d6477651 100644
--- a/apps/sim/blocks/blocks/translate.ts
+++ b/apps/sim/blocks/blocks/translate.ts
@@ -1,17 +1,9 @@
import { TranslateIcon } from '@/components/icons'
-import { isHosted } from '@/lib/core/config/feature-flags'
import { AuthMode, type BlockConfig } from '@/blocks/types'
-import { getHostedModels, getProviderIcon, providers } from '@/providers/utils'
+import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils'
+import { getProviderIcon } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
-const getCurrentOllamaModels = () => {
- return useProvidersStore.getState().providers.ollama.models
-}
-
-const getCurrentVLLMModels = () => {
- return useProvidersStore.getState().providers.vllm.models
-}
-
const getTranslationPrompt = (targetLanguage: string) =>
`Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.`
@@ -59,91 +51,7 @@ export const TranslateBlock: BlockConfig = {
})
},
},
- {
- id: 'vertexCredential',
- title: 'Google Cloud Account',
- type: 'oauth-input',
- serviceId: 'vertex-ai',
- requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
- placeholder: 'Select Google Cloud account',
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your API key',
- password: true,
- connectionDroppable: false,
- required: true,
- // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
- condition: isHosted
- ? {
- field: 'model',
- value: [...getHostedModels(), ...providers.vertex.models],
- not: true, // Show for all models EXCEPT those listed
- }
- : () => ({
- field: 'model',
- value: [
- ...getCurrentOllamaModels(),
- ...getCurrentVLLMModels(),
- ...providers.vertex.models,
- ],
- not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
- }),
- },
- {
- id: 'azureEndpoint',
- title: 'Azure OpenAI Endpoint',
- type: 'short-input',
- password: true,
- placeholder: 'https://your-resource.openai.azure.com',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'azureApiVersion',
- title: 'Azure API Version',
- type: 'short-input',
- placeholder: '2024-07-01-preview',
- connectionDroppable: false,
- condition: {
- field: 'model',
- value: providers['azure-openai'].models,
- },
- },
- {
- id: 'vertexProject',
- title: 'Vertex AI Project',
- type: 'short-input',
- placeholder: 'your-gcp-project-id',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
- {
- id: 'vertexLocation',
- title: 'Vertex AI Location',
- type: 'short-input',
- placeholder: 'us-central1',
- connectionDroppable: false,
- required: true,
- condition: {
- field: 'model',
- value: providers.vertex.models,
- },
- },
+ ...getProviderCredentialSubBlocks(),
{
id: 'systemPrompt',
title: 'System Prompt',
@@ -168,21 +76,15 @@ export const TranslateBlock: BlockConfig = {
vertexProject: params.vertexProject,
vertexLocation: params.vertexLocation,
vertexCredential: params.vertexCredential,
+ bedrockRegion: params.bedrockRegion,
+ bedrockSecretKey: params.bedrockSecretKey,
}),
},
},
inputs: {
context: { type: 'string', description: 'Text to translate' },
targetLanguage: { type: 'string', description: 'Target language' },
- apiKey: { type: 'string', description: 'Provider API key' },
- azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
- azureApiVersion: { type: 'string', description: 'Azure API version' },
- vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
- vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
- vertexCredential: {
- type: 'string',
- description: 'Google Cloud OAuth credential ID for Vertex AI',
- },
+ ...PROVIDER_CREDENTIAL_INPUTS,
systemPrompt: { type: 'string', description: 'Translation instructions' },
},
outputs: {
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index fd2fe4f1ee..28605a440d 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -254,6 +254,8 @@ export interface SubBlockConfig {
// OAuth specific properties - serviceId is the canonical identifier for OAuth services
serviceId?: string
requiredScopes?: string[]
+ // Whether this credential selector supports credential sets (for trigger blocks)
+ supportsCredentialSets?: boolean
// File selector specific properties
mimeType?: string
// File upload specific properties
diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts
index 8a96ca2ae0..6d75c58619 100644
--- a/apps/sim/blocks/utils.ts
+++ b/apps/sim/blocks/utils.ts
@@ -1,4 +1,7 @@
+import { isHosted } from '@/lib/core/config/feature-flags'
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'
+import { getHostedModels, providers } from '@/providers/utils'
+import { useProvidersStore } from '@/stores/providers/store'
/**
* Checks if a field is included in the dependsOn config.
@@ -37,3 +40,177 @@ export function resolveOutputType(
return resolvedOutputs
}
+
+/**
+ * Helper to get current Ollama models from store
+ */
+const getCurrentOllamaModels = () => {
+ return useProvidersStore.getState().providers.ollama.models
+}
+
+/**
+ * Helper to get current vLLM models from store
+ */
+const getCurrentVLLMModels = () => {
+ return useProvidersStore.getState().providers.vllm.models
+}
+
+/**
+ * Get the API key condition for provider credential subblocks.
+ * Handles hosted vs self-hosted environments and excludes providers that don't need API key.
+ */
+export function getApiKeyCondition() {
+ return isHosted
+ ? {
+ field: 'model',
+ value: [...getHostedModels(), ...providers.vertex.models, ...providers.bedrock.models],
+ not: true,
+ }
+ : () => ({
+ field: 'model',
+ value: [
+ ...getCurrentOllamaModels(),
+ ...getCurrentVLLMModels(),
+ ...providers.vertex.models,
+ ...providers.bedrock.models,
+ ],
+ not: true,
+ })
+}
+
+/**
+ * Returns the standard provider credential subblocks used by LLM-based blocks.
+ * This includes: Vertex AI OAuth, API Key, Azure OpenAI, Vertex AI config, and Bedrock config.
+ *
+ * Usage: Spread into your block's subBlocks array after block-specific fields
+ */
+export function getProviderCredentialSubBlocks(): SubBlockConfig[] {
+ return [
+ {
+ id: 'vertexCredential',
+ title: 'Google Cloud Account',
+ type: 'oauth-input',
+ serviceId: 'vertex-ai',
+ requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
+ placeholder: 'Select Google Cloud account',
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.vertex.models,
+ },
+ },
+ {
+ id: 'apiKey',
+ title: 'API Key',
+ type: 'short-input',
+ placeholder: 'Enter your API key',
+ password: true,
+ connectionDroppable: false,
+ required: true,
+ condition: getApiKeyCondition(),
+ },
+ {
+ id: 'azureEndpoint',
+ title: 'Azure OpenAI Endpoint',
+ type: 'short-input',
+ password: true,
+ placeholder: 'https://your-resource.openai.azure.com',
+ connectionDroppable: false,
+ condition: {
+ field: 'model',
+ value: providers['azure-openai'].models,
+ },
+ },
+ {
+ id: 'azureApiVersion',
+ title: 'Azure API Version',
+ type: 'short-input',
+ placeholder: '2024-07-01-preview',
+ connectionDroppable: false,
+ condition: {
+ field: 'model',
+ value: providers['azure-openai'].models,
+ },
+ },
+ {
+ id: 'vertexProject',
+ title: 'Vertex AI Project',
+ type: 'short-input',
+ placeholder: 'your-gcp-project-id',
+ connectionDroppable: false,
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.vertex.models,
+ },
+ },
+ {
+ id: 'vertexLocation',
+ title: 'Vertex AI Location',
+ type: 'short-input',
+ placeholder: 'us-central1',
+ connectionDroppable: false,
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.vertex.models,
+ },
+ },
+ {
+ id: 'bedrockAccessKeyId',
+ title: 'AWS Access Key ID',
+ type: 'short-input',
+ password: true,
+ placeholder: 'Enter your AWS Access Key ID',
+ connectionDroppable: false,
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.bedrock.models,
+ },
+ },
+ {
+ id: 'bedrockSecretKey',
+ title: 'AWS Secret Access Key',
+ type: 'short-input',
+ password: true,
+ placeholder: 'Enter your AWS Secret Access Key',
+ connectionDroppable: false,
+ required: true,
+ condition: {
+ field: 'model',
+ value: providers.bedrock.models,
+ },
+ },
+ {
+ id: 'bedrockRegion',
+ title: 'AWS Region',
+ type: 'short-input',
+ placeholder: 'us-east-1',
+ connectionDroppable: false,
+ condition: {
+ field: 'model',
+ value: providers.bedrock.models,
+ },
+ },
+ ]
+}
+
+/**
+ * Returns the standard input definitions for provider credentials.
+ * Use this in your block's inputs definition.
+ */
+export const PROVIDER_CREDENTIAL_INPUTS = {
+ apiKey: { type: 'string', description: 'Provider API key' },
+ azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
+ azureApiVersion: { type: 'string', description: 'Azure API version' },
+ vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
+ vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
+ vertexCredential: {
+ type: 'string',
+ description: 'Google Cloud OAuth credential ID for Vertex AI',
+ },
+ bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },
+ bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' },
+ bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' },
+} as const
diff --git a/apps/sim/components/emails/invitations/index.ts b/apps/sim/components/emails/invitations/index.ts
index 2133bad90d..6fa64cdc31 100644
--- a/apps/sim/components/emails/invitations/index.ts
+++ b/apps/sim/components/emails/invitations/index.ts
@@ -1,3 +1,4 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
+export { PollingGroupInvitationEmail } from './polling-group-invitation-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
diff --git a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
new file mode 100644
index 0000000000..e87436a154
--- /dev/null
+++ b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx
@@ -0,0 +1,52 @@
+import { Link, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+
+interface PollingGroupInvitationEmailProps {
+ inviterName?: string
+ organizationName?: string
+ pollingGroupName?: string
+ provider?: 'google-email' | 'outlook'
+ inviteLink?: string
+}
+
+export function PollingGroupInvitationEmail({
+ inviterName = 'A team member',
+ organizationName = 'an organization',
+ pollingGroupName = 'a polling group',
+ provider = 'google-email',
+ inviteLink = '',
+}: PollingGroupInvitationEmailProps) {
+ const brand = getBrandConfig()
+ const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
+
+ return (
+
+ Hello,
+
+ {inviterName} from {organizationName} has invited you to
+ join the polling group {pollingGroupName} on {brand.name}.
+
+
+
+ By accepting this invitation, your {providerName} account will be connected to enable email
+ polling for automated workflows.
+
+
+
+ Accept Invitation
+
+
+ {/* Divider */}
+
+
+
+ This invitation expires in 7 days. If you weren't expecting this email, you can safely
+ ignore it.
+
+
+ )
+}
+
+export default PollingGroupInvitationEmail
diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts
index bd18eeedca..90522246aa 100644
--- a/apps/sim/components/emails/render.ts
+++ b/apps/sim/components/emails/render.ts
@@ -12,6 +12,7 @@ import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/e
import {
BatchInvitationEmail,
InvitationEmail,
+ PollingGroupInvitationEmail,
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
@@ -184,6 +185,24 @@ export async function renderWorkspaceInvitationEmail(
)
}
+export async function renderPollingGroupInvitationEmail(params: {
+ inviterName: string
+ organizationName: string
+ pollingGroupName: string
+ provider: 'google-email' | 'outlook'
+ inviteLink: string
+}): Promise {
+ return await render(
+ PollingGroupInvitationEmail({
+ inviterName: params.inviterName,
+ organizationName: params.organizationName,
+ pollingGroupName: params.pollingGroupName,
+ provider: params.provider,
+ inviteLink: params.inviteLink,
+ })
+ )
+}
+
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number
diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts
index 26f451270b..bf8b9197b5 100644
--- a/apps/sim/components/emails/subjects.ts
+++ b/apps/sim/components/emails/subjects.ts
@@ -8,6 +8,7 @@ export type EmailSubjectType =
| 'reset-password'
| 'invitation'
| 'batch-invitation'
+ | 'polling-group-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
@@ -38,6 +39,8 @@ export function getEmailSubject(type: EmailSubjectType): string {
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
+ case 'polling-group-invitation':
+ return `You've been invited to join an email polling group on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
diff --git a/apps/sim/components/emcn/components/checkbox/checkbox.tsx b/apps/sim/components/emcn/components/checkbox/checkbox.tsx
index 6e5b6f64c7..c32ba636c3 100644
--- a/apps/sim/components/emcn/components/checkbox/checkbox.tsx
+++ b/apps/sim/components/emcn/components/checkbox/checkbox.tsx
@@ -23,7 +23,7 @@ import { cn } from '@/lib/core/utils/cn'
* ```
*/
const checkboxVariants = cva(
- 'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
+ 'peer shrink-0 rounded-sm border border-[var(--border-1)] bg-[var(--surface-4)] ring-offset-background transition-colors hover:border-[var(--border-muted)] hover:bg-[var(--surface-7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-[var(--text-muted)] data-[state=checked]:bg-[var(--text-muted)] data-[state=checked]:text-white dark:bg-[var(--surface-5)] dark:data-[state=checked]:border-[var(--surface-7)] dark:data-[state=checked]:bg-[var(--surface-7)] dark:data-[state=checked]:text-[var(--text-primary)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
{
variants: {
size: {
diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx
index b72db43c00..19dc56a343 100644
--- a/apps/sim/components/emcn/components/combobox/combobox.tsx
+++ b/apps/sim/components/emcn/components/combobox/combobox.tsx
@@ -467,7 +467,12 @@ const Combobox = forwardRef(
{...inputProps}
/>
{(overlayContent || SelectedIcon) && (
-
+
{overlayContent ? (
overlayContent
) : (
@@ -505,6 +510,7 @@ const Combobox = forwardRef
(
className={cn(
comboboxVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
+ disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleToggle}
diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx
index 3196852bde..1fb2616dad 100644
--- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx
+++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx
@@ -844,6 +844,7 @@ const DatePicker = React.forwardRef((props, ref
className={cn(
datePickerVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
+ disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={handleTriggerClick}
diff --git a/apps/sim/components/emcn/components/slider/slider.tsx b/apps/sim/components/emcn/components/slider/slider.tsx
index d6ccc54b72..71a84a9120 100644
--- a/apps/sim/components/emcn/components/slider/slider.tsx
+++ b/apps/sim/components/emcn/components/slider/slider.tsx
@@ -16,12 +16,13 @@ export interface SliderProps extends React.ComponentPropsWithoutRef, SliderProps>(
- ({ className, ...props }, ref) => (
+ ({ className, disabled, ...props }, ref) => (
,
React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+>(({ className, disabled, ...props }, ref) => (
) {
)
}
+
+export function BedrockIcon(props: SVGProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts
index ca6be4c3ec..f483bbfc78 100644
--- a/apps/sim/executor/constants.ts
+++ b/apps/sim/executor/constants.ts
@@ -181,6 +181,22 @@ export const MCP = {
TOOL_PREFIX: 'mcp-',
} as const
+export const CREDENTIAL_SET = {
+ PREFIX: 'credentialSet:',
+} as const
+
+export const CREDENTIAL = {
+ FOREIGN_LABEL: 'Saved by collaborator',
+} as const
+
+export function isCredentialSetValue(value: string | null | undefined): boolean {
+ return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX)
+}
+
+export function extractCredentialSetId(value: string): string {
+ return value.slice(CREDENTIAL_SET.PREFIX.length)
+}
+
export const MEMORY = {
DEFAULT_SLIDING_WINDOW_SIZE: 10,
DEFAULT_SLIDING_WINDOW_TOKENS: 4000,
diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts
index 1860dbc9fc..b454eca5c1 100644
--- a/apps/sim/executor/execution/block-executor.ts
+++ b/apps/sim/executor/execution/block-executor.ts
@@ -339,7 +339,7 @@ export class BlockExecutor {
if (isTrigger) {
const filtered: NormalizedBlockOutput = {}
- const internalKeys = ['webhook', 'workflowId', 'input']
+ const internalKeys = ['webhook', 'workflowId']
for (const [key, value] of Object.entries(output)) {
if (internalKeys.includes(key)) continue
filtered[key] = value
diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts
index e4a6a53283..38d403f042 100644
--- a/apps/sim/executor/execution/types.ts
+++ b/apps/sim/executor/execution/types.ts
@@ -17,6 +17,7 @@ export interface ExecutionMetadata {
isClientSession?: boolean
pendingBlocks?: string[]
resumeFromSnapshot?: boolean
+ credentialAccountUserId?: string
workflowStateOverride?: {
blocks: Record
edges: Edge[]
diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts
index 392a99da9a..2337cf4fb7 100644
--- a/apps/sim/executor/handlers/agent/agent-handler.ts
+++ b/apps/sim/executor/handlers/agent/agent-handler.ts
@@ -928,6 +928,9 @@ export class AgentBlockHandler implements BlockHandler {
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
+ bedrockAccessKeyId: inputs.bedrockAccessKeyId,
+ bedrockSecretKey: inputs.bedrockSecretKey,
+ bedrockRegion: inputs.bedrockRegion,
responseFormat,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
@@ -1029,6 +1032,9 @@ export class AgentBlockHandler implements BlockHandler {
azureApiVersion: providerRequest.azureApiVersion,
vertexProject: providerRequest.vertexProject,
vertexLocation: providerRequest.vertexLocation,
+ bedrockAccessKeyId: providerRequest.bedrockAccessKeyId,
+ bedrockSecretKey: providerRequest.bedrockSecretKey,
+ bedrockRegion: providerRequest.bedrockRegion,
responseFormat: providerRequest.responseFormat,
workflowId: providerRequest.workflowId,
workspaceId: ctx.workspaceId,
diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts
index 60694171ba..c3050f3a08 100644
--- a/apps/sim/executor/handlers/agent/types.ts
+++ b/apps/sim/executor/handlers/agent/types.ts
@@ -22,6 +22,9 @@ export interface AgentInputs {
vertexProject?: string
vertexLocation?: string
vertexCredential?: string
+ bedrockAccessKeyId?: string
+ bedrockSecretKey?: string
+ bedrockRegion?: string
reasoningEffort?: string
verbosity?: string
}
diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts
index e7a768ee33..c53486ec5b 100644
--- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts
+++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts
@@ -32,6 +32,9 @@ export class EvaluatorBlockHandler implements BlockHandler {
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
+ bedrockAccessKeyId: inputs.bedrockAccessKeyId,
+ bedrockSecretKey: inputs.bedrockSecretKey,
+ bedrockRegion: inputs.bedrockRegion,
}
const providerId = getProviderFromModel(evaluatorConfig.model)
@@ -128,6 +131,12 @@ export class EvaluatorBlockHandler implements BlockHandler {
providerRequest.azureApiVersion = inputs.azureApiVersion
}
+ if (providerId === 'bedrock') {
+ providerRequest.bedrockAccessKeyId = evaluatorConfig.bedrockAccessKeyId
+ providerRequest.bedrockSecretKey = evaluatorConfig.bedrockSecretKey
+ providerRequest.bedrockRegion = evaluatorConfig.bedrockRegion
+ }
+
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts
index 55524b7050..b00cc0f6ea 100644
--- a/apps/sim/executor/handlers/router/router-handler.ts
+++ b/apps/sim/executor/handlers/router/router-handler.ts
@@ -68,6 +68,9 @@ export class RouterBlockHandler implements BlockHandler {
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
+ bedrockAccessKeyId: inputs.bedrockAccessKeyId,
+ bedrockSecretKey: inputs.bedrockSecretKey,
+ bedrockRegion: inputs.bedrockRegion,
}
const providerId = getProviderFromModel(routerConfig.model)
@@ -104,6 +107,12 @@ export class RouterBlockHandler implements BlockHandler {
providerRequest.azureApiVersion = inputs.azureApiVersion
}
+ if (providerId === 'bedrock') {
+ providerRequest.bedrockAccessKeyId = routerConfig.bedrockAccessKeyId
+ providerRequest.bedrockSecretKey = routerConfig.bedrockSecretKey
+ providerRequest.bedrockRegion = routerConfig.bedrockRegion
+ }
+
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
@@ -197,6 +206,9 @@ export class RouterBlockHandler implements BlockHandler {
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
+ bedrockAccessKeyId: inputs.bedrockAccessKeyId,
+ bedrockSecretKey: inputs.bedrockSecretKey,
+ bedrockRegion: inputs.bedrockRegion,
}
const providerId = getProviderFromModel(routerConfig.model)
@@ -233,6 +245,12 @@ export class RouterBlockHandler implements BlockHandler {
providerRequest.azureApiVersion = inputs.azureApiVersion
}
+ if (providerId === 'bedrock') {
+ providerRequest.bedrockAccessKeyId = routerConfig.bedrockAccessKeyId
+ providerRequest.bedrockSecretKey = routerConfig.bedrockSecretKey
+ providerRequest.bedrockRegion = routerConfig.bedrockRegion
+ }
+
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts
index f33b49195f..9c266e8b40 100644
--- a/apps/sim/executor/types.ts
+++ b/apps/sim/executor/types.ts
@@ -124,6 +124,7 @@ export interface ExecutionMetadata {
isDebugSession?: boolean
context?: ExecutionContext
workflowConnections?: Array<{ source: string; target: string }>
+ credentialAccountUserId?: string
status?: 'running' | 'paused' | 'completed'
pausePoints?: string[]
resumeChain?: {
diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts
index 88b255de9d..36ec66827c 100644
--- a/apps/sim/hooks/queries/byok-keys.ts
+++ b/apps/sim/hooks/queries/byok-keys.ts
@@ -15,18 +15,26 @@ export interface BYOKKey {
updatedAt: string
}
+export interface BYOKKeysResponse {
+ keys: BYOKKey[]
+ byokEnabled: boolean
+}
+
export const byokKeysKeys = {
all: ['byok-keys'] as const,
workspace: (workspaceId: string) => [...byokKeysKeys.all, 'workspace', workspaceId] as const,
}
-async function fetchBYOKKeys(workspaceId: string): Promise {
+async function fetchBYOKKeys(workspaceId: string): Promise {
const response = await fetch(API_ENDPOINTS.WORKSPACE_BYOK_KEYS(workspaceId))
if (!response.ok) {
throw new Error(`Failed to load BYOK keys: ${response.statusText}`)
}
- const { keys } = await response.json()
- return keys
+ const data = await response.json()
+ return {
+ keys: data.keys ?? [],
+ byokEnabled: data.byokEnabled ?? true,
+ }
}
export function useBYOKKeys(workspaceId: string) {
@@ -36,6 +44,7 @@ export function useBYOKKeys(workspaceId: string) {
enabled: !!workspaceId,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
+ select: (data) => data,
})
}
diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts
new file mode 100644
index 0000000000..33da082f75
--- /dev/null
+++ b/apps/sim/hooks/queries/credential-sets.ts
@@ -0,0 +1,370 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { fetchJson } from '@/hooks/selectors/helpers'
+
+export interface CredentialSet {
+ id: string
+ name: string
+ description: string | null
+ providerId: string | null
+ createdBy: string
+ createdAt: string
+ updatedAt: string
+ creatorName: string | null
+ creatorEmail: string | null
+ memberCount: number
+}
+
+export interface CredentialSetMembership {
+ membershipId: string
+ status: string
+ joinedAt: string | null
+ credentialSetId: string
+ credentialSetName: string
+ credentialSetDescription: string | null
+ providerId: string | null
+ organizationId: string
+ organizationName: string
+}
+
+export interface CredentialSetInvitation {
+ invitationId: string
+ token: string
+ status: string
+ expiresAt: string
+ createdAt: string
+ credentialSetId: string
+ credentialSetName: string
+ providerId: string | null
+ organizationId: string
+ organizationName: string
+ invitedByName: string | null
+ invitedByEmail: string | null
+}
+
+interface CredentialSetsResponse {
+ credentialSets?: CredentialSet[]
+}
+
+interface MembershipsResponse {
+ memberships?: CredentialSetMembership[]
+}
+
+interface InvitationsResponse {
+ invitations?: CredentialSetInvitation[]
+}
+
+export const credentialSetKeys = {
+ all: ['credentialSets'] as const,
+ list: (organizationId?: string) => ['credentialSets', 'list', organizationId ?? 'none'] as const,
+ detail: (id?: string) => ['credentialSets', 'detail', id ?? 'none'] as const,
+ memberships: () => ['credentialSets', 'memberships'] as const,
+ invitations: () => ['credentialSets', 'invitations'] as const,
+}
+
+export async function fetchCredentialSets(organizationId: string): Promise {
+ if (!organizationId) return []
+ const data = await fetchJson('/api/credential-sets', {
+ searchParams: { organizationId },
+ })
+ return data.credentialSets ?? []
+}
+
+export function useCredentialSets(organizationId?: string, enabled = true) {
+ return useQuery({
+ queryKey: credentialSetKeys.list(organizationId),
+ queryFn: () => fetchCredentialSets(organizationId ?? ''),
+ enabled: Boolean(organizationId) && enabled,
+ staleTime: 60 * 1000,
+ })
+}
+
+interface CredentialSetDetailResponse {
+ credentialSet?: CredentialSet
+}
+
+export async function fetchCredentialSetById(id: string): Promise {
+ if (!id) return null
+ const data = await fetchJson(`/api/credential-sets/${id}`)
+ return data.credentialSet ?? null
+}
+
+export function useCredentialSetDetail(id?: string, enabled = true) {
+ return useQuery({
+ queryKey: credentialSetKeys.detail(id),
+ queryFn: () => fetchCredentialSetById(id ?? ''),
+ enabled: Boolean(id) && enabled,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useCredentialSetMemberships() {
+ return useQuery({
+ queryKey: credentialSetKeys.memberships(),
+ queryFn: async () => {
+ const data = await fetchJson('/api/credential-sets/memberships')
+ return data.memberships ?? []
+ },
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useCredentialSetInvitations() {
+ return useQuery({
+ queryKey: credentialSetKeys.invitations(),
+ queryFn: async () => {
+ const data = await fetchJson('/api/credential-sets/invitations')
+ return data.invitations ?? []
+ },
+ staleTime: 30 * 1000,
+ })
+}
+
+export function useAcceptCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (token: string) => {
+ const response = await fetch(`/api/credential-sets/invite/${token}`, {
+ method: 'POST',
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to accept invitation')
+ }
+ return response.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() })
+ },
+ })
+}
+
+export interface CreateCredentialSetData {
+ organizationId: string
+ name: string
+ description?: string
+ providerId?: string
+}
+
+export function useCreateCredentialSet() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: CreateCredentialSetData) => {
+ const response = await fetch('/api/credential-sets', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to create credential set')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) })
+ },
+ })
+}
+
+export function useCreateCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; email?: string }) => {
+ const response = await fetch(`/api/credential-sets/${data.credentialSetId}/invite`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: data.email }),
+ })
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to create invitation')
+ }
+ return response.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.all })
+ },
+ })
+}
+
+export interface CredentialSetMember {
+ id: string
+ userId: string
+ status: string
+ joinedAt: string | null
+ createdAt: string
+ userName: string | null
+ userEmail: string | null
+ userImage: string | null
+ credentials: { providerId: string; accountId: string }[]
+}
+
+interface MembersResponse {
+ members?: CredentialSetMember[]
+}
+
+export function useCredentialSetMembers(credentialSetId?: string) {
+ return useQuery({
+ queryKey: [...credentialSetKeys.detail(credentialSetId), 'members'],
+ queryFn: async () => {
+ const data = await fetchJson(
+ `/api/credential-sets/${credentialSetId}/members`
+ )
+ return data.members ?? []
+ },
+ enabled: Boolean(credentialSetId),
+ staleTime: 30 * 1000,
+ })
+}
+
+export function useRemoveCredentialSetMember() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; memberId: string }) => {
+ const response = await fetch(
+ `/api/credential-sets/${data.credentialSetId}/members?memberId=${data.memberId}`,
+ { method: 'DELETE' }
+ )
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to remove member')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'members'],
+ })
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.all })
+ },
+ })
+}
+
+export function useLeaveCredentialSet() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (credentialSetId: string) => {
+ const response = await fetch(
+ `/api/credential-sets/memberships?credentialSetId=${credentialSetId}`,
+ { method: 'DELETE' }
+ )
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to leave credential set')
+ }
+ return response.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
+ },
+ })
+}
+
+export interface DeleteCredentialSetParams {
+ credentialSetId: string
+ organizationId: string
+}
+
+export function useDeleteCredentialSet() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ credentialSetId }: DeleteCredentialSetParams) => {
+ const response = await fetch(`/api/credential-sets/${credentialSetId}`, {
+ method: 'DELETE',
+ })
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to delete credential set')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: credentialSetKeys.list(variables.organizationId),
+ })
+ queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
+ },
+ })
+}
+
+export interface CredentialSetInvitationDetail {
+ id: string
+ credentialSetId: string
+ email: string | null
+ token: string
+ status: string
+ expiresAt: string
+ createdAt: string
+ invitedBy: string
+}
+
+interface InvitationsDetailResponse {
+ invitations?: CredentialSetInvitationDetail[]
+}
+
+export function useCredentialSetInvitationsDetail(credentialSetId?: string) {
+ return useQuery({
+ queryKey: [...credentialSetKeys.detail(credentialSetId), 'invitations'],
+ queryFn: async () => {
+ const data = await fetchJson(
+ `/api/credential-sets/${credentialSetId}/invite`
+ )
+ return (data.invitations ?? []).filter((inv) => inv.status === 'pending')
+ },
+ enabled: Boolean(credentialSetId),
+ staleTime: 30 * 1000,
+ })
+}
+
+export function useCancelCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; invitationId: string }) => {
+ const response = await fetch(
+ `/api/credential-sets/${data.credentialSetId}/invite?invitationId=${data.invitationId}`,
+ { method: 'DELETE' }
+ )
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to cancel invitation')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'],
+ })
+ },
+ })
+}
+
+export function useResendCredentialSetInvitation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (data: { credentialSetId: string; invitationId: string; email: string }) => {
+ const response = await fetch(
+ `/api/credential-sets/${data.credentialSetId}/invite/${data.invitationId}`,
+ { method: 'POST' }
+ )
+ if (!response.ok) {
+ const result = await response.json()
+ throw new Error(result.error || 'Failed to resend invitation')
+ }
+ return response.json()
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'],
+ })
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts
index c9db54a362..9325ae0992 100644
--- a/apps/sim/hooks/queries/logs.ts
+++ b/apps/sim/hooks/queries/logs.ts
@@ -12,6 +12,9 @@ export const logKeys = {
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
dashboard: (workspaceId: string | undefined, filters: Record) =>
[...logKeys.all, 'dashboard', workspaceId ?? '', filters] as const,
+ executionSnapshots: () => [...logKeys.all, 'executionSnapshot'] as const,
+ executionSnapshot: (executionId: string | undefined) =>
+ [...logKeys.executionSnapshots(), executionId ?? ''] as const,
}
interface LogFilters {
@@ -196,3 +199,45 @@ export function useDashboardLogs(
placeholderData: keepPreviousData,
})
}
+
+export interface ExecutionSnapshotData {
+ executionId: string
+ workflowId: string
+ workflowState: Record
+ executionMetadata: {
+ trigger: string
+ startedAt: string
+ endedAt?: string
+ totalDurationMs?: number
+ cost: {
+ total: number | null
+ input: number | null
+ output: number | null
+ }
+ totalTokens: number | null
+ }
+}
+
+async function fetchExecutionSnapshot(executionId: string): Promise {
+ const response = await fetch(`/api/logs/execution/${executionId}`)
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch execution snapshot: ${response.statusText}`)
+ }
+
+ const data = await response.json()
+ if (!data) {
+ throw new Error('No execution snapshot data returned')
+ }
+
+ return data
+}
+
+export function useExecutionSnapshot(executionId: string | undefined) {
+ return useQuery({
+ queryKey: logKeys.executionSnapshot(executionId),
+ queryFn: () => fetchExecutionSnapshot(executionId as string),
+ enabled: Boolean(executionId),
+ staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
+ })
+}
diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts
index f692321653..414fae2d9c 100644
--- a/apps/sim/hooks/queries/oauth-credentials.ts
+++ b/apps/sim/hooks/queries/oauth-credentials.ts
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import type { Credential } from '@/lib/oauth'
+import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
+import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
import { fetchJson } from '@/hooks/selectors/helpers'
interface CredentialListResponse {
@@ -61,14 +63,28 @@ export function useOAuthCredentialDetail(
}
export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) {
+ // Check if this is a credential set value
+ const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false
+ const credentialSetId = isCredentialSet
+ ? credentialId?.slice(CREDENTIAL_SET.PREFIX.length)
+ : undefined
+
+ // Fetch credential set by ID directly
+ const { data: credentialSetData, isFetching: credentialSetLoading } = useCredentialSetDetail(
+ credentialSetId,
+ isCredentialSet
+ )
+
const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials(
providerId,
- Boolean(providerId)
+ Boolean(providerId) && !isCredentialSet
)
const selectedCredential = credentials.find((cred) => cred.id === credentialId)
- const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId)
+ const shouldFetchDetail = Boolean(
+ credentialId && !selectedCredential && providerId && workflowId && !isCredentialSet
+ )
const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail(
shouldFetchDetail ? credentialId : undefined,
@@ -77,12 +93,17 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo
)
const hasForeignMeta = foreignCredentials.length > 0
+ const isForeignCredentialSet = isCredentialSet && !credentialSetData && !credentialSetLoading
- const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null)
+ const displayName =
+ credentialSetData?.name ??
+ selectedCredential?.name ??
+ (hasForeignMeta ? CREDENTIAL.FOREIGN_LABEL : null) ??
+ (isForeignCredentialSet ? CREDENTIAL.FOREIGN_LABEL : null)
return {
displayName,
- isLoading: credentialsLoading || foreignLoading,
+ isLoading: credentialsLoading || foreignLoading || (isCredentialSet && credentialSetLoading),
hasForeignMeta,
}
}
diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts
index e3ed5b4c6d..7f4fbe7ee4 100644
--- a/apps/sim/hooks/queries/organization.ts
+++ b/apps/sim/hooks/queries/organization.ts
@@ -363,6 +363,32 @@ export function useCancelInvitation() {
})
}
+/**
+ * Resend invitation mutation
+ */
+interface ResendInvitationParams {
+ invitationId: string
+ orgId: string
+}
+
+export function useResendInvitation() {
+ return useMutation({
+ mutationFn: async ({ invitationId, orgId }: ResendInvitationParams) => {
+ const response = await fetch(`/api/organizations/${orgId}/invitations/${invitationId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || 'Failed to resend invitation')
+ }
+
+ return response.json()
+ },
+ })
+}
+
/**
* Update seats mutation (handles both add and reduce)
*/
diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts
index a9ca5d21e6..881a0a9939 100644
--- a/apps/sim/hooks/queries/workflows.ts
+++ b/apps/sim/hooks/queries/workflows.ts
@@ -13,6 +13,7 @@ import {
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
+import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowQueries')
@@ -20,6 +21,9 @@ export const workflowKeys = {
all: ['workflows'] as const,
lists: () => [...workflowKeys.all, 'list'] as const,
list: (workspaceId: string | undefined) => [...workflowKeys.lists(), workspaceId ?? ''] as const,
+ deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
+ deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
+ [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
}
function mapWorkflow(workflow: any): WorkflowMetadata {
@@ -339,3 +343,60 @@ export function useDuplicateWorkflowMutation() {
},
})
}
+
+interface DeploymentVersionStateResponse {
+ deployedState: WorkflowState
+}
+
+async function fetchDeploymentVersionState(
+ workflowId: string,
+ version: number
+): Promise {
+ const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch deployment version: ${response.statusText}`)
+ }
+
+ const data: DeploymentVersionStateResponse = await response.json()
+ if (!data.deployedState) {
+ throw new Error('No deployed state returned')
+ }
+
+ return data.deployedState
+}
+
+/**
+ * Hook for fetching the workflow state of a specific deployment version.
+ * Used in the deploy modal to preview historical versions.
+ */
+export function useDeploymentVersionState(workflowId: string | null, version: number | null) {
+ return useQuery({
+ queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined),
+ queryFn: () => fetchDeploymentVersionState(workflowId as string, version as number),
+ enabled: Boolean(workflowId) && version !== null,
+ staleTime: 5 * 60 * 1000, // 5 minutes - deployment versions don't change
+ })
+}
+
+interface RevertToVersionVariables {
+ workflowId: string
+ version: number
+}
+
+/**
+ * Mutation hook for reverting (loading) a deployment version into the current workflow.
+ */
+export function useRevertToVersion() {
+ return useMutation({
+ mutationFn: async ({ workflowId, version }: RevertToVersionVariables): Promise => {
+ const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
+ method: 'POST',
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to load deployment')
+ }
+ },
+ })
+}
diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts
index 3e81c35ced..e71a0cedb3 100644
--- a/apps/sim/hooks/use-webhook-management.ts
+++ b/apps/sim/hooks/use-webhook-management.ts
@@ -10,6 +10,8 @@ import { getTrigger, isTriggerValid } from '@/triggers'
const logger = createLogger('useWebhookManagement')
+const CREDENTIAL_SET_PREFIX = 'credentialSet:'
+
interface UseWebhookManagementProps {
blockId: string
triggerId?: string
@@ -169,7 +171,22 @@ export function useWebhookManagement({
if (webhook.providerConfig) {
const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId, webhook)
- useSubBlockStore.getState().setValue(blockId, 'triggerConfig', webhook.providerConfig)
+ // Filter out runtime/system fields from providerConfig before storing as triggerConfig
+ // These fields are managed by the system and should not be included in change detection
+ const {
+ credentialId: _credId,
+ credentialSetId: _credSetId,
+ userId: _userId,
+ historyId: _historyId,
+ lastCheckedTimestamp: _lastChecked,
+ setupCompleted: _setupCompleted,
+ externalId: _externalId,
+ triggerId: _triggerId,
+ blockId: _blockId,
+ ...userConfigurableFields
+ } = webhook.providerConfig as Record
+
+ useSubBlockStore.getState().setValue(blockId, 'triggerConfig', userConfigurableFields)
if (effectiveTriggerId) {
populateTriggerFieldsFromConfig(blockId, webhook.providerConfig, effectiveTriggerId)
@@ -220,9 +237,17 @@ export function useWebhookManagement({
}
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
+
+ const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX)
+ const credentialSetId = isCredentialSet
+ ? selectedCredentialId!.slice(CREDENTIAL_SET_PREFIX.length)
+ : undefined
+ const credentialId = isCredentialSet ? undefined : selectedCredentialId
+
const webhookConfig = {
...(triggerConfig || {}),
- ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
+ ...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
triggerId: effectiveTriggerId,
}
@@ -279,13 +304,20 @@ export function useWebhookManagement({
): Promise => {
const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig')
+ const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX)
+ const credentialSetId = isCredentialSet
+ ? selectedCredentialId!.slice(CREDENTIAL_SET_PREFIX.length)
+ : undefined
+ const credentialId = isCredentialSet ? undefined : selectedCredentialId
+
const response = await fetch(`/api/webhooks/${webhookIdToUpdate}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerConfig: {
...triggerConfig,
- ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
+ ...(credentialId ? { credentialId } : {}),
+ ...(credentialSetId ? { credentialSetId } : {}),
triggerId: effectiveTriggerId,
},
}),
diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts
index 5ac6d02f61..c1d1f4bad8 100644
--- a/apps/sim/instrumentation-node.ts
+++ b/apps/sim/instrumentation-node.ts
@@ -2,7 +2,9 @@
* Sim OpenTelemetry - Server-side Instrumentation
*/
+import type { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api'
+import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base'
import { createLogger } from '@sim/logger'
import { env } from './lib/core/config/env'
@@ -24,8 +26,25 @@ const DEFAULT_TELEMETRY_CONFIG = {
}
/**
- * Initialize OpenTelemetry SDK with proper configuration
+ * Span name prefixes we want to KEEP
*/
+const ALLOWED_SPAN_PREFIXES = [
+ 'platform.', // Our platform events
+ 'gen_ai.', // GenAI semantic convention spans
+ 'workflow.', // Workflow execution spans
+ 'block.', // Block execution spans
+ 'http.client.', // Our API block HTTP calls
+ 'function.', // Function block execution
+ 'router.', // Router block evaluation
+ 'condition.', // Condition block evaluation
+ 'loop.', // Loop block execution
+ 'parallel.', // Parallel block execution
+]
+
+function isBusinessSpan(spanName: string): boolean {
+ return ALLOWED_SPAN_PREFIXES.some((prefix) => spanName.startsWith(prefix))
+}
+
async function initializeOpenTelemetry() {
try {
if (env.NEXT_TELEMETRY_DISABLED === '1') {
@@ -52,18 +71,43 @@ async function initializeOpenTelemetry() {
)
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
- const { ParentBasedSampler, TraceIdRatioBasedSampler } = await import(
+ const { ParentBasedSampler, TraceIdRatioBasedSampler, SamplingDecision } = await import(
'@opentelemetry/sdk-trace-base'
)
+ const createBusinessSpanSampler = (baseSampler: Sampler): Sampler => ({
+ shouldSample(
+ context: Context,
+ traceId: string,
+ spanName: string,
+ spanKind: SpanKind,
+ attributes: Attributes,
+ links: Link[]
+ ): SamplingResult {
+ if (attributes['next.span_type']) {
+ return { decision: SamplingDecision.NOT_RECORD }
+ }
+
+ if (isBusinessSpan(spanName)) {
+ return baseSampler.shouldSample(context, traceId, spanName, spanKind, attributes, links)
+ }
+
+ return { decision: SamplingDecision.NOT_RECORD }
+ },
+
+ toString(): string {
+ return `BusinessSpanSampler{baseSampler=${baseSampler.toString()}}`
+ },
+ })
+
const exporter = new OTLPTraceExporter({
url: telemetryConfig.endpoint,
headers: {},
- timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), // Max 10s
+ timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
keepAlive: false,
})
- const spanProcessor = new BatchSpanProcessor(exporter, {
+ const batchProcessor = new BatchSpanProcessor(exporter, {
maxQueueSize: telemetryConfig.batchSettings.maxQueueSize,
maxExportBatchSize: telemetryConfig.batchSettings.maxExportBatchSize,
scheduledDelayMillis: telemetryConfig.batchSettings.scheduledDelayMillis,
@@ -82,13 +126,14 @@ async function initializeOpenTelemetry() {
})
)
- const sampler = new ParentBasedSampler({
- root: new TraceIdRatioBasedSampler(0.1), // 10% sampling for root spans
+ const baseSampler = new ParentBasedSampler({
+ root: new TraceIdRatioBasedSampler(0.1),
})
+ const sampler = createBusinessSpanSampler(baseSampler)
const sdk = new NodeSDK({
resource,
- spanProcessor,
+ spanProcessor: batchProcessor,
sampler,
traceExporter: exporter,
})
@@ -107,7 +152,7 @@ async function initializeOpenTelemetry() {
process.on('SIGTERM', shutdownHandler)
process.on('SIGINT', shutdownHandler)
- logger.info('OpenTelemetry instrumentation initialized')
+ logger.info('OpenTelemetry instrumentation initialized with business span filtering')
} catch (error) {
logger.error('Failed to initialize OpenTelemetry instrumentation', error)
}
diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts
index 458da3452a..34c589c21a 100644
--- a/apps/sim/lib/api-key/byok.ts
+++ b/apps/sim/lib/api-key/byok.ts
@@ -2,7 +2,12 @@ import { db } from '@sim/db'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
+import { isWorkspaceOnEnterprisePlan } from '@/lib/billing'
+import { getRotatingApiKey } from '@/lib/core/config/api-keys'
+import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
+import { getHostedModels } from '@/providers/models'
+import { useProvidersStore } from '@/stores/providers/store'
const logger = createLogger('BYOKKeys')
@@ -51,9 +56,6 @@ export async function getApiKeyWithBYOK(
workspaceId: string | undefined | null,
userProvidedKey?: string
): Promise<{ apiKey: string; isBYOK: boolean }> {
- const { isHosted } = await import('@/lib/core/config/feature-flags')
- const { useProvidersStore } = await import('@/stores/providers/store')
-
const isOllamaModel =
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
if (isOllamaModel) {
@@ -66,6 +68,11 @@ export async function getApiKeyWithBYOK(
return { apiKey: userProvidedKey || 'empty', isBYOK: false }
}
+ const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/')
+ if (isBedrockModel) {
+ return { apiKey: 'bedrock-uses-own-credentials', isBYOK: false }
+ }
+
const isOpenAIModel = provider === 'openai'
const isClaudeModel = provider === 'anthropic'
const isGeminiModel = provider === 'google'
@@ -78,23 +85,27 @@ export async function getApiKeyWithBYOK(
workspaceId &&
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
) {
- const { getHostedModels } = await import('@/providers/models')
const hostedModels = getHostedModels()
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
if (isModelHosted || isMistralModel) {
- const byokResult = await getBYOKKey(workspaceId, byokProviderId)
- if (byokResult) {
- logger.info('Using BYOK key', { provider, model, workspaceId })
- return byokResult
+ const hasEnterprise = await isWorkspaceOnEnterprisePlan(workspaceId)
+
+ if (hasEnterprise) {
+ const byokResult = await getBYOKKey(workspaceId, byokProviderId)
+ if (byokResult) {
+ logger.info('Using BYOK key', { provider, model, workspaceId })
+ return byokResult
+ }
+ logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
+ } else {
+ logger.debug('Workspace not on enterprise plan, skipping BYOK', { workspaceId })
}
- logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
if (isModelHosted) {
try {
- const { getRotatingApiKey } = await import('@/lib/core/config/api-keys')
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
return { apiKey: serverKey, isBYOK: false }
} catch (_error) {
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 3922c4f0b5..43e4c919ad 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -47,14 +47,17 @@ import { env } from '@/lib/core/config/env'
import {
isAuthDisabled,
isBillingEnabled,
+ isEmailPasswordEnabled,
isEmailVerificationEnabled,
isHosted,
isRegistrationDisabled,
} from '@/lib/core/config/feature-flags'
+import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
+import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
@@ -96,6 +99,15 @@ export const auth = betterAuth({
userId: user.id,
})
+ try {
+ PlatformEvents.userSignedUp({
+ userId: user.id,
+ authMethod: 'email',
+ })
+ } catch {
+ // Telemetry should not fail the operation
+ }
+
try {
await handleNewUser(user.id)
} catch (error) {
@@ -188,6 +200,49 @@ export const auth = betterAuth({
})
.where(eq(schema.account.id, existing.id))
+ // Sync webhooks for credential sets after reconnecting
+ const requestId = crypto.randomUUID().slice(0, 8)
+ const userMemberships = await db
+ .select({
+ credentialSetId: schema.credentialSetMember.credentialSetId,
+ providerId: schema.credentialSet.providerId,
+ })
+ .from(schema.credentialSetMember)
+ .innerJoin(
+ schema.credentialSet,
+ eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
+ )
+ .where(
+ and(
+ eq(schema.credentialSetMember.userId, account.userId),
+ eq(schema.credentialSetMember.status, 'active')
+ )
+ )
+
+ for (const membership of userMemberships) {
+ if (membership.providerId === account.providerId) {
+ try {
+ await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
+ logger.info(
+ '[account.create.before] Synced webhooks after credential reconnect',
+ {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ }
+ )
+ } catch (error) {
+ logger.error(
+ '[account.create.before] Failed to sync webhooks after credential reconnect',
+ {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ error,
+ }
+ )
+ }
+ }
+ }
+
return false
}
@@ -235,6 +290,55 @@ export const auth = betterAuth({
await db.update(schema.account).set(updates).where(eq(schema.account.id, account.id))
}
}
+
+ // Sync webhooks for credential sets after connecting a new credential
+ const requestId = crypto.randomUUID().slice(0, 8)
+ const userMemberships = await db
+ .select({
+ credentialSetId: schema.credentialSetMember.credentialSetId,
+ providerId: schema.credentialSet.providerId,
+ })
+ .from(schema.credentialSetMember)
+ .innerJoin(
+ schema.credentialSet,
+ eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id)
+ )
+ .where(
+ and(
+ eq(schema.credentialSetMember.userId, account.userId),
+ eq(schema.credentialSetMember.status, 'active')
+ )
+ )
+
+ for (const membership of userMemberships) {
+ if (membership.providerId === account.providerId) {
+ try {
+ await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
+ logger.info('[account.create.after] Synced webhooks after credential connect', {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ })
+ } catch (error) {
+ logger.error(
+ '[account.create.after] Failed to sync webhooks after credential connect',
+ {
+ credentialSetId: membership.credentialSetId,
+ providerId: account.providerId,
+ error,
+ }
+ )
+ }
+ }
+ }
+
+ try {
+ PlatformEvents.oauthConnected({
+ userId: account.userId,
+ provider: account.providerId,
+ })
+ } catch {
+ // Telemetry should not fail the operation
+ }
},
},
},
@@ -377,6 +481,12 @@ export const auth = betterAuth({
if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled)
throw new Error('Registration is disabled, please contact your admin.')
+ if (!isEmailPasswordEnabled) {
+ const emailPasswordPaths = ['/sign-in/email', '/sign-up/email', '/email-otp']
+ if (emailPasswordPaths.some((path) => ctx.path.startsWith(path)))
+ throw new Error('Email/password authentication is disabled. Please use SSO to sign in.')
+ }
+
if (
(ctx.path.startsWith('/sign-in') || ctx.path.startsWith('/sign-up')) &&
(env.ALLOWED_LOGIN_EMAILS || env.ALLOWED_LOGIN_DOMAINS)
diff --git a/apps/sim/lib/billing/core/plan.ts b/apps/sim/lib/billing/core/plan.ts
new file mode 100644
index 0000000000..073a9a6a36
--- /dev/null
+++ b/apps/sim/lib/billing/core/plan.ts
@@ -0,0 +1,53 @@
+import { db } from '@sim/db'
+import { member, subscription } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq, inArray } from 'drizzle-orm'
+import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
+
+const logger = createLogger('PlanLookup')
+
+/**
+ * Get the highest priority active subscription for a user
+ * Priority: Enterprise > Team > Pro > Free
+ */
+export async function getHighestPrioritySubscription(userId: string) {
+ try {
+ const personalSubs = await db
+ .select()
+ .from(subscription)
+ .where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
+
+ const memberships = await db
+ .select({ organizationId: member.organizationId })
+ .from(member)
+ .where(eq(member.userId, userId))
+
+ const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
+
+ let orgSubs: typeof personalSubs = []
+ if (orgIds.length > 0) {
+ orgSubs = await db
+ .select()
+ .from(subscription)
+ .where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
+ }
+
+ const allSubs = [...personalSubs, ...orgSubs]
+
+ if (allSubs.length === 0) return null
+
+ const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
+ if (enterpriseSub) return enterpriseSub
+
+ const teamSub = allSubs.find((s) => checkTeamPlan(s))
+ if (teamSub) return teamSub
+
+ const proSub = allSubs.find((s) => checkProPlan(s))
+ if (proSub) return proSub
+
+ return null
+ } catch (error) {
+ logger.error('Error getting highest priority subscription', { error, userId })
+ return null
+ }
+}
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index 9b5f4047bd..c1d5f7e3a0 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -1,7 +1,9 @@
import { db } from '@sim/db'
-import { member, subscription, user, userStats } from '@sim/db/schema'
+import { member, subscription, user, userStats, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, inArray } from 'drizzle-orm'
+import { and, eq } from 'drizzle-orm'
+import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
+import { getUserUsageLimit } from '@/lib/billing/core/usage'
import {
checkEnterprisePlan,
checkProPlan,
@@ -10,65 +12,17 @@ import {
getPerUserMinimumLimit,
} from '@/lib/billing/subscriptions/utils'
import type { UserSubscriptionState } from '@/lib/billing/types'
-import { isProd } from '@/lib/core/config/feature-flags'
+import {
+ isCredentialSetsEnabled,
+ isHosted,
+ isProd,
+ isSsoEnabled,
+} from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('SubscriptionCore')
-/**
- * Core subscription management - single source of truth
- * Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
- */
-
-/**
- * Get the highest priority active subscription for a user
- * Priority: Enterprise > Team > Pro > Free
- */
-export async function getHighestPrioritySubscription(userId: string) {
- try {
- // Get direct subscriptions
- const personalSubs = await db
- .select()
- .from(subscription)
- .where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
-
- // Get organization memberships
- const memberships = await db
- .select({ organizationId: member.organizationId })
- .from(member)
- .where(eq(member.userId, userId))
-
- const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
-
- // Get organization subscriptions
- let orgSubs: any[] = []
- if (orgIds.length > 0) {
- orgSubs = await db
- .select()
- .from(subscription)
- .where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
- }
-
- const allSubs = [...personalSubs, ...orgSubs]
-
- if (allSubs.length === 0) return null
-
- // Return highest priority subscription
- const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
- if (enterpriseSub) return enterpriseSub
-
- const teamSub = allSubs.find((s) => checkTeamPlan(s))
- if (teamSub) return teamSub
-
- const proSub = allSubs.find((s) => checkProPlan(s))
- if (proSub) return proSub
-
- return null
- } catch (error) {
- logger.error('Error getting highest priority subscription', { error, userId })
- return null
- }
-}
+export { getHighestPrioritySubscription }
/**
* Check if user is on Pro plan (direct or via organization)
@@ -144,6 +98,224 @@ export async function isEnterprisePlan(userId: string): Promise {
}
}
+/**
+ * Check if user is an admin or owner of an enterprise organization
+ * Returns true if:
+ * - User is a member of an enterprise organization AND
+ * - User's role in that organization is 'owner' or 'admin'
+ *
+ * In non-production environments, returns true for convenience.
+ */
+export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise {
+ try {
+ if (!isProd) {
+ return true
+ }
+
+ const [memberRecord] = await db
+ .select({
+ organizationId: member.organizationId,
+ role: member.role,
+ })
+ .from(member)
+ .where(eq(member.userId, userId))
+ .limit(1)
+
+ if (!memberRecord) {
+ return false
+ }
+
+ if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
+ return false
+ }
+
+ const [orgSub] = await db
+ .select()
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.referenceId, memberRecord.organizationId),
+ eq(subscription.status, 'active')
+ )
+ )
+ .limit(1)
+
+ const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
+
+ if (isEnterprise) {
+ logger.info('User is enterprise org admin/owner', {
+ userId,
+ organizationId: memberRecord.organizationId,
+ role: memberRecord.role,
+ })
+ }
+
+ return !!isEnterprise
+ } catch (error) {
+ logger.error('Error checking enterprise org admin/owner status', { error, userId })
+ return false
+ }
+}
+
+/**
+ * Check if user is an admin or owner of a team or enterprise organization
+ * Returns true if:
+ * - User is a member of a team/enterprise organization AND
+ * - User's role in that organization is 'owner' or 'admin'
+ *
+ * In non-production environments, returns true for convenience.
+ */
+export async function isTeamOrgAdminOrOwner(userId: string): Promise {
+ try {
+ if (!isProd) {
+ return true
+ }
+
+ const [memberRecord] = await db
+ .select({
+ organizationId: member.organizationId,
+ role: member.role,
+ })
+ .from(member)
+ .where(eq(member.userId, userId))
+ .limit(1)
+
+ if (!memberRecord) {
+ return false
+ }
+
+ if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
+ return false
+ }
+
+ const [orgSub] = await db
+ .select()
+ .from(subscription)
+ .where(
+ and(
+ eq(subscription.referenceId, memberRecord.organizationId),
+ eq(subscription.status, 'active')
+ )
+ )
+ .limit(1)
+
+ const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
+
+ if (hasTeamPlan) {
+ logger.info('User is team org admin/owner', {
+ userId,
+ organizationId: memberRecord.organizationId,
+ role: memberRecord.role,
+ plan: orgSub.plan,
+ })
+ }
+
+ return !!hasTeamPlan
+ } catch (error) {
+ logger.error('Error checking team org admin/owner status', { error, userId })
+ return false
+ }
+}
+
+/**
+ * Check if a workspace has access to enterprise features (BYOK)
+ * Used at execution time to determine if BYOK keys should be used
+ * Returns true if workspace's billed account is on enterprise plan
+ */
+export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise {
+ try {
+ if (!isProd) {
+ return true
+ }
+
+ const [ws] = await db
+ .select({ billedAccountUserId: workspace.billedAccountUserId })
+ .from(workspace)
+ .where(eq(workspace.id, workspaceId))
+ .limit(1)
+
+ if (!ws) {
+ return false
+ }
+
+ return isEnterprisePlan(ws.billedAccountUserId)
+ } catch (error) {
+ logger.error('Error checking workspace enterprise status', { error, workspaceId })
+ return false
+ }
+}
+
+/**
+ * Check if an organization has team or enterprise plan
+ * Used at execution time (e.g., polling services) to check org billing directly
+ */
+export async function isOrganizationOnTeamOrEnterprisePlan(
+ organizationId: string
+): Promise {
+ try {
+ if (!isProd) {
+ return true
+ }
+
+ if (isCredentialSetsEnabled && !isHosted) {
+ return true
+ }
+
+ const [orgSub] = await db
+ .select()
+ .from(subscription)
+ .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
+ .limit(1)
+
+ return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
+ } catch (error) {
+ logger.error('Error checking organization plan status', { error, organizationId })
+ return false
+ }
+}
+
+/**
+ * Check if user has access to credential sets (email polling) feature
+ * Returns true if:
+ * - CREDENTIAL_SETS_ENABLED env var is set (self-hosted override), OR
+ * - User is admin/owner of a team/enterprise organization
+ *
+ * In non-production environments, returns true for convenience.
+ */
+export async function hasCredentialSetsAccess(userId: string): Promise {
+ try {
+ if (isCredentialSetsEnabled && !isHosted) {
+ return true
+ }
+
+ return isTeamOrgAdminOrOwner(userId)
+ } catch (error) {
+ logger.error('Error checking credential sets access', { error, userId })
+ return false
+ }
+}
+
+/**
+ * Check if user has access to SSO feature
+ * Returns true if:
+ * - SSO_ENABLED env var is set (self-hosted override), OR
+ * - User is admin/owner of an enterprise organization
+ *
+ * In non-production environments, returns true for convenience.
+ */
+export async function hasSSOAccess(userId: string): Promise {
+ try {
+ if (isSsoEnabled && !isHosted) {
+ return true
+ }
+
+ return isEnterpriseOrgAdminOrOwner(userId)
+ } catch (error) {
+ logger.error('Error checking SSO access', { error, userId })
+ return false
+ }
+}
+
/**
* Check if user has exceeded their cost limit based on current period usage
*/
@@ -160,7 +332,6 @@ export async function hasExceededCostLimit(userId: string): Promise {
if (subscription) {
// Team/Enterprise: Use organization limit
if (subscription.plan === 'team' || subscription.plan === 'enterprise') {
- const { getUserUsageLimit } = await import('@/lib/billing/core/usage')
limit = await getUserUsageLimit(userId)
logger.info('Using organization limit', {
userId,
@@ -221,14 +392,16 @@ export async function getUserSubscriptionState(userId: string): Promise ({
- env: {
+vi.mock('@/lib/core/config/env', () =>
+ createEnvMock({
NEXT_PUBLIC_APP_URL: 'https://example.com',
NEXT_PUBLIC_SOCKET_URL: 'https://socket.example.com',
OLLAMA_URL: 'http://localhost:11434',
@@ -13,20 +14,8 @@ vi.mock('@/lib/core/config/env', () => ({
NEXT_PUBLIC_BRAND_FAVICON_URL: 'https://brand.example.com/favicon.ico',
NEXT_PUBLIC_PRIVACY_URL: 'https://legal.example.com/privacy',
NEXT_PUBLIC_TERMS_URL: 'https://legal.example.com/terms',
- },
- getEnv: vi.fn((key: string) => {
- const envMap: Record = {
- NEXT_PUBLIC_APP_URL: 'https://example.com',
- NEXT_PUBLIC_SOCKET_URL: 'https://socket.example.com',
- OLLAMA_URL: 'http://localhost:11434',
- NEXT_PUBLIC_BRAND_LOGO_URL: 'https://brand.example.com/logo.png',
- NEXT_PUBLIC_BRAND_FAVICON_URL: 'https://brand.example.com/favicon.ico',
- NEXT_PUBLIC_PRIVACY_URL: 'https://legal.example.com/privacy',
- NEXT_PUBLIC_TERMS_URL: 'https://legal.example.com/terms',
- }
- return envMap[key] || ''
- }),
-}))
+ })
+)
vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: false,
diff --git a/apps/sim/lib/core/security/encryption.test.ts b/apps/sim/lib/core/security/encryption.test.ts
index 0e54d21dec..67540e5cac 100644
--- a/apps/sim/lib/core/security/encryption.test.ts
+++ b/apps/sim/lib/core/security/encryption.test.ts
@@ -6,6 +6,10 @@ const mockEnv = vi.hoisted(() => ({
vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
+ isTruthy: (value: string | boolean | number | undefined) =>
+ typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
+ isFalsy: (value: string | boolean | number | undefined) =>
+ typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false,
}))
vi.mock('@sim/logger', () => ({
diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts
index b61882e2c6..78268de85b 100644
--- a/apps/sim/lib/core/security/input-validation.test.ts
+++ b/apps/sim/lib/core/security/input-validation.test.ts
@@ -595,26 +595,6 @@ describe('validateUrlWithDNS', () => {
expect(result.isValid).toBe(false)
})
})
-
- describe('DNS resolution', () => {
- it('should accept valid public URLs and return resolved IP', async () => {
- const result = await validateUrlWithDNS('https://example.com')
- expect(result.isValid).toBe(true)
- expect(result.resolvedIP).toBeDefined()
- expect(result.originalHostname).toBe('example.com')
- })
-
- it('should reject URLs that resolve to private IPs', async () => {
- const result = await validateUrlWithDNS('https://localhost.localdomain')
- expect(result.isValid).toBe(false)
- })
-
- it('should reject unresolvable hostnames', async () => {
- const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
- expect(result.isValid).toBe(false)
- expect(result.error).toContain('could not be resolved')
- })
- })
})
describe('createPinnedUrl', () => {
diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts
index dc2c60e3a2..c12fe1303a 100644
--- a/apps/sim/lib/core/telemetry.ts
+++ b/apps/sim/lib/core/telemetry.ts
@@ -449,3 +449,505 @@ export function trackPlatformEvent(
// Silently fail
}
}
+
+// ============================================================================
+// PLATFORM TELEMETRY EVENTS
+// ============================================================================
+//
+// Naming Convention:
+// Event: platform.{resource}.{past_tense_action}
+// Attribute: {resource}.{attribute_name}
+//
+// Examples:
+// Event: platform.user.signed_up
+// Attribute: user.id, user.auth_method, workspace.id
+//
+// Categories:
+// - User/Auth: platform.user.*
+// - Workspace: platform.workspace.*
+// - Workflow: platform.workflow.*
+// - Knowledge Base: platform.knowledge_base.*
+// - MCP: platform.mcp.*
+// - API Keys: platform.api_key.*
+// - OAuth: platform.oauth.*
+// - Webhook: platform.webhook.*
+// - Billing: platform.billing.*
+// - Template: platform.template.*
+// ============================================================================
+
+/**
+ * Platform Events - Typed event tracking helpers
+ * These provide type-safe, consistent telemetry across the platform
+ */
+export const PlatformEvents = {
+ /**
+ * Track user sign up
+ */
+ userSignedUp: (attrs: {
+ userId: string
+ authMethod: 'email' | 'oauth' | 'sso'
+ provider?: string
+ }) => {
+ trackPlatformEvent('platform.user.signed_up', {
+ 'user.id': attrs.userId,
+ 'user.auth_method': attrs.authMethod,
+ ...(attrs.provider && { 'user.auth_provider': attrs.provider }),
+ })
+ },
+
+ /**
+ * Track user sign in
+ */
+ userSignedIn: (attrs: {
+ userId: string
+ authMethod: 'email' | 'oauth' | 'sso'
+ provider?: string
+ }) => {
+ trackPlatformEvent('platform.user.signed_in', {
+ 'user.id': attrs.userId,
+ 'user.auth_method': attrs.authMethod,
+ ...(attrs.provider && { 'user.auth_provider': attrs.provider }),
+ })
+ },
+
+ /**
+ * Track password reset requested
+ */
+ passwordResetRequested: (attrs: { userId: string }) => {
+ trackPlatformEvent('platform.user.password_reset_requested', {
+ 'user.id': attrs.userId,
+ })
+ },
+
+ /**
+ * Track workspace created
+ */
+ workspaceCreated: (attrs: { workspaceId: string; userId: string; name: string }) => {
+ trackPlatformEvent('platform.workspace.created', {
+ 'workspace.id': attrs.workspaceId,
+ 'workspace.name': attrs.name,
+ 'user.id': attrs.userId,
+ })
+ },
+
+ /**
+ * Track member invited to workspace
+ */
+ workspaceMemberInvited: (attrs: {
+ workspaceId: string
+ invitedBy: string
+ inviteeEmail: string
+ role: string
+ }) => {
+ trackPlatformEvent('platform.workspace.member_invited', {
+ 'workspace.id': attrs.workspaceId,
+ 'user.id': attrs.invitedBy,
+ 'invitation.role': attrs.role,
+ })
+ },
+
+ /**
+ * Track member joined workspace
+ */
+ workspaceMemberJoined: (attrs: { workspaceId: string; userId: string; role: string }) => {
+ trackPlatformEvent('platform.workspace.member_joined', {
+ 'workspace.id': attrs.workspaceId,
+ 'user.id': attrs.userId,
+ 'member.role': attrs.role,
+ })
+ },
+
+ /**
+ * Track workflow created
+ */
+ workflowCreated: (attrs: {
+ workflowId: string
+ name: string
+ workspaceId?: string
+ folderId?: string
+ }) => {
+ trackPlatformEvent('platform.workflow.created', {
+ 'workflow.id': attrs.workflowId,
+ 'workflow.name': attrs.name,
+ 'workflow.has_workspace': !!attrs.workspaceId,
+ 'workflow.has_folder': !!attrs.folderId,
+ })
+ },
+
+ /**
+ * Track workflow deleted
+ */
+ workflowDeleted: (attrs: { workflowId: string; workspaceId?: string }) => {
+ trackPlatformEvent('platform.workflow.deleted', {
+ 'workflow.id': attrs.workflowId,
+ ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }),
+ })
+ },
+
+ /**
+ * Track workflow duplicated
+ */
+ workflowDuplicated: (attrs: {
+ sourceWorkflowId: string
+ newWorkflowId: string
+ workspaceId?: string
+ }) => {
+ trackPlatformEvent('platform.workflow.duplicated', {
+ 'workflow.source_id': attrs.sourceWorkflowId,
+ 'workflow.new_id': attrs.newWorkflowId,
+ ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }),
+ })
+ },
+
+ /**
+ * Track workflow deployed
+ */
+ workflowDeployed: (attrs: {
+ workflowId: string
+ workflowName: string
+ blocksCount: number
+ edgesCount: number
+ version: number
+ loopsCount?: number
+ parallelsCount?: number
+ blockTypes?: string
+ }) => {
+ trackPlatformEvent('platform.workflow.deployed', {
+ 'workflow.id': attrs.workflowId,
+ 'workflow.name': attrs.workflowName,
+ 'workflow.blocks_count': attrs.blocksCount,
+ 'workflow.edges_count': attrs.edgesCount,
+ 'deployment.version': attrs.version,
+ ...(attrs.loopsCount !== undefined && { 'workflow.loops_count': attrs.loopsCount }),
+ ...(attrs.parallelsCount !== undefined && {
+ 'workflow.parallels_count': attrs.parallelsCount,
+ }),
+ ...(attrs.blockTypes && { 'workflow.block_types': attrs.blockTypes }),
+ })
+ },
+
+ /**
+ * Track workflow undeployed
+ */
+ workflowUndeployed: (attrs: { workflowId: string }) => {
+ trackPlatformEvent('platform.workflow.undeployed', {
+ 'workflow.id': attrs.workflowId,
+ })
+ },
+
+ /**
+ * Track workflow executed
+ */
+ workflowExecuted: (attrs: {
+ workflowId: string
+ durationMs: number
+ status: 'success' | 'error' | 'cancelled' | 'paused'
+ trigger: string
+ blocksExecuted: number
+ hasErrors: boolean
+ totalCost?: number
+ errorMessage?: string
+ }) => {
+ trackPlatformEvent('platform.workflow.executed', {
+ 'workflow.id': attrs.workflowId,
+ 'execution.duration_ms': attrs.durationMs,
+ 'execution.status': attrs.status,
+ 'execution.trigger': attrs.trigger,
+ 'execution.blocks_executed': attrs.blocksExecuted,
+ 'execution.has_errors': attrs.hasErrors,
+ ...(attrs.totalCost !== undefined && { 'execution.total_cost': attrs.totalCost }),
+ ...(attrs.errorMessage && { 'execution.error_message': attrs.errorMessage }),
+ })
+ },
+
+ /**
+ * Track knowledge base created
+ */
+ knowledgeBaseCreated: (attrs: {
+ knowledgeBaseId: string
+ name: string
+ workspaceId?: string
+ }) => {
+ trackPlatformEvent('platform.knowledge_base.created', {
+ 'knowledge_base.id': attrs.knowledgeBaseId,
+ 'knowledge_base.name': attrs.name,
+ ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }),
+ })
+ },
+
+ /**
+ * Track knowledge base deleted
+ */
+ knowledgeBaseDeleted: (attrs: { knowledgeBaseId: string }) => {
+ trackPlatformEvent('platform.knowledge_base.deleted', {
+ 'knowledge_base.id': attrs.knowledgeBaseId,
+ })
+ },
+
+ /**
+ * Track documents uploaded to knowledge base
+ */
+ knowledgeBaseDocumentsUploaded: (attrs: {
+ knowledgeBaseId: string
+ documentsCount: number
+ uploadType: 'single' | 'bulk'
+ chunkSize?: number
+ recipe?: string
+ mimeType?: string
+ fileSize?: number
+ }) => {
+ trackPlatformEvent('platform.knowledge_base.documents_uploaded', {
+ 'knowledge_base.id': attrs.knowledgeBaseId,
+ 'documents.count': attrs.documentsCount,
+ 'documents.upload_type': attrs.uploadType,
+ ...(attrs.chunkSize !== undefined && { 'processing.chunk_size': attrs.chunkSize }),
+ ...(attrs.recipe && { 'processing.recipe': attrs.recipe }),
+ ...(attrs.mimeType && { 'document.mime_type': attrs.mimeType }),
+ ...(attrs.fileSize !== undefined && { 'document.file_size': attrs.fileSize }),
+ })
+ },
+
+ /**
+ * Track knowledge base searched
+ */
+ knowledgeBaseSearched: (attrs: {
+ knowledgeBaseId: string
+ resultsCount: number
+ workspaceId?: string
+ }) => {
+ trackPlatformEvent('platform.knowledge_base.searched', {
+ 'knowledge_base.id': attrs.knowledgeBaseId,
+ 'search.results_count': attrs.resultsCount,
+ ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }),
+ })
+ },
+
+ /**
+ * Track API key generated
+ */
+ apiKeyGenerated: (attrs: { userId: string; keyName?: string }) => {
+ trackPlatformEvent('platform.api_key.generated', {
+ 'user.id': attrs.userId,
+ ...(attrs.keyName && { 'api_key.name': attrs.keyName }),
+ })
+ },
+
+ /**
+ * Track API key revoked
+ */
+ apiKeyRevoked: (attrs: { userId: string; keyId: string }) => {
+ trackPlatformEvent('platform.api_key.revoked', {
+ 'user.id': attrs.userId,
+ 'api_key.id': attrs.keyId,
+ })
+ },
+
+ /**
+ * Track OAuth provider connected
+ */
+ oauthConnected: (attrs: { userId: string; provider: string; workspaceId?: string }) => {
+ trackPlatformEvent('platform.oauth.connected', {
+ 'user.id': attrs.userId,
+ 'oauth.provider': attrs.provider,
+ ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }),
+ })
+ },
+
+ /**
+ * Track OAuth provider disconnected
+ */
+ oauthDisconnected: (attrs: { userId: string; provider: string }) => {
+ trackPlatformEvent('platform.oauth.disconnected', {
+ 'user.id': attrs.userId,
+ 'oauth.provider': attrs.provider,
+ })
+ },
+
+ /**
+ * Track credential set created
+ */
+ credentialSetCreated: (attrs: { credentialSetId: string; userId: string; name: string }) => {
+ trackPlatformEvent('platform.credential_set.created', {
+ 'credential_set.id': attrs.credentialSetId,
+ 'credential_set.name': attrs.name,
+ 'user.id': attrs.userId,
+ })
+ },
+
+ /**
+ * Track webhook created
+ */
+ webhookCreated: (attrs: {
+ webhookId: string
+ workflowId: string
+ provider: string
+ workspaceId?: string
+ }) => {
+ trackPlatformEvent('platform.webhook.created', {
+ 'webhook.id': attrs.webhookId,
+ 'workflow.id': attrs.workflowId,
+ 'webhook.provider': attrs.provider,
+ ...(attrs.workspaceId && { 'workspace.id': attrs.workspaceId }),
+ })
+ },
+
+ /**
+ * Track webhook deleted
+ */
+ webhookDeleted: (attrs: { webhookId: string; workflowId: string }) => {
+ trackPlatformEvent('platform.webhook.deleted', {
+ 'webhook.id': attrs.webhookId,
+ 'workflow.id': attrs.workflowId,
+ })
+ },
+
+ /**
+ * Track webhook triggered
+ */
+ webhookTriggered: (attrs: {
+ webhookId: string
+ workflowId: string
+ provider: string
+ success: boolean
+ }) => {
+ trackPlatformEvent('platform.webhook.triggered', {
+ 'webhook.id': attrs.webhookId,
+ 'workflow.id': attrs.workflowId,
+ 'webhook.provider': attrs.provider,
+ 'webhook.trigger_success': attrs.success,
+ })
+ },
+
+ /**
+ * Track MCP server added
+ */
+ mcpServerAdded: (attrs: {
+ serverId: string
+ serverName: string
+ transport: string
+ workspaceId: string
+ }) => {
+ trackPlatformEvent('platform.mcp.server_added', {
+ 'mcp.server_id': attrs.serverId,
+ 'mcp.server_name': attrs.serverName,
+ 'mcp.transport': attrs.transport,
+ 'workspace.id': attrs.workspaceId,
+ })
+ },
+
+ /**
+ * Track MCP tool executed
+ */
+ mcpToolExecuted: (attrs: {
+ serverId: string
+ toolName: string
+ status: 'success' | 'error'
+ workspaceId: string
+ }) => {
+ trackPlatformEvent('platform.mcp.tool_executed', {
+ 'mcp.server_id': attrs.serverId,
+ 'mcp.tool_name': attrs.toolName,
+ 'mcp.execution_status': attrs.status,
+ 'workspace.id': attrs.workspaceId,
+ })
+ },
+
+ /**
+ * Track template used
+ */
+ templateUsed: (attrs: {
+ templateId: string
+ templateName: string
+ newWorkflowId: string
+ blocksCount: number
+ workspaceId: string
+ }) => {
+ trackPlatformEvent('platform.template.used', {
+ 'template.id': attrs.templateId,
+ 'template.name': attrs.templateName,
+ 'workflow.created_id': attrs.newWorkflowId,
+ 'workflow.blocks_count': attrs.blocksCount,
+ 'workspace.id': attrs.workspaceId,
+ })
+ },
+
+ /**
+ * Track subscription created
+ */
+ subscriptionCreated: (attrs: {
+ userId: string
+ plan: string
+ interval: 'monthly' | 'yearly'
+ }) => {
+ trackPlatformEvent('platform.billing.subscription_created', {
+ 'user.id': attrs.userId,
+ 'billing.plan': attrs.plan,
+ 'billing.interval': attrs.interval,
+ })
+ },
+
+ /**
+ * Track subscription changed
+ */
+ subscriptionChanged: (attrs: {
+ userId: string
+ previousPlan: string
+ newPlan: string
+ changeType: 'upgrade' | 'downgrade'
+ }) => {
+ trackPlatformEvent('platform.billing.subscription_changed', {
+ 'user.id': attrs.userId,
+ 'billing.previous_plan': attrs.previousPlan,
+ 'billing.new_plan': attrs.newPlan,
+ 'billing.change_type': attrs.changeType,
+ })
+ },
+
+ /**
+ * Track subscription cancelled
+ */
+ subscriptionCancelled: (attrs: { userId: string; plan: string }) => {
+ trackPlatformEvent('platform.billing.subscription_cancelled', {
+ 'user.id': attrs.userId,
+ 'billing.plan': attrs.plan,
+ })
+ },
+
+ /**
+ * Track folder created
+ */
+ folderCreated: (attrs: { folderId: string; name: string; workspaceId: string }) => {
+ trackPlatformEvent('platform.folder.created', {
+ 'folder.id': attrs.folderId,
+ 'folder.name': attrs.name,
+ 'workspace.id': attrs.workspaceId,
+ })
+ },
+
+ /**
+ * Track folder deleted
+ */
+ folderDeleted: (attrs: { folderId: string; workspaceId: string }) => {
+ trackPlatformEvent('platform.folder.deleted', {
+ 'folder.id': attrs.folderId,
+ 'workspace.id': attrs.workspaceId,
+ })
+ },
+
+ /**
+ * Track chat deployed (workflow deployed as chat interface)
+ */
+ chatDeployed: (attrs: {
+ chatId: string
+ workflowId: string
+ authType: 'public' | 'password' | 'email' | 'sso'
+ hasOutputConfigs: boolean
+ }) => {
+ trackPlatformEvent('platform.chat.deployed', {
+ 'chat.id': attrs.chatId,
+ 'workflow.id': attrs.workflowId,
+ 'chat.auth_type': attrs.authType,
+ 'chat.has_output_configs': attrs.hasOutputConfigs,
+ })
+ },
+}
diff --git a/apps/sim/lib/credential-sets/providers.ts b/apps/sim/lib/credential-sets/providers.ts
new file mode 100644
index 0000000000..4de42ffb78
--- /dev/null
+++ b/apps/sim/lib/credential-sets/providers.ts
@@ -0,0 +1,27 @@
+export type PollingProvider = 'google-email' | 'outlook'
+
+export const POLLING_PROVIDERS: Record = {
+ 'google-email': { displayName: 'Gmail' },
+ outlook: { displayName: 'Outlook' },
+}
+
+export function getProviderDisplayName(providerId: string): string {
+ if (providerId === 'google-email') return 'Gmail'
+ if (providerId === 'outlook') return 'Outlook'
+ return providerId
+}
+
+export function isPollingProvider(provider: string): provider is PollingProvider {
+ return provider === 'google-email' || provider === 'outlook'
+}
+
+/**
+ * Maps an OAuth provider ID to its corresponding polling provider ID.
+ * Since credential sets now store the OAuth provider ID directly, this is primarily
+ * used in the credential selector to match OAuth providers to credential sets.
+ */
+export function getPollingProviderFromOAuth(oauthProviderId: string): PollingProvider | null {
+ if (oauthProviderId === 'google-email') return 'google-email'
+ if (oauthProviderId === 'outlook') return 'outlook'
+ return null
+}
diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts
index d618be12bc..fd3ae55bab 100644
--- a/apps/sim/lib/logs/execution/logging-session.ts
+++ b/apps/sim/lib/logs/execution/logging-session.ts
@@ -289,12 +289,12 @@ export class LoggingSession {
this.completed = true
- // Track workflow execution outcome
if (traceSpans && traceSpans.length > 0) {
try {
- const { trackPlatformEvent } = await import('@/lib/core/telemetry')
+ const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
+ '@/lib/core/telemetry'
+ )
- // Determine status from trace spans
const hasErrors = traceSpans.some((span: any) => {
const checkForErrors = (s: any): boolean => {
if (s.status === 'error') return true
@@ -306,14 +306,27 @@ export class LoggingSession {
return checkForErrors(span)
})
- trackPlatformEvent('platform.workflow.executed', {
- 'workflow.id': this.workflowId,
- 'execution.duration_ms': duration,
- 'execution.status': hasErrors ? 'error' : 'success',
- 'execution.trigger': this.triggerType,
- 'execution.blocks_executed': traceSpans.length,
- 'execution.has_errors': hasErrors,
- 'execution.total_cost': costSummary.totalCost || 0,
+ PlatformEvents.workflowExecuted({
+ workflowId: this.workflowId,
+ durationMs: duration,
+ status: hasErrors ? 'error' : 'success',
+ trigger: this.triggerType,
+ blocksExecuted: traceSpans.length,
+ hasErrors,
+ totalCost: costSummary.totalCost || 0,
+ })
+
+ const startTime = new Date(new Date(endTime).getTime() - duration).toISOString()
+ createOTelSpansForWorkflowExecution({
+ workflowId: this.workflowId,
+ workflowName: this.workflowState?.metadata?.name,
+ executionId: this.executionId,
+ traceSpans,
+ trigger: this.triggerType,
+ startTime,
+ endTime,
+ totalDurationMs: duration,
+ status: hasErrors ? 'error' : 'success',
})
} catch (_e) {
// Silently fail
@@ -324,7 +337,6 @@ export class LoggingSession {
logger.debug(`[${this.requestId}] Completed logging for execution ${this.executionId}`)
}
} catch (error) {
- // Always log completion failures with full details - these should not be silent
logger.error(`Failed to complete logging for execution ${this.executionId}:`, {
requestId: this.requestId,
workflowId: this.workflowId,
@@ -332,7 +344,6 @@ export class LoggingSession {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
- // Rethrow so safeComplete can decide what to do
throw error
}
}
@@ -404,17 +415,31 @@ export class LoggingSession {
this.completed = true
- // Track workflow execution error outcome
try {
- const { trackPlatformEvent } = await import('@/lib/core/telemetry')
- trackPlatformEvent('platform.workflow.executed', {
- 'workflow.id': this.workflowId,
- 'execution.duration_ms': Math.max(1, durationMs),
- 'execution.status': 'error',
- 'execution.trigger': this.triggerType,
- 'execution.blocks_executed': spans.length,
- 'execution.has_errors': true,
- 'execution.error_message': message,
+ const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
+ '@/lib/core/telemetry'
+ )
+ PlatformEvents.workflowExecuted({
+ workflowId: this.workflowId,
+ durationMs: Math.max(1, durationMs),
+ status: 'error',
+ trigger: this.triggerType,
+ blocksExecuted: spans.length,
+ hasErrors: true,
+ errorMessage: message,
+ })
+
+ createOTelSpansForWorkflowExecution({
+ workflowId: this.workflowId,
+ workflowName: this.workflowState?.metadata?.name,
+ executionId: this.executionId,
+ traceSpans: spans,
+ trigger: this.triggerType,
+ startTime: startTime.toISOString(),
+ endTime: endTime.toISOString(),
+ totalDurationMs: Math.max(1, durationMs),
+ status: 'error',
+ error: message,
})
} catch (_e) {
// Silently fail
@@ -426,7 +451,6 @@ export class LoggingSession {
)
}
} catch (enhancedError) {
- // Always log completion failures with full details
logger.error(`Failed to complete error logging for execution ${this.executionId}:`, {
requestId: this.requestId,
workflowId: this.workflowId,
@@ -434,7 +458,6 @@ export class LoggingSession {
error: enhancedError instanceof Error ? enhancedError.message : String(enhancedError),
stack: enhancedError instanceof Error ? enhancedError.stack : undefined,
})
- // Rethrow so safeCompleteWithError can decide what to do
throw enhancedError
}
}
@@ -477,15 +500,32 @@ export class LoggingSession {
this.completed = true
try {
- const { trackPlatformEvent } = await import('@/lib/core/telemetry')
- trackPlatformEvent('platform.workflow.executed', {
- 'workflow.id': this.workflowId,
- 'execution.duration_ms': Math.max(1, durationMs),
- 'execution.status': 'cancelled',
- 'execution.trigger': this.triggerType,
- 'execution.blocks_executed': traceSpans?.length || 0,
- 'execution.has_errors': false,
+ const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
+ '@/lib/core/telemetry'
+ )
+ PlatformEvents.workflowExecuted({
+ workflowId: this.workflowId,
+ durationMs: Math.max(1, durationMs),
+ status: 'cancelled',
+ trigger: this.triggerType,
+ blocksExecuted: traceSpans?.length || 0,
+ hasErrors: false,
})
+
+ if (traceSpans && traceSpans.length > 0) {
+ const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
+ createOTelSpansForWorkflowExecution({
+ workflowId: this.workflowId,
+ workflowName: this.workflowState?.metadata?.name,
+ executionId: this.executionId,
+ traceSpans,
+ trigger: this.triggerType,
+ startTime: startTime.toISOString(),
+ endTime: endTime.toISOString(),
+ totalDurationMs: Math.max(1, durationMs),
+ status: 'success', // Cancelled executions are not errors
+ })
+ }
} catch (_e) {
// Silently fail
}
@@ -540,16 +580,33 @@ export class LoggingSession {
})
try {
- const { trackPlatformEvent } = await import('@/lib/core/telemetry')
- trackPlatformEvent('platform.workflow.executed', {
- 'workflow.id': this.workflowId,
- 'execution.duration_ms': Math.max(1, durationMs),
- 'execution.status': 'paused',
- 'execution.trigger': this.triggerType,
- 'execution.blocks_executed': traceSpans?.length || 0,
- 'execution.has_errors': false,
- 'execution.total_cost': costSummary.totalCost || 0,
+ const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
+ '@/lib/core/telemetry'
+ )
+ PlatformEvents.workflowExecuted({
+ workflowId: this.workflowId,
+ durationMs: Math.max(1, durationMs),
+ status: 'paused',
+ trigger: this.triggerType,
+ blocksExecuted: traceSpans?.length || 0,
+ hasErrors: false,
+ totalCost: costSummary.totalCost || 0,
})
+
+ if (traceSpans && traceSpans.length > 0) {
+ const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
+ createOTelSpansForWorkflowExecution({
+ workflowId: this.workflowId,
+ workflowName: this.workflowState?.metadata?.name,
+ executionId: this.executionId,
+ traceSpans,
+ trigger: this.triggerType,
+ startTime: startTime.toISOString(),
+ endTime: endTime.toISOString(),
+ totalDurationMs: Math.max(1, durationMs),
+ status: 'success', // Paused executions are not errors
+ })
+ }
} catch (_e) {}
if (this.requestId) {
diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts
index a5921eb008..9ea8e5d871 100644
--- a/apps/sim/lib/messaging/email/mailer.test.ts
+++ b/apps/sim/lib/messaging/email/mailer.test.ts
@@ -1,3 +1,4 @@
+import { createEnvMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
/**
@@ -44,15 +45,15 @@ vi.mock('@/lib/messaging/email/unsubscribe', () => ({
}))
// Mock env with valid API keys so the clients get initialized
-vi.mock('@/lib/core/config/env', () => ({
- env: {
+vi.mock('@/lib/core/config/env', () =>
+ createEnvMock({
RESEND_API_KEY: 'test-api-key',
AZURE_ACS_CONNECTION_STRING: 'test-azure-connection-string',
AZURE_COMMUNICATION_EMAIL_DOMAIN: 'test.azurecomm.net',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim ',
- },
-}))
+ })
+)
// Mock URL utilities
vi.mock('@/lib/core/utils/urls', () => ({
diff --git a/apps/sim/lib/messaging/email/unsubscribe.test.ts b/apps/sim/lib/messaging/email/unsubscribe.test.ts
index b456e79c05..578976da57 100644
--- a/apps/sim/lib/messaging/email/unsubscribe.test.ts
+++ b/apps/sim/lib/messaging/email/unsubscribe.test.ts
@@ -1,3 +1,4 @@
+import { createEnvMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EmailType } from '@/lib/messaging/email/mailer'
@@ -25,14 +26,7 @@ vi.mock('drizzle-orm', () => ({
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
}))
-vi.mock('@/lib/core/config/env', () => ({
- env: {
- BETTER_AUTH_SECRET: 'test-secret-key',
- },
- isTruthy: (value: string | boolean | number | undefined) =>
- typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
- getEnv: (variable: string) => process.env[variable],
-}))
+vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' }))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
diff --git a/apps/sim/lib/messaging/email/utils.test.ts b/apps/sim/lib/messaging/email/utils.test.ts
index c58010be34..2d398a5492 100644
--- a/apps/sim/lib/messaging/email/utils.test.ts
+++ b/apps/sim/lib/messaging/email/utils.test.ts
@@ -1,3 +1,4 @@
+import { createEnvMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
/**
@@ -8,12 +9,12 @@ import { describe, expect, it, vi } from 'vitest'
*/
// Set up mocks at module level - these will be used for all tests in this file
-vi.mock('@/lib/core/config/env', () => ({
- env: {
+vi.mock('@/lib/core/config/env', () =>
+ createEnvMock({
FROM_EMAIL_ADDRESS: 'Sim ',
EMAIL_DOMAIN: 'example.com',
- },
-}))
+ })
+)
vi.mock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts
index 85a31d5286..5373ccccf2 100644
--- a/apps/sim/lib/oauth/oauth.test.ts
+++ b/apps/sim/lib/oauth/oauth.test.ts
@@ -1,8 +1,8 @@
-import { createMockFetch, loggerMock } from '@sim/testing'
+import { createEnvMock, createMockFetch, loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
-vi.mock('@/lib/core/config/env', () => ({
- env: {
+vi.mock('@/lib/core/config/env', () =>
+ createEnvMock({
GOOGLE_CLIENT_ID: 'google_client_id',
GOOGLE_CLIENT_SECRET: 'google_client_secret',
GITHUB_CLIENT_ID: 'github_client_id',
@@ -49,8 +49,8 @@ vi.mock('@/lib/core/config/env', () => ({
WORDPRESS_CLIENT_SECRET: 'wordpress_client_secret',
SPOTIFY_CLIENT_ID: 'spotify_client_id',
SPOTIFY_CLIENT_SECRET: 'spotify_client_secret',
- },
-}))
+ })
+)
vi.mock('@sim/logger', () => loggerMock)
diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts
index cc62c30afc..bb54e4c3e3 100644
--- a/apps/sim/lib/webhooks/gmail-polling-service.ts
+++ b/apps/sim/lib/webhooks/gmail-polling-service.ts
@@ -1,8 +1,9 @@
import { db } from '@sim/db'
-import { account, webhook, workflow } from '@sim/db/schema'
+import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
+import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -143,15 +144,42 @@ export async function pollGmailWebhooks() {
const metadata = webhookData.providerConfig as any
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
+ const credentialSetId: string | undefined = metadata?.credentialSetId
if (!credentialId && !userId) {
- logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
+ logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`)
await markWebhookFailed(webhookId)
failureCount++
return
}
+ if (credentialSetId) {
+ const [cs] = await db
+ .select({ organizationId: credentialSet.organizationId })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (cs?.organizationId) {
+ const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
+ if (!hasAccess) {
+ logger.error(
+ `[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
+ {
+ webhookId,
+ credentialSetId,
+ organizationId: cs.organizationId,
+ }
+ )
+ await markWebhookFailed(webhookId)
+ failureCount++
+ return
+ }
+ }
+ }
+
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) {
@@ -165,13 +193,12 @@ export async function pollGmailWebhooks() {
const ownerUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
} else if (userId) {
+ // Legacy fallback for webhooks without credentialId
accessToken = await getOAuthToken(userId, 'google-email')
}
if (!accessToken) {
- logger.error(
- `[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)`
- )
+ logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`)
await markWebhookFailed(webhookId)
failureCount++
return
diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts
index 68f93385ac..b594ef47d9 100644
--- a/apps/sim/lib/webhooks/outlook-polling-service.ts
+++ b/apps/sim/lib/webhooks/outlook-polling-service.ts
@@ -1,9 +1,10 @@
import { db } from '@sim/db'
-import { account, webhook, workflow } from '@sim/db/schema'
+import { account, credentialSet, webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { htmlToText } from 'html-to-text'
import { nanoid } from 'nanoid'
+import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -192,6 +193,7 @@ export async function pollOutlookWebhooks() {
const metadata = webhookData.providerConfig as any
const credentialId: string | undefined = metadata?.credentialId
const userId: string | undefined = metadata?.userId
+ const credentialSetId: string | undefined = metadata?.credentialSetId
if (!credentialId && !userId) {
logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`)
@@ -200,6 +202,31 @@ export async function pollOutlookWebhooks() {
return
}
+ if (credentialSetId) {
+ const [cs] = await db
+ .select({ organizationId: credentialSet.organizationId })
+ .from(credentialSet)
+ .where(eq(credentialSet.id, credentialSetId))
+ .limit(1)
+
+ if (cs?.organizationId) {
+ const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId)
+ if (!hasAccess) {
+ logger.error(
+ `[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`,
+ {
+ webhookId,
+ credentialSetId,
+ organizationId: cs.organizationId,
+ }
+ )
+ await markWebhookFailed(webhookId)
+ failureCount++
+ return
+ }
+ }
+ }
+
let accessToken: string | null = null
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
@@ -359,7 +386,19 @@ async function fetchNewOutlookEmails(
const data = await response.json()
const emails = data.value || []
- const filteredEmails = filterEmailsByFolder(emails, config)
+ let resolvedFolderIds: Map | undefined
+ if (config.folderIds && config.folderIds.length > 0) {
+ const hasWellKnownFolders = config.folderIds.some(isWellKnownFolderName)
+ if (hasWellKnownFolders) {
+ resolvedFolderIds = await resolveWellKnownFolderIds(
+ accessToken,
+ config.folderIds,
+ requestId
+ )
+ }
+ }
+
+ const filteredEmails = filterEmailsByFolder(emails, config, resolvedFolderIds)
logger.info(
`[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering`
@@ -373,18 +412,103 @@ async function fetchNewOutlookEmails(
}
}
+const OUTLOOK_WELL_KNOWN_FOLDERS = new Set([
+ 'inbox',
+ 'drafts',
+ 'sentitems',
+ 'deleteditems',
+ 'junkemail',
+ 'archive',
+ 'outbox',
+])
+
+function isWellKnownFolderName(folderId: string): boolean {
+ return OUTLOOK_WELL_KNOWN_FOLDERS.has(folderId.toLowerCase())
+}
+
+async function resolveWellKnownFolderId(
+ accessToken: string,
+ folderName: string,
+ requestId: string
+): Promise {
+ try {
+ const response = await fetch(`https://graph.microsoft.com/v1.0/me/mailFolders/${folderName}`, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ logger.warn(
+ `[${requestId}] Failed to resolve well-known folder '${folderName}': ${response.status}`
+ )
+ return null
+ }
+
+ const folder = await response.json()
+ return folder.id || null
+ } catch (error) {
+ logger.error(`[${requestId}] Error resolving well-known folder '${folderName}':`, error)
+ return null
+ }
+}
+
+async function resolveWellKnownFolderIds(
+ accessToken: string,
+ folderIds: string[],
+ requestId: string
+): Promise