diff --git a/apps/docs/content/docs/en/blocks/credential.mdx b/apps/docs/content/docs/en/blocks/credential.mdx new file mode 100644 index 00000000000..06b8cb5bec1 --- /dev/null +++ b/apps/docs/content/docs/en/blocks/credential.mdx @@ -0,0 +1,150 @@ +--- +title: Credential +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +The Credential block has two operations: **Select Credential** picks a single OAuth credential and outputs its ID reference for downstream blocks; **List Credentials** returns all OAuth credentials in the workspace (optionally filtered by provider) as an array for iteration. + +
+ Credential Block +
+ + + The Credential block outputs credential **ID references**, not secrets. Downstream blocks receive the ID and resolve the actual OAuth token securely during their own execution. + + +## Configuration Options + +### Operation + +| Value | Description | +|---|---| +| **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks | +| **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop | + +### Credential (Select operation) + +Select an OAuth credential from your workspace. The dropdown shows all connected OAuth accounts (Google, GitHub, Slack, etc.). + +In advanced mode, paste a credential ID directly. You can copy a credential ID from your workspace's Credentials settings page. + +### Provider (List operation) + +Filter the returned OAuth credentials by provider. Select one or more providers from the dropdown — only providers you have credentials for will appear. Leave empty to return all OAuth credentials. + +| Example | Returns | +|---|---| +| Gmail | Gmail credentials only | +| Slack | Slack credentials only | +| Gmail + Slack | Gmail and Slack credentials | + +## Outputs + + + + | Output | Type | Description | + |---|---|---| + | `credentialId` | `string` | The credential ID — pipe this into other blocks' credential fields | + | `displayName` | `string` | Human-readable name (e.g. "waleed@company.com") | + | `providerId` | `string` | OAuth provider ID (e.g. `google-email`, `slack`) | + + + | Output | Type | Description | + |---|---|---| + | `credentials` | `json` | Array of OAuth credential objects (see shape below) | + | `count` | `number` | Number of credentials returned | + + Each object in the `credentials` array: + + | Field | Type | Description | + |---|---|---| + | `credentialId` | `string` | The credential ID | + | `displayName` | `string` | Human-readable name | + | `providerId` | `string` | OAuth provider ID | + + + +## Example Use Cases + +**Shared credential across multiple blocks** — Define once, use everywhere +``` +Credential (Select, Google) → Gmail (Send) & Google Drive (Upload) & Google Calendar (Create) +``` + +**Multi-account workflows** — Route to different credentials based on logic +``` +Agent (Determine account) → Condition → Credential A or Credential B → Slack (Post) +``` + +**Iterate over all Gmail accounts** +``` +Credential (List, Provider: Gmail) → ForEach Loop → Gmail (Send) using +``` + +
+ Credential List wired into a ForEach Loop +
+ +## How to wire a Credential block + +### Select Credential + +1. Drop a **Credential** block and select your OAuth credential from the picker +2. In the downstream block, switch to **advanced mode** on its credential field +3. Enter `` as the value + + + + In the Gmail block's credential field (advanced mode): + ``` + + ``` + + + In the Slack block's credential field (advanced mode): + ``` + + ``` + + + +### List Credentials + +1. Drop a **Credential** block, set Operation to **List Credentials** +2. Optionally select one or more **Providers** to narrow results (only your connected providers appear) +3. Wire `` into a **ForEach Loop** as the items source +4. Inside the loop, reference `` in downstream blocks' credential fields + +## Best Practices + +- **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `` instead of selecting the account five times +- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs +- **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable +- **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference +- **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop +- **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown + + in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." }, + { question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." }, +]} /> diff --git a/apps/docs/content/docs/en/blocks/meta.json b/apps/docs/content/docs/en/blocks/meta.json index 6be14a79666..225d3ff8fb5 100644 --- a/apps/docs/content/docs/en/blocks/meta.json +++ b/apps/docs/content/docs/en/blocks/meta.json @@ -4,6 +4,7 @@ "agent", "api", "condition", + "credential", "evaluator", "function", "guardrails", diff --git a/apps/docs/public/static/blocks/credential-loop.png b/apps/docs/public/static/blocks/credential-loop.png new file mode 100644 index 00000000000..1da2f8f5088 Binary files /dev/null and b/apps/docs/public/static/blocks/credential-loop.png differ diff --git a/apps/docs/public/static/blocks/credential.png b/apps/docs/public/static/blocks/credential.png new file mode 100644 index 00000000000..32cc0731c91 Binary files /dev/null and b/apps/docs/public/static/blocks/credential.png differ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index ade784ccab2..4641e9ba6ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -59,14 +59,10 @@ interface ComboBoxProps { /** Configuration for the sub-block */ config: SubBlockConfig /** Async function to fetch options dynamically */ - fetchOptions?: ( - blockId: string, - subBlockId: string - ) => Promise> + fetchOptions?: (blockId: string) => Promise> /** Async function to fetch a single option's label by ID (for hydration) */ fetchOptionById?: ( blockId: string, - subBlockId: string, optionId: string ) => Promise<{ label: string; id: string } | null> /** Field dependencies that trigger option refetch when changed */ @@ -135,7 +131,7 @@ export const ComboBox = memo(function ComboBox({ setIsLoadingOptions(true) setFetchError(null) try { - const options = await fetchOptions(blockId, subBlockId) + const options = await fetchOptions(blockId) setFetchedOptions(options) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' @@ -144,7 +140,7 @@ export const ComboBox = memo(function ComboBox({ } finally { setIsLoadingOptions(false) } - }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) + }, [fetchOptions, blockId, isPreview, disabled]) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -363,7 +359,7 @@ export const ComboBox = memo(function ComboBox({ let isActive = true // Fetch the hydrated option - fetchOptionById(blockId, subBlockId, valueToHydrate) + fetchOptionById(blockId, valueToHydrate) .then((option) => { if (isActive) setHydratedOption(option) }) @@ -378,7 +374,6 @@ export const ComboBox = memo(function ComboBox({ fetchOptionById, value, blockId, - subBlockId, isPreview, disabled, fetchedOptions, 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 296bb357929..ecfb61ba3d8 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 @@ -1,7 +1,7 @@ 'use client' import { createElement, useCallback, useMemo, useState } from 'react' -import { ExternalLink, Users } from 'lucide-react' +import { ExternalLink, KeyRound, Users } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Combobox } from '@/components/emcn/components' import { getSubscriptionAccessState } from '@/lib/billing/client' @@ -22,7 +22,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import type { SubBlockConfig } from '@/blocks/types' import { CREDENTIAL_SET } from '@/executor/constants' import { useCredentialSets } from '@/hooks/queries/credential-sets' -import { useWorkspaceCredential } from '@/hooks/queries/credentials' +import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' @@ -60,6 +60,7 @@ export function CredentialSelector({ const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' + const isAllCredentials = !serviceId const supportsCredentialSets = subBlock.supportsCredentialSets || false const { data: organizationsData } = useOrganizations() @@ -101,14 +102,22 @@ export function CredentialSelector({ const { data: rawCredentials = [], - isFetching: credentialsLoading, + isFetching: oauthCredentialsLoading, refetch: refetchCredentials, } = useOAuthCredentials(effectiveProviderId, { - enabled: Boolean(effectiveProviderId), + enabled: !isAllCredentials && Boolean(effectiveProviderId), workspaceId, workflowId: activeWorkflowId || undefined, }) + const { + data: allWorkspaceCredentials = [], + isFetching: allCredentialsLoading, + refetch: refetchAllCredentials, + } = useWorkspaceCredentials({ workspaceId, enabled: isAllCredentials }) + + const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading + const credentials = useMemo( () => isTriggerMode @@ -122,9 +131,17 @@ export function CredentialSelector({ [credentials, selectedId] ) + const selectedAllCredential = useMemo( + () => + isAllCredentials ? (allWorkspaceCredentials.find((c) => c.id === selectedId) ?? null) : null, + [isAllCredentials, allWorkspaceCredentials, selectedId] + ) + const isServiceAccount = useMemo( - () => selectedCredential?.type === 'service_account', - [selectedCredential] + () => + selectedCredential?.type === 'service_account' || + selectedAllCredential?.type === 'service_account', + [selectedCredential, selectedAllCredential] ) const selectedCredentialSet = useMemo( @@ -134,37 +151,45 @@ export function CredentialSelector({ const { data: inaccessibleCredential } = useWorkspaceCredential( selectedId || undefined, - Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId) + Boolean(selectedId) && + !selectedCredential && + !selectedAllCredential && + !credentialsLoading && + Boolean(workspaceId) ) const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null const resolvedLabel = useMemo(() => { if (selectedCredentialSet) return selectedCredentialSet.name + if (selectedAllCredential) return selectedAllCredential.displayName if (selectedCredential) return selectedCredential.name if (inaccessibleCredentialName) return inaccessibleCredentialName return '' - }, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName]) + }, [selectedCredentialSet, selectedAllCredential, selectedCredential, inaccessibleCredentialName]) const displayValue = isEditing ? editingValue : resolvedLabel - useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId) + const refetch = useCallback( + () => (isAllCredentials ? refetchAllCredentials() : refetchCredentials()), + [isAllCredentials, refetchAllCredentials, refetchCredentials] + ) + + useCredentialRefreshTriggers(refetch, effectiveProviderId, workspaceId) const handleOpenChange = useCallback( (isOpen: boolean) => { - if (isOpen) { - void refetchCredentials() - } + if (isOpen) void refetch() }, - [refetchCredentials] + [refetch] ) - const hasSelection = Boolean(selectedCredential) - const missingRequiredScopes = hasSelection + const hasOAuthSelection = Boolean(selectedCredential) + const missingRequiredScopes = hasOAuthSelection ? getMissingRequiredScopes(selectedCredential!, requiredScopes || []) : [] const needsUpdate = - hasSelection && + hasOAuthSelection && !isServiceAccount && missingRequiredScopes.length > 0 && !effectiveDisabled && @@ -218,6 +243,12 @@ export function CredentialSelector({ }, []) const { comboboxOptions, comboboxGroups } = useMemo(() => { + if (isAllCredentials) { + const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth') + const options = oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) + return { comboboxOptions: options, comboboxGroups: undefined } + } + const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId) // Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility const matchesProvider = (csProviderId: string | null) => { @@ -281,6 +312,8 @@ export function CredentialSelector({ return { comboboxOptions: options, comboboxGroups: undefined } }, [ + isAllCredentials, + allWorkspaceCredentials, credentials, provider, effectiveProviderId, @@ -306,6 +339,17 @@ export function CredentialSelector({ ) } + if (isAllCredentials && selectedAllCredential) { + return ( +
+
+ +
+ {displayValue} +
+ ) + } + return (
@@ -320,7 +364,8 @@ export function CredentialSelector({ selectedCredentialProvider, isCredentialSetSelected, selectedCredentialSet, - isServiceAccount, + isAllCredentials, + selectedAllCredential, ]) const handleComboboxChange = useCallback( @@ -339,7 +384,9 @@ export function CredentialSelector({ } } - const matchedCred = credentials.find((c) => c.id === value) + const matchedCred = ( + isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials + ).find((c) => c.id === value) if (matchedCred) { handleSelect(value) return @@ -348,7 +395,15 @@ export function CredentialSelector({ setIsEditing(true) setEditingValue(value) }, - [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect] + [ + isAllCredentials, + allWorkspaceCredentials, + credentials, + credentialSets, + handleAddCredential, + handleSelect, + handleCredentialSetSelect, + ] ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 207cd2e6308..4abb1110183 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -52,14 +52,10 @@ interface DropdownProps { /** Enable multi-select mode */ multiSelect?: boolean /** Async function to fetch options dynamically */ - fetchOptions?: ( - blockId: string, - subBlockId: string - ) => Promise> + fetchOptions?: (blockId: string) => Promise> /** Async function to fetch a single option's label by ID (for hydration) */ fetchOptionById?: ( blockId: string, - subBlockId: string, optionId: string ) => Promise<{ label: string; id: string } | null> /** Field dependencies that trigger option refetch when changed */ @@ -160,7 +156,7 @@ export const Dropdown = memo(function Dropdown({ setIsLoadingOptions(true) setFetchError(null) try { - const options = await fetchOptions(blockId, subBlockId) + const options = await fetchOptions(blockId) setFetchedOptions(options) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' @@ -169,7 +165,7 @@ export const Dropdown = memo(function Dropdown({ } finally { setIsLoadingOptions(false) } - }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) + }, [fetchOptions, blockId, isPreview, disabled]) /** * Handles combobox open state changes to trigger option fetching @@ -430,7 +426,7 @@ export const Dropdown = memo(function Dropdown({ let isActive = true // Fetch the hydrated option - fetchOptionById(blockId, subBlockId, valueToHydrate) + fetchOptionById(blockId, valueToHydrate) .then((option) => { if (isActive) setHydratedOption(option) }) @@ -446,7 +442,6 @@ export const Dropdown = memo(function Dropdown({ singleValue, multiSelect, blockId, - subBlockId, isPreview, disabled, fetchedOptions, diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts new file mode 100644 index 00000000000..ec1408ac8ee --- /dev/null +++ b/apps/sim/blocks/blocks/credential.ts @@ -0,0 +1,151 @@ +import { CredentialIcon } from '@/components/icons' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import type { BlockConfig } from '@/blocks/types' +import { fetchWorkspaceCredentialList, workspaceCredentialKeys } from '@/hooks/queries/credentials' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface CredentialBlockOutput { + success: boolean + output: { + credentialId: string + displayName: string + providerId: string + credentials: Array<{ + credentialId: string + displayName: string + providerId: string + }> + count: number + } +} + +export const CredentialBlock: BlockConfig = { + type: 'credential', + name: 'Credential', + description: 'Select or list OAuth credentials', + longDescription: + 'Select an OAuth credential once and pipe its ID into any downstream block that requires authentication, or list all OAuth credentials in the workspace for iteration. No secrets are ever exposed — only credential IDs and metadata.', + bestPractices: ` + - Use "Select Credential" to define an OAuth credential once and reference in multiple downstream blocks instead of repeating credential IDs. + - Use "List Credentials" with a ForEach loop to iterate over all OAuth accounts (e.g. all Gmail accounts). + - Use the Provider filter to narrow results to specific services (e.g. Gmail, Slack). + - The outputs are credential ID references, not secret values — they are safe to log and inspect. + - To switch credentials across environments, replace the single Credential block rather than updating every downstream block. + `, + docsLink: 'https://docs.sim.ai/blocks/credential', + bgColor: '#6366F1', + icon: CredentialIcon, + category: 'blocks', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Select Credential', id: 'select' }, + { label: 'List Credentials', id: 'list' }, + ], + value: () => 'select', + }, + { + id: 'providerFilter', + title: 'Provider', + type: 'dropdown', + multiSelect: true, + options: [], + condition: { field: 'operation', value: 'list' }, + fetchOptions: async () => { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + const credentials = await getQueryClient().fetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: () => fetchWorkspaceCredentialList(workspaceId), + staleTime: 60 * 1000, + }) + + const seen = new Set() + const options: Array<{ label: string; id: string }> = [] + + for (const cred of credentials) { + if (cred.type === 'oauth' && cred.providerId && !seen.has(cred.providerId)) { + seen.add(cred.providerId) + const serviceConfig = getServiceConfigByProviderId(cred.providerId) + options.push({ label: serviceConfig?.name ?? cred.providerId, id: cred.providerId }) + } + } + + return options.sort((a, b) => a.label.localeCompare(b.label)) + }, + fetchOptionById: async (_blockId: string, optionId: string) => { + const serviceConfig = getServiceConfigByProviderId(optionId) + const label = serviceConfig?.name ?? optionId + return { label, id: optionId } + }, + }, + { + id: 'credential', + title: 'Credential', + type: 'oauth-input', + required: { field: 'operation', value: 'select' }, + mode: 'basic', + placeholder: 'Select a credential', + canonicalParamId: 'credentialId', + condition: { field: 'operation', value: 'select' }, + }, + { + id: 'manualCredential', + title: 'Credential ID', + type: 'short-input', + required: { field: 'operation', value: 'select' }, + mode: 'advanced', + placeholder: 'Enter credential ID', + canonicalParamId: 'credentialId', + condition: { field: 'operation', value: 'select' }, + }, + ], + tools: { + access: [], + }, + inputs: { + operation: { type: 'string', description: "'select' or 'list'" }, + credentialId: { + type: 'string', + description: 'The OAuth credential ID to resolve (select operation)', + }, + providerFilter: { + type: 'json', + description: + 'Array of OAuth provider IDs to filter by (e.g. ["google-email", "slack"]). Leave empty to return all OAuth credentials.', + }, + }, + outputs: { + credentialId: { + type: 'string', + description: "Credential ID — pipe into other blocks' credential fields", + condition: { field: 'operation', value: 'select' }, + }, + displayName: { + type: 'string', + description: 'Human-readable name of the credential', + condition: { field: 'operation', value: 'select' }, + }, + providerId: { + type: 'string', + description: 'OAuth provider ID (e.g. google-email, slack)', + condition: { field: 'operation', value: 'select' }, + }, + credentials: { + type: 'json', + description: + 'Array of OAuth credential objects, each with credentialId, displayName, and providerId', + condition: { field: 'operation', value: 'list' }, + }, + count: { + type: 'number', + description: 'Number of credentials returned', + condition: { field: 'operation', value: 'list' }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index da08d87ec38..091408b7e99 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -26,6 +26,7 @@ import { ClerkBlock } from '@/blocks/blocks/clerk' import { CloudflareBlock } from '@/blocks/blocks/cloudflare' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence' +import { CredentialBlock } from '@/blocks/blocks/credential' import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor' import { DatabricksBlock } from '@/blocks/blocks/databricks' import { DatadogBlock } from '@/blocks/blocks/datadog' @@ -243,6 +244,7 @@ export const registry: Record = { clay: ClayBlock, clerk: ClerkBlock, condition: ConditionBlock, + credential: CredentialBlock, confluence: ConfluenceBlock, confluence_v2: ConfluenceV2Block, cursor: CursorBlock, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 052bc47f363..d6969cc9c23 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -421,15 +421,11 @@ export interface SubBlockConfig { triggerId?: string // Dropdown/Combobox: Function to fetch options dynamically // Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support) - fetchOptions?: ( - blockId: string, - subBlockId: string - ) => Promise> + fetchOptions?: (blockId: string) => Promise> // Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration) // Called when component mounts with a stored value to display the correct label before options load fetchOptionById?: ( blockId: string, - subBlockId: string, optionId: string ) => Promise<{ label: string; id: string } | null> } diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 8adfadbf9a4..49dda618ccb 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -124,6 +124,29 @@ export function ConditionalIcon(props: SVGProps) { ) } +export function CredentialIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function NoteIcon(props: SVGProps) { return ( + ): Promise { + if (!ctx.workspaceId) { + throw new Error('workspaceId is required for credential resolution') + } + + const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select' + + if (operation === 'list') { + return this.listCredentials(ctx.workspaceId, inputs) + } + + return this.selectCredential(ctx.workspaceId, inputs) + } + + private async selectCredential( + workspaceId: string, + inputs: Record + ): Promise { + const credentialId = typeof inputs.credentialId === 'string' ? inputs.credentialId.trim() : '' + + if (!credentialId) { + throw new Error('No credential selected') + } + + const record = await db.query.credential.findFirst({ + where: and( + eq(credential.id, credentialId), + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'oauth') + ), + columns: { + id: true, + displayName: true, + providerId: true, + }, + }) + + if (!record) { + throw new Error(`Credential not found: ${credentialId}`) + } + + logger.info('Credential block resolved', { credentialId: record.id }) + + return { + credentialId: record.id, + displayName: record.displayName, + providerId: record.providerId ?? '', + } + } + + private async listCredentials( + workspaceId: string, + inputs: Record + ): Promise { + const providerFilter = Array.isArray(inputs.providerFilter) + ? (inputs.providerFilter as string[]).filter(Boolean) + : [] + + const conditions = [eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')] + + if (providerFilter.length > 0) { + conditions.push(inArray(credential.providerId, providerFilter)) + } + + const records = await db.query.credential.findMany({ + where: and(...conditions), + columns: { + id: true, + displayName: true, + providerId: true, + }, + orderBy: [asc(credential.displayName)], + }) + + const credentials = records.map((r) => ({ + credentialId: r.id, + displayName: r.displayName, + providerId: r.providerId ?? '', + })) + + logger.info('Credential block listed credentials', { + count: credentials.length, + providerFilter: providerFilter.length > 0 ? providerFilter : undefined, + }) + + return { + credentials, + count: credentials.length, + } + } +} diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index 751ef4720c4..f2b3c292025 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -8,6 +8,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' +import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' @@ -42,6 +43,7 @@ export function createBlockHandlers(): BlockHandler[] { new WorkflowBlockHandler(), new WaitBlockHandler(), new EvaluatorBlockHandler(), + new CredentialBlockHandler(), new GenericBlockHandler(), ] } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 9b9b35a5f7a..5859970ea08 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -73,7 +73,7 @@ export const workspaceCredentialKeys = { * Fetch workspace credential list from API. * Used by the prefetch function for hover-based cache warming. */ -async function fetchWorkspaceCredentialList( +export async function fetchWorkspaceCredentialList( workspaceId: string, signal?: AbortSignal ): Promise { diff --git a/apps/sim/hooks/queries/oauth/oauth-credentials.ts b/apps/sim/hooks/queries/oauth/oauth-credentials.ts index 0b106bfda07..4302d2f4432 100644 --- a/apps/sim/hooks/queries/oauth/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth/oauth-credentials.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import type { Credential } from '@/lib/oauth' import { CREDENTIAL_SET } from '@/executor/constants' import { useCredentialSetDetail } from '@/hooks/queries/credential-sets' +import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { fetchJson } from '@/hooks/selectors/helpers' interface CredentialListResponse { @@ -163,17 +164,26 @@ export function useCredentialName( shouldFetchDetail ) + // Fallback for credential blocks that have no serviceId/providerId — look up by ID directly + const { data: workspaceCredential, isFetching: workspaceCredentialLoading } = + useWorkspaceCredential(!providerId && !isCredentialSet ? credentialId : undefined) + const detailCredential = foreignCredentials[0] const hasForeignMeta = foreignCredentials.length > 0 const displayName = - credentialSetData?.name ?? selectedCredential?.name ?? detailCredential?.name ?? null + credentialSetData?.name ?? + selectedCredential?.name ?? + detailCredential?.name ?? + workspaceCredential?.displayName ?? + null return { displayName, isLoading: credentialsLoading || foreignLoading || + workspaceCredentialLoading || (isCredentialSet && credentialSetLoading && !credentialSetData), hasForeignMeta, } diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index ada550c5f34..772e254b93d 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -53,7 +53,7 @@ export const gmailPollingTrigger: TriggerConfig = { description: 'Choose which Gmail labels to monitor. Leave empty to monitor all emails.', required: false, options: [], // Will be populated dynamically from user's Gmail labels - fetchOptions: async (blockId: string, subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null diff --git a/apps/sim/triggers/imap/poller.ts b/apps/sim/triggers/imap/poller.ts index e4279236f0d..ca4cfd18ab5 100644 --- a/apps/sim/triggers/imap/poller.ts +++ b/apps/sim/triggers/imap/poller.ts @@ -75,7 +75,7 @@ export const imapPollingTrigger: TriggerConfig = { 'Choose which mailbox/folder(s) to monitor for new emails. Leave empty to monitor INBOX.', required: false, options: [], - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const store = useSubBlockStore.getState() const host = store.getValue(blockId, 'host') as string | null const port = store.getValue(blockId, 'port') as string | null diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index bd22d2d13bc..a4af3961576 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -47,7 +47,7 @@ export const outlookPollingTrigger: TriggerConfig = { description: 'Choose which Outlook folders to monitor. Leave empty to monitor all emails.', required: false, options: [], // Will be populated dynamically - fetchOptions: async (blockId: string, subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null diff --git a/apps/sim/triggers/webflow/collection_item_changed.ts b/apps/sim/triggers/webflow/collection_item_changed.ts index e0c43fd36ca..976b58c8f53 100644 --- a/apps/sim/triggers/webflow/collection_item_changed.ts +++ b/apps/sim/triggers/webflow/collection_item_changed.ts @@ -42,7 +42,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_collection_item_changed', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -71,7 +71,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -108,7 +108,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_collection_item_changed', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -140,7 +140,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null diff --git a/apps/sim/triggers/webflow/collection_item_created.ts b/apps/sim/triggers/webflow/collection_item_created.ts index 3c2c6cee4be..4494b08108a 100644 --- a/apps/sim/triggers/webflow/collection_item_created.ts +++ b/apps/sim/triggers/webflow/collection_item_created.ts @@ -56,7 +56,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_collection_item_created', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -85,7 +85,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -122,7 +122,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_collection_item_created', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -154,7 +154,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null diff --git a/apps/sim/triggers/webflow/collection_item_deleted.ts b/apps/sim/triggers/webflow/collection_item_deleted.ts index 80011af97dd..e4e3d1f033f 100644 --- a/apps/sim/triggers/webflow/collection_item_deleted.ts +++ b/apps/sim/triggers/webflow/collection_item_deleted.ts @@ -42,7 +42,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_collection_item_deleted', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -71,7 +71,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -108,7 +108,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_collection_item_deleted', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -140,7 +140,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null diff --git a/apps/sim/triggers/webflow/form_submission.ts b/apps/sim/triggers/webflow/form_submission.ts index 2d698daa05b..3ea3494696c 100644 --- a/apps/sim/triggers/webflow/form_submission.ts +++ b/apps/sim/triggers/webflow/form_submission.ts @@ -42,7 +42,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { field: 'selectedTriggerId', value: 'webflow_form_submission', }, - fetchOptions: async (blockId: string, _subBlockId: string) => { + fetchOptions: async (blockId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null @@ -71,7 +71,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { throw error } }, - fetchOptionById: async (blockId: string, _subBlockId: string, optionId: string) => { + fetchOptionById: async (blockId: string, optionId: string) => { const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as | string | null