From 06c88441f80316924ac02d8e8e5cc0480591f4cc Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 05:51:27 -0800 Subject: [PATCH 1/9] fix(tool-input): restore workflow input mapper visibility (#3438) --- .../components/sub-block/components/tool-input/tool-input.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index cc3225b9c20..324857cf9bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1969,9 +1969,8 @@ export const ToolInput = memo(function ToolInput({ } if (useSubBlocks && displaySubBlocks.length > 0) { - const allBlockSubBlocks = toolBlock?.subBlocks || [] const coveredParamIds = new Set( - allBlockSubBlocks.flatMap((sb) => { + displaySubBlocks.flatMap((sb) => { const ids = [sb.id] if (sb.canonicalParamId) ids.push(sb.canonicalParamId) const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] From ae887185a1270878219e8609256fa639541755a1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 11:35:46 -0800 Subject: [PATCH 2/9] fix(memory): upgrade bun from 1.3.9 to 1.3.10 (#3441) --- .devcontainer/Dockerfile | 2 +- .github/workflows/docs-embeddings.yml | 2 +- .github/workflows/i18n.yml | 4 ++-- .github/workflows/migrations.yml | 2 +- .github/workflows/publish-cli.yml | 2 +- .github/workflows/publish-ts-sdk.yml | 2 +- .github/workflows/test-build.yml | 2 +- docker/app.Dockerfile | 2 +- docker/db.Dockerfile | 2 +- docker/realtime.Dockerfile | 2 +- package.json | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 27318a34367..63ec07c288d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.9-alpine +FROM oven/bun:1.3.10-alpine # Install necessary packages for development RUN apk add --no-cache \ diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index fc03808068e..08576b5c396 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index c32730076e7..90dc2d370f7 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Cache Bun dependencies uses: actions/cache@v4 @@ -122,7 +122,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Cache Bun dependencies uses: actions/cache@v4 diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index c9680845fcd..6f246e829e7 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Cache Bun dependencies uses: actions/cache@v4 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 174b2b52397..3448cbb5504 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index 73cabe8ee4c..4bd88074b5c 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 7c802be4e4c..c3ac097abff 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version: 1.3.10 - name: Setup Node uses: actions/setup-node@v4 diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 5fbf6eb30a1..5f7c651cbf1 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Debian-based Bun with Node.js 22 # ======================================== -FROM oven/bun:1.3.9-slim AS base +FROM oven/bun:1.3.10-slim AS base # Install Node.js 22 and common dependencies once in base stage RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index eded7b7904c..874dadcb401 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.3.9-alpine AS base +FROM oven/bun:1.3.10-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies diff --git a/docker/realtime.Dockerfile b/docker/realtime.Dockerfile index 492a5d60ac0..9ee8df0d695 100644 --- a/docker/realtime.Dockerfile +++ b/docker/realtime.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.3.9-alpine AS base +FROM oven/bun:1.3.10-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies diff --git a/package.json b/package.json index 932415f1f30..cf07b995487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simstudio", - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.10", "version": "0.0.0", "private": true, "license": "Apache-2.0", From 244cf4ff7e84b6f4eb30b29828c58af798b86c65 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 12:34:28 -0800 Subject: [PATCH 3/9] feat(selectors): add dropdown selectors for 14 integrations (#3433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(selectors): add dropdown selectors for 14 integrations * fix(selectors): secure OAuth tokens in JSM and Confluence selector routes Convert JSM selector-servicedesks, selector-requesttypes, and Confluence selector-spaces routes from GET (with access token in URL query params) to POST with authorizeCredentialUse + refreshAccessTokenIfNeeded pattern. Also adds missing ensureCredential guard to microsoft.planner.plans registry entry. * fix(selectors): use sanitized serviceDeskId and encode SharePoint siteId Use serviceDeskIdValidation.sanitized instead of raw serviceDeskId in JSM request types URL. Add encodeURIComponent to SharePoint siteId to prevent URL path injection. * lint * fix(selectors): revert encodeURIComponent on SharePoint siteId SharePoint site IDs use the format "hostname,guid,guid" with commas that must remain unencoded for the Microsoft Graph API. The encodeURIComponent call would convert commas to %2C and break the API call. * fix(selectors): use sanitized cloudId in Confluence and JSM route URLs Use cloudIdValidation.sanitized instead of raw cloudId in URL construction for consistency with the validation pattern, even though the current validator returns the input unchanged. * fix(selectors): add missing context fields to resolution, ensureCredential to sharepoint.lists, and siteId validation - Add baseId, datasetId, serviceDeskId to SelectorResolutionArgs, ExtendedSelectorContext, extractExtendedContext, useSelectorDisplayName, and resolveSelectorForSubBlock so cascading selectors resolve correctly through the resolution path. - Add ensureCredential guard to sharepoint.lists registry entry. - Add regex validation for SharePoint siteId format (hostname,GUID,GUID). * fix(selectors): rename advanced subBlock IDs to avoid canonicalParamId clashes Rename all advanced-mode subBlock IDs that matched their canonicalParamId to use a `manual*` prefix, following the established convention (e.g., manualSiteId, manualCredential). This prevents ambiguity between subBlock IDs and canonical parameter names in the serialization layer. 25 renames across 14 blocks: baseId→manualBaseId, tableId→manualTableId, workspace→manualWorkspace, objectType→manualObjectType, etc. * Revert "fix(selectors): rename advanced subBlock IDs to avoid canonicalParamId clashes" This reverts commit 4e30161c68e640f1c2e4ca79d16b69f12d631ab2. * fix(selectors): rename canonicalParamIds to avoid subBlock ID clashes Prefix all clashing canonicalParamId values with `selected_` so they don't match any subBlock ID. Update each block's `inputs` section and `tools.config.params` function to destructure the new canonical names and remap them to the original tool param names. SubBlock IDs and tool definitions remain unchanged for backwards compatibility. Affected: 25 canonical params across 14 blocks (airtable, asana, attio, calcom, confluence, google_bigquery, google_tasks, jsm, microsoft_planner, notion, pipedrive, sharepoint, trello, zoom). * fix(selectors): rename pre-existing driveId and files canonicalParamIds in SharePoint Apply the same selected_ prefix convention to the pre-existing SharePoint driveId and files canonical params that clashed with their subBlock IDs. * style: format long lines in calcom, pipedrive, and sharepoint blocks Co-Authored-By: Claude Opus 4.6 * fix(selectors): resolve cascading context for selected_ canonical params and normalize Asana response Strip `selected_` prefix from canonical param IDs when mapping to SelectorContext fields so cascading selectors (Airtable base→table, BigQuery dataset→table, JSM serviceDesk→requestType) correctly propagate parent values. Normalize Asana workspaces route to return `{ id, name }` instead of `{ gid, name }` for consistency with all other selector routes. Co-Authored-By: Claude Opus 4.6 * fix(selectors): replace hacky prefix stripping with explicit CANONICAL_TO_CONTEXT mapping Replace CONTEXT_FIELD_SET (Record) with CANONICAL_TO_CONTEXT (Record) that explicitly maps canonical param IDs to their SelectorContext field names. This properly handles the selected_ prefix aliases (e.g. selected_baseId → baseId) without string manipulation, and removes the unsafe Record cast. Co-Authored-By: Claude Opus 4.6 * refactor(selectors): remove unnecessary selected_ prefix from canonicalParamIds The selected_ prefix was added to avoid a perceived clash between canonicalParamId and subBlock id values, but this clash does not actually cause any issues — pre-existing blocks on main (Google Sheets, Webflow, SharePoint) already use matching values successfully. Remove the prefix from all 14 blocks, revert use-selector-setup.ts to the simple CONTEXT_FIELD_SET pattern, and simplify tools.config.params functions that were only remapping the prefix back. Co-Authored-By: Claude Opus 4.6 * fix(selectors): add spaceId selector pair to Confluence V2 block The V2 block was missing the spaceSelector basic-mode selector that the V1 (Legacy) block already had. Co-Authored-By: Claude Opus 4.6 * refactor(selectors): revert V1 block changes, add selectors to Notion V1 for V2 inheritance Confluence V1: reverted to main state (V2 has its own subBlocks). Notion V1: added selector pairs per-operation since V2 inherits subBlocks, inputs, and params from V1. Co-Authored-By: Claude Opus 4.6 * fix(selectors): audit fixes for auth patterns, registry gaps, and display name resolution - Convert Microsoft Planner plans/tasks routes from GET+getSession to POST+authorizeCredentialUse - Add fetchById to microsoft.planner (tasks) and sharepoint.sites registry entries - Add ensureCredential to sharepoint.sites and microsoft.planner registry fetchList - Update microsoft.planner.plans registry to use POST method - Add siteId, collectionId, spreadsheetId, fileId to SelectorDisplayNameArgs and caller - Add fileId to SelectorResolutionArgs and resolution context - Fix Zoom topicUpdate visibility in basic mode (remove mode:'advanced') - Change Zoom meetings selector to fetch upcoming_meetings instead of only scheduled Co-Authored-By: Claude Opus 4.6 * style: lint formatting fixes Co-Authored-By: Claude Opus 4.6 * fix(selectors): consolidate Notion canonical param pairs into array conditions Co-Authored-By: Claude Opus 4.6 * fix(selectors): add missing selectorKey to Confluence V1 page selector Co-Authored-By: Claude Opus 4.6 * fix(selectors): use sanitized IDs in URLs, convert SharePoint routes to POST+authorizeCredentialUse - Use planIdValidation.sanitized in MS Planner tasks fetch URL - Convert sharepoint/lists and sharepoint/sites from GET+getSession to POST+authorizeCredentialUse - Update registry entries to match POST pattern Co-Authored-By: Claude Opus 4.6 * fix(selectors): revert Zoom meetings type to scheduled for broader compatibility Co-Authored-By: Claude Opus 4.6 * fix(selectors): add SharePoint site ID validator, fix cascading selector display name fallbacks - Add validateSharePointSiteId to input-validation.ts - Use validation util in SharePoint lists route instead of inline regex - Add || fallback to selector IDs in workflow-block.tsx so cascading display names resolve in basic mode (baseSelector, planSelector, etc.) Co-Authored-By: Claude Opus 4.6 * fix(selectors): hoist requestId before try block in all selector routes Co-Authored-By: Claude Opus 4.6 * fix(selectors): hoist requestId before try block in Trello boards route Co-Authored-By: Claude Opus 4.6 * fix(selectors): guard selector queries against unresolved variable references Skip fetchById and context population when values are design-time placeholders ( or {{ENV_VAR}}) rather than real IDs. Co-Authored-By: Claude Opus 4.6 * refactor(selectors): replace hardcoded display name fallbacks with canonical-aware resolution Use resolveDependencyValue to resolve context values for useSelectorDisplayName, eliminating manual || getStringValue('*Selector') fallbacks that required updating for each new selector pair. Co-Authored-By: Claude Opus 4.6 * fix(selectors): tighten SharePoint site ID validation to exclude underscores SharePoint composite site IDs use hostname,guid,guid format where only alphanumerics, periods, hyphens, and commas are valid characters. Co-Authored-By: Claude Opus 4.6 * fix(selectors): ensure string IDs in Pipedrive/Cal.com routes, fix Trello closed board filter Pipedrive pipelines and Cal.com event-types/schedules routes now consistently return string IDs via String() conversion. Trello boards route no longer filters out closed boards, preserving them for fetchById lookups. The closed filter is applied only in the registry's fetchList so archived boards don't appear in dropdowns but can still be resolved by ID for display names. Co-Authored-By: Claude Opus 4.6 * fix(selectors): convert Zoom meeting IDs to strings for consistency Zoom API returns numeric meeting IDs. Convert with String() to match the string ID convention used by all other selector routes. Co-Authored-By: Claude Opus 4.6 * fix(selectors): align registry types with route string ID returns Routes already convert numeric IDs to strings via String(), so update the registry types (CalcomEventType, CalcomSchedule, PipedrivePipeline, ZoomMeeting) from id: number to id: string and remove the now-redundant String() coercions in fetchList/fetchById. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../sim/app/api/tools/airtable/bases/route.ts | 79 ++ .../app/api/tools/airtable/tables/route.ts | 95 ++ .../app/api/tools/asana/workspaces/route.ts | 79 ++ apps/sim/app/api/tools/attio/lists/route.ts | 79 ++ apps/sim/app/api/tools/attio/objects/route.ts | 79 ++ .../app/api/tools/calcom/event-types/route.ts | 83 ++ .../app/api/tools/calcom/schedules/route.ts | 80 ++ .../tools/confluence/selector-spaces/route.ts | 96 ++ .../tools/google_bigquery/datasets/route.ts | 100 +++ .../api/tools/google_bigquery/tables/route.ts | 94 ++ .../tools/google_tasks/task-lists/route.ts | 79 ++ .../tools/jsm/selector-requesttypes/route.ts | 103 +++ .../tools/jsm/selector-servicedesks/route.ts | 94 ++ .../tools/microsoft_planner/plans/route.ts | 72 ++ .../tools/microsoft_planner/tasks/route.ts | 94 +- .../app/api/tools/notion/databases/route.ts | 86 ++ apps/sim/app/api/tools/notion/pages/route.ts | 86 ++ .../api/tools/pipedrive/pipelines/route.ts | 79 ++ .../app/api/tools/sharepoint/lists/route.ts | 91 ++ .../app/api/tools/sharepoint/sites/route.ts | 82 +- apps/sim/app/api/tools/trello/boards/route.ts | 87 ++ apps/sim/app/api/tools/zoom/meetings/route.ts | 82 ++ .../sub-block/hooks/use-selector-setup.ts | 5 + .../workflow-block/workflow-block.tsx | 35 +- apps/sim/blocks/blocks.test.ts | 13 +- apps/sim/blocks/blocks/airtable.ts | 34 +- apps/sim/blocks/blocks/asana.ts | 37 + apps/sim/blocks/blocks/attio.ts | 74 ++ apps/sim/blocks/blocks/calcom.ts | 85 +- apps/sim/blocks/blocks/confluence.ts | 37 +- apps/sim/blocks/blocks/google_bigquery.ts | 32 + apps/sim/blocks/blocks/google_tasks.ts | 21 +- .../blocks/blocks/jira_service_management.ts | 57 ++ apps/sim/blocks/blocks/microsoft_planner.ts | 28 +- apps/sim/blocks/blocks/notion.ts | 90 +- apps/sim/blocks/blocks/pipedrive.ts | 42 +- apps/sim/blocks/blocks/sharepoint.ts | 29 +- apps/sim/blocks/blocks/trello.ts | 63 +- apps/sim/blocks/blocks/zoom.ts | 30 +- apps/sim/hooks/selectors/registry.ts | 839 +++++++++++++++++- apps/sim/hooks/selectors/resolution.ts | 8 + apps/sim/hooks/selectors/types.ts | 23 + .../sim/hooks/selectors/use-selector-query.ts | 5 +- apps/sim/hooks/use-selector-display-name.ts | 28 + .../sim/lib/core/security/input-validation.ts | 45 + .../workflows/comparison/resolve-values.ts | 12 + 46 files changed, 3340 insertions(+), 231 deletions(-) create mode 100644 apps/sim/app/api/tools/airtable/bases/route.ts create mode 100644 apps/sim/app/api/tools/airtable/tables/route.ts create mode 100644 apps/sim/app/api/tools/asana/workspaces/route.ts create mode 100644 apps/sim/app/api/tools/attio/lists/route.ts create mode 100644 apps/sim/app/api/tools/attio/objects/route.ts create mode 100644 apps/sim/app/api/tools/calcom/event-types/route.ts create mode 100644 apps/sim/app/api/tools/calcom/schedules/route.ts create mode 100644 apps/sim/app/api/tools/confluence/selector-spaces/route.ts create mode 100644 apps/sim/app/api/tools/google_bigquery/datasets/route.ts create mode 100644 apps/sim/app/api/tools/google_bigquery/tables/route.ts create mode 100644 apps/sim/app/api/tools/google_tasks/task-lists/route.ts create mode 100644 apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts create mode 100644 apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts create mode 100644 apps/sim/app/api/tools/microsoft_planner/plans/route.ts create mode 100644 apps/sim/app/api/tools/notion/databases/route.ts create mode 100644 apps/sim/app/api/tools/notion/pages/route.ts create mode 100644 apps/sim/app/api/tools/pipedrive/pipelines/route.ts create mode 100644 apps/sim/app/api/tools/sharepoint/lists/route.ts create mode 100644 apps/sim/app/api/tools/trello/boards/route.ts create mode 100644 apps/sim/app/api/tools/zoom/meetings/route.ts diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts new file mode 100644 index 00000000000..839c1359dd3 --- /dev/null +++ b/apps/sim/app/api/tools/airtable/bases/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AirtableBasesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.airtable.com/v0/meta/bases', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Airtable bases', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Airtable bases', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const bases = (data.bases || []).map((base: { id: string; name: string }) => ({ + id: base.id, + name: base.name, + })) + + return NextResponse.json({ bases }) + } catch (error) { + logger.error('Error processing Airtable bases request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Airtable bases', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts new file mode 100644 index 00000000000..41cd68dc12f --- /dev/null +++ b/apps/sim/app/api/tools/airtable/tables/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAirtableId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AirtableTablesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, baseId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!baseId) { + logger.error('Missing baseId in request') + return NextResponse.json({ error: 'Base ID is required' }, { status: 400 }) + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + logger.error('Invalid baseId', { error: baseIdValidation.error }) + return NextResponse.json({ error: baseIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://api.airtable.com/v0/meta/bases/${baseIdValidation.sanitized}/tables`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Airtable tables', { + status: response.status, + error: errorData, + baseId, + }) + return NextResponse.json( + { error: 'Failed to fetch Airtable tables', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const tables = (data.tables || []).map((table: { id: string; name: string }) => ({ + id: table.id, + name: table.name, + })) + + return NextResponse.json({ tables }) + } catch (error) { + logger.error('Error processing Airtable tables request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Airtable tables', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts new file mode 100644 index 00000000000..2393ade11c9 --- /dev/null +++ b/apps/sim/app/api/tools/asana/workspaces/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AsanaWorkspacesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://app.asana.com/api/1.0/workspaces', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Asana workspaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Asana workspaces', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const workspaces = (data.data || []).map((workspace: { gid: string; name: string }) => ({ + id: workspace.gid, + name: workspace.name, + })) + + return NextResponse.json({ workspaces }) + } catch (error) { + logger.error('Error processing Asana workspaces request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Asana workspaces', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/attio/lists/route.ts b/apps/sim/app/api/tools/attio/lists/route.ts new file mode 100644 index 00000000000..1575f7eb3a0 --- /dev/null +++ b/apps/sim/app/api/tools/attio/lists/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AttioListsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.attio.com/v2/lists', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Attio lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Attio lists', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const lists = (data.data || []).map((list: { api_slug: string; name: string }) => ({ + id: list.api_slug, + name: list.name, + })) + + return NextResponse.json({ lists }) + } catch (error) { + logger.error('Error processing Attio lists request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Attio lists', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/attio/objects/route.ts b/apps/sim/app/api/tools/attio/objects/route.ts new file mode 100644 index 00000000000..ae3ba5152dd --- /dev/null +++ b/apps/sim/app/api/tools/attio/objects/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('AttioObjectsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.attio.com/v2/objects', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Attio objects', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Attio objects', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const objects = (data.data || []).map((obj: { api_slug: string; singular_noun: string }) => ({ + id: obj.api_slug, + name: obj.singular_noun, + })) + + return NextResponse.json({ objects }) + } catch (error) { + logger.error('Error processing Attio objects request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Attio objects', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/calcom/event-types/route.ts b/apps/sim/app/api/tools/calcom/event-types/route.ts new file mode 100644 index 00000000000..b8596f614f8 --- /dev/null +++ b/apps/sim/app/api/tools/calcom/event-types/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('CalcomEventTypesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.cal.com/v2/event-types', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'cal-api-version': '2024-06-14', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Cal.com event types', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Cal.com event types', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const eventTypes = (data.data || []).map( + (eventType: { id: number; title: string; slug: string }) => ({ + id: String(eventType.id), + title: eventType.title, + slug: eventType.slug, + }) + ) + + return NextResponse.json({ eventTypes }) + } catch (error) { + logger.error('Error processing Cal.com event types request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Cal.com event types', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/calcom/schedules/route.ts b/apps/sim/app/api/tools/calcom/schedules/route.ts new file mode 100644 index 00000000000..8f69328cc65 --- /dev/null +++ b/apps/sim/app/api/tools/calcom/schedules/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('CalcomSchedulesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.cal.com/v2/schedules', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'cal-api-version': '2024-06-11', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Cal.com schedules', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Cal.com schedules', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const schedules = (data.data || []).map((schedule: { id: number; name: string }) => ({ + id: String(schedule.id), + name: schedule.name, + })) + + return NextResponse.json({ schedules }) + } catch (error) { + logger.error('Error processing Cal.com schedules request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Cal.com schedules', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts new file mode 100644 index 00000000000..7ae61d3e983 --- /dev/null +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getConfluenceCloudId } from '@/tools/confluence/utils' + +const logger = createLogger('ConfluenceSelectorSpacesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, domain } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const cloudId = await getConfluenceCloudId(domain, accessToken) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces?limit=250` + + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + logger.error('Confluence API error:', { + status: response.status, + statusText: response.statusText, + error: errorData, + }) + const errorMessage = + errorData?.message || `Failed to list Confluence spaces (${response.status})` + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const data = await response.json() + const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({ + id: space.id, + name: space.name, + key: space.key, + })) + + return NextResponse.json({ spaces }) + } catch (error) { + logger.error('Error listing Confluence spaces:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts new file mode 100644 index 00000000000..ffc4ef7235d --- /dev/null +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GoogleBigQueryDatasetsAPI') + +export const dynamic = 'force-dynamic' + +/** + * POST /api/tools/google_bigquery/datasets + * + * Fetches the list of BigQuery datasets for a given project using the caller's OAuth credential. + * + * @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body + * @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName` + */ +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, projectId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!projectId) { + logger.error('Missing project ID in request') + return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets?maxResults=200`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch BigQuery datasets', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch BigQuery datasets', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const datasets = (data.datasets || []).map( + (ds: { + datasetReference: { datasetId: string; projectId: string } + friendlyName?: string + }) => ({ + datasetReference: ds.datasetReference, + friendlyName: ds.friendlyName, + }) + ) + + return NextResponse.json({ datasets }) + } catch (error) { + logger.error('Error processing BigQuery datasets request:', error) + return NextResponse.json( + { error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts new file mode 100644 index 00000000000..f2f7c6c43c4 --- /dev/null +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GoogleBigQueryTablesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, projectId, datasetId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!projectId) { + logger.error('Missing project ID in request') + return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) + } + + if (!datasetId) { + logger.error('Missing dataset ID in request') + return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables?maxResults=200`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch BigQuery tables', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch BigQuery tables', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const tables = (data.tables || []).map( + (t: { tableReference: { tableId: string }; friendlyName?: string }) => ({ + tableReference: t.tableReference, + friendlyName: t.friendlyName, + }) + ) + + return NextResponse.json({ tables }) + } catch (error) { + logger.error('Error processing BigQuery tables request:', error) + return NextResponse.json( + { error: 'Failed to retrieve BigQuery tables', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts new file mode 100644 index 00000000000..6448f216505 --- /dev/null +++ b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('GoogleTasksTaskListsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://tasks.googleapis.com/tasks/v1/users/@me/lists', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Google Tasks task lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Google Tasks task lists', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const taskLists = (data.items || []).map((list: { id: string; title: string }) => ({ + id: list.id, + title: list.title, + })) + + return NextResponse.json({ taskLists }) + } catch (error) { + logger.error('Error processing Google Tasks task lists request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts new file mode 100644 index 00000000000..a9ef02bec86 --- /dev/null +++ b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +const logger = createLogger('JsmSelectorRequestTypesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, domain, serviceDeskId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + if (!serviceDeskId) { + return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 }) + } + + const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId') + if (!serviceDeskIdValidation.isValid) { + return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const cloudId = await getJiraCloudId(domain, accessToken) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) + const url = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype?limit=100` + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + return NextResponse.json( + { error: `JSM API error: ${response.status} ${response.statusText}` }, + { status: response.status } + ) + } + + const data = await response.json() + const requestTypes = (data.values || []).map((rt: { id: string; name: string }) => ({ + id: rt.id, + name: rt.name, + })) + + return NextResponse.json({ requestTypes }) + } catch (error) { + logger.error('Error listing JSM request types:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts new file mode 100644 index 00000000000..b4bc93032fb --- /dev/null +++ b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +const logger = createLogger('JsmSelectorServiceDesksAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId, domain } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + if (!domain) { + return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const cloudId = await getJiraCloudId(domain, accessToken) + + const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') + if (!cloudIdValidation.isValid) { + return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) + } + + const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) + const url = `${baseUrl}/servicedesk?limit=100` + + const response = await fetch(url, { + method: 'GET', + headers: getJsmHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('JSM API error:', { + status: response.status, + statusText: response.statusText, + error: errorText, + }) + return NextResponse.json( + { error: `JSM API error: ${response.status} ${response.statusText}` }, + { status: response.status } + ) + } + + const data = await response.json() + const serviceDesks = (data.values || []).map((sd: { id: string; projectName: string }) => ({ + id: sd.id, + name: sd.projectName, + })) + + return NextResponse.json({ serviceDesks }) + } catch (error) { + logger.error('Error listing JSM service desks:', error) + return NextResponse.json( + { error: (error as Error).message || 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts new file mode 100644 index 00000000000..e43650d3d7a --- /dev/null +++ b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('MicrosoftPlannerPlansAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://graph.microsoft.com/v1.0/me/planner/plans', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) + return NextResponse.json( + { error: 'Failed to fetch plans from Microsoft Graph' }, + { status: response.status } + ) + } + + const data = await response.json() + const plans = data.value || [] + + const filteredPlans = plans.map((plan: { id: string; title: string }) => ({ + id: plan.id, + title: plan.title, + })) + + return NextResponse.json({ plans: filteredPlans }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Microsoft Planner plans:`, error) + return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index eecfdb48c75..db0ccd88ae6 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -1,38 +1,29 @@ -import { randomUUID } from 'crypto' -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { PlannerTask } from '@/tools/microsoft_planner/types' const logger = createLogger('MicrosoftPlannerTasksAPI') -export async function GET(request: NextRequest) { - const requestId = randomUUID().slice(0, 8) +export const dynamic = 'force-dynamic' - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } +export async function POST(request: Request) { + const requestId = generateRequestId() - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const planId = searchParams.get('planId') + try { + const body = await request.json() + const { credential, workflowId, planId } = body - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } if (!planId) { - logger.error(`[${requestId}] Missing planId parameter`) + logger.error(`[${requestId}] Missing planId in request`) return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 }) } @@ -42,52 +33,35 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: planIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentials.length) { - logger.warn(`[${requestId}] Credential not found`, { credentialId }) - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credential, + authz.credentialOwnerUserId, requestId ) - if (!accessToken) { logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) } - const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) + const response = await fetch( + `https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) if (!response.ok) { const errorText = await response.text() diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts new file mode 100644 index 00000000000..1dee214a2d9 --- /dev/null +++ b/apps/sim/app/api/tools/notion/databases/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { extractTitleFromItem } from '@/tools/notion/utils' + +const logger = createLogger('NotionDatabasesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: 'database', property: 'object' }, + page_size: 100, + }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Notion databases', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Notion databases', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const databases = (data.results || []).map((db: Record) => ({ + id: db.id as string, + name: extractTitleFromItem(db), + })) + + return NextResponse.json({ databases }) + } catch (error) { + logger.error('Error processing Notion databases request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Notion databases', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts new file mode 100644 index 00000000000..0a0bd4f4703 --- /dev/null +++ b/apps/sim/app/api/tools/notion/pages/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { extractTitleFromItem } from '@/tools/notion/utils' + +const logger = createLogger('NotionPagesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: 'page', property: 'object' }, + page_size: 100, + }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Notion pages', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Notion pages', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const pages = (data.results || []).map((page: Record) => ({ + id: page.id as string, + name: extractTitleFromItem(page), + })) + + return NextResponse.json({ pages }) + } catch (error) { + logger.error('Error processing Notion pages request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Notion pages', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts new file mode 100644 index 00000000000..ba188e6c386 --- /dev/null +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -0,0 +1,79 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('PipedrivePipelinesAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch('https://api.pipedrive.com/v1/pipelines', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Pipedrive pipelines', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Pipedrive pipelines', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const pipelines = (data.data || []).map((pipeline: { id: number; name: string }) => ({ + id: String(pipeline.id), + name: pipeline.name, + })) + + return NextResponse.json({ pipelines }) + } catch (error) { + logger.error('Error processing Pipedrive pipelines request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Pipedrive pipelines', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts new file mode 100644 index 00000000000..fbbbaab6817 --- /dev/null +++ b/apps/sim/app/api/tools/sharepoint/lists/route.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateSharePointSiteId } from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SharePointListsAPI') + +interface SharePointList { + id: string + displayName: string + description?: string + webUrl?: string + list?: { + hidden?: boolean + } +} + +export async function POST(request: Request) { + const requestId = generateRequestId() + + try { + const body = await request.json() + const { credential, workflowId, siteId } = body + + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const siteIdValidation = validateSharePointSiteId(siteId) + if (!siteIdValidation.isValid) { + logger.error(`[${requestId}] Invalid siteId: ${siteIdValidation.error}`) + return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) + } + + const url = `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=100` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch lists from SharePoint' }, + { status: response.status } + ) + } + + const data = await response.json() + const lists = (data.value || []) + .filter((list: SharePointList) => list.list?.hidden !== true) + .map((list: SharePointList) => ({ + id: list.id, + displayName: list.displayName, + })) + + logger.info(`[${requestId}] Successfully fetched ${lists.length} SharePoint lists`) + return NextResponse.json({ lists }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching lists from SharePoint`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index de161b97309..2119fe975c6 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -1,79 +1,45 @@ -import { randomUUID } from 'crypto' -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { SharepointSite } from '@/tools/sharepoint/types' export const dynamic = 'force-dynamic' const logger = createLogger('SharePointSitesAPI') -/** - * Get SharePoint sites from Microsoft Graph API - */ -export async function GET(request: NextRequest) { - const requestId = randomUUID().slice(0, 8) +export async function POST(request: Request) { + const requestId = generateRequestId() try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const credentialId = searchParams.get('credentialId') - const query = searchParams.get('query') || '' - - if (!credentialId) { - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } + const body = await request.json() + const { credential, workflowId, query } = body - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) + if (!credential) { + logger.error(`[${requestId}] Missing credential in request`) + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + credential, + authz.credentialOwnerUserId, requestId ) if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json( + { error: 'Failed to obtain valid access token', authRequired: true }, + { status: 401 } + ) } const searchQuery = query || '*' diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts new file mode 100644 index 00000000000..fb4ca52738a --- /dev/null +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('TrelloBoardsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const apiKey = process.env.TRELLO_API_KEY + if (!apiKey) { + logger.error('Trello API key not configured') + return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 }) + } + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `https://api.trello.com/1/members/me/boards?key=${apiKey}&token=${accessToken}&fields=id,name,closed`, + { + headers: { + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Trello boards', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Trello boards', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const boards = (data || []).map((board: { id: string; name: string; closed: boolean }) => ({ + id: board.id, + name: board.name, + closed: board.closed, + })) + + return NextResponse.json({ boards }) + } catch (error) { + logger.error('Error processing Trello boards request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Trello boards', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts new file mode 100644 index 00000000000..01360af7610 --- /dev/null +++ b/apps/sim/app/api/tools/zoom/meetings/route.ts @@ -0,0 +1,82 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('ZoomMeetingsAPI') + +export const dynamic = 'force-dynamic' + +export async function POST(request: Request) { + const requestId = generateRequestId() + try { + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + 'https://api.zoom.us/v2/users/me/meetings?page_size=300&type=scheduled', + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch Zoom meetings', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch Zoom meetings', details: errorData }, + { status: response.status } + ) + } + + const data = await response.json() + const meetings = (data.meetings || []).map((meeting: { id: number; topic: string }) => ({ + id: String(meeting.id), + name: meeting.topic, + })) + + return NextResponse.json({ meetings }) + } catch (error) { + logger.error('Error processing Zoom meetings request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Zoom meetings', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index ff6d5e43b6d..e9acaca7dcb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' import type { SubBlockConfig } from '@/blocks/types' +import { isEnvVarReference, isReference } from '@/executor/constants' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useDependsOnGate } from './use-depends-on-gate' @@ -45,6 +46,7 @@ export function useSelectorSetup( if (value === null || value === undefined) continue const strValue = String(value) if (!strValue) continue + if (isReference(strValue) || isEnvVarReference(strValue)) continue const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey @@ -78,4 +80,7 @@ const CONTEXT_FIELD_SET: Record = { collectionId: true, spreadsheetId: true, fileId: true, + baseId: true, + datasetId: true, + serviceDeskId: true, } 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 ae10a76a029..de5694b7b9b 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 @@ -549,10 +549,30 @@ const SubBlockRow = memo(function SubBlockRow({ return typeof option === 'string' ? option : option.label }, [subBlock, rawValue]) - const domainValue = getStringValue('domain') - const teamIdValue = getStringValue('teamId') - const projectIdValue = getStringValue('projectId') - const planIdValue = getStringValue('planId') + const resolveContextValue = useCallback( + (key: string): string | undefined => { + const resolved = resolveDependencyValue( + key, + rawValues, + canonicalIndex || buildCanonicalIndex([]), + canonicalModeOverrides + ) + return typeof resolved === 'string' && resolved.length > 0 ? resolved : undefined + }, + [rawValues, canonicalIndex, canonicalModeOverrides] + ) + + const domainValue = resolveContextValue('domain') + const teamIdValue = resolveContextValue('teamId') + const projectIdValue = resolveContextValue('projectId') + const planIdValue = resolveContextValue('planId') + const baseIdValue = resolveContextValue('baseId') + const datasetIdValue = resolveContextValue('datasetId') + const serviceDeskIdValue = resolveContextValue('serviceDeskId') + const siteIdValue = resolveContextValue('siteId') + const collectionIdValue = resolveContextValue('collectionId') + const spreadsheetIdValue = resolveContextValue('spreadsheetId') + const fileIdValue = resolveContextValue('fileId') const { displayName: selectorDisplayName } = useSelectorDisplayName({ subBlock, @@ -564,6 +584,13 @@ const SubBlockRow = memo(function SubBlockRow({ teamId: teamIdValue, projectId: projectIdValue, planId: planIdValue, + baseId: baseIdValue, + datasetId: datasetIdValue, + serviceDeskId: serviceDeskIdValue, + siteId: siteIdValue, + collectionId: collectionIdValue, + spreadsheetId: spreadsheetIdValue, + fileId: fileIdValue, }) const { knowledgeBase: kbForDisplayName } = useKnowledgeBase( diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index dfc80540915..9f668aab81e 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -667,15 +667,18 @@ describe.concurrent('Blocks Module', () => { const errors: string[] = [] for (const block of blocks) { - const allSubBlockIds = new Set(block.subBlocks.map((sb) => sb.id)) + // Exclude trigger-mode subBlocks — they operate in a separate rendering context + // and their IDs don't participate in canonical param resolution + const nonTriggerSubBlocks = block.subBlocks.filter((sb) => sb.mode !== 'trigger') + const allSubBlockIds = new Set(nonTriggerSubBlocks.map((sb) => sb.id)) const canonicalParamIds = new Set( - block.subBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId) + nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId) ) for (const canonicalId of canonicalParamIds) { if (allSubBlockIds.has(canonicalId!)) { // Check if the matching subBlock also has a canonicalParamId pointing to itself - const matchingSubBlock = block.subBlocks.find( + const matchingSubBlock = nonTriggerSubBlocks.find( (sb) => sb.id === canonicalId && !sb.canonicalParamId ) if (matchingSubBlock) { @@ -857,6 +860,10 @@ describe.concurrent('Blocks Module', () => { if (typeof subBlock.condition === 'function') { continue } + // Skip trigger-mode subBlocks — they operate in a separate rendering context + if (subBlock.mode === 'trigger') { + continue + } const conditionKey = serializeCondition(subBlock.condition) if (!canonicalByCondition.has(subBlock.canonicalParamId)) { canonicalByCondition.set(subBlock.canonicalParamId, new Set()) diff --git a/apps/sim/blocks/blocks/airtable.ts b/apps/sim/blocks/blocks/airtable.ts index 63fc14b33dd..f2b38efb309 100644 --- a/apps/sim/blocks/blocks/airtable.ts +++ b/apps/sim/blocks/blocks/airtable.ts @@ -57,21 +57,51 @@ export const AirtableBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + { + id: 'baseSelector', + title: 'Base', + type: 'project-selector', + canonicalParamId: 'baseId', + serviceId: 'airtable', + selectorKey: 'airtable.bases', + selectorAllowSearch: false, + placeholder: 'Select Airtable base', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: 'listBases', not: true }, + required: { field: 'operation', value: 'listBases', not: true }, + }, { id: 'baseId', title: 'Base ID', type: 'short-input', + canonicalParamId: 'baseId', placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)', - dependsOn: ['credential'], + mode: 'advanced', condition: { field: 'operation', value: 'listBases', not: true }, required: { field: 'operation', value: 'listBases', not: true }, }, + { + id: 'tableSelector', + title: 'Table', + type: 'file-selector', + canonicalParamId: 'tableId', + serviceId: 'airtable', + selectorKey: 'airtable.tables', + selectorAllowSearch: false, + placeholder: 'Select Airtable table', + dependsOn: ['credential', 'baseSelector'], + mode: 'basic', + condition: { field: 'operation', value: ['listBases', 'listTables'], not: true }, + required: { field: 'operation', value: ['listBases', 'listTables'], not: true }, + }, { id: 'tableId', title: 'Table ID', type: 'short-input', + canonicalParamId: 'tableId', placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)', - dependsOn: ['credential', 'baseId'], + mode: 'advanced', condition: { field: 'operation', value: ['listBases', 'listTables'], not: true }, required: { field: 'operation', value: ['listBases', 'listTables'], not: true }, }, diff --git a/apps/sim/blocks/blocks/asana.ts b/apps/sim/blocks/blocks/asana.ts index 1276e8d572b..25418864593 100644 --- a/apps/sim/blocks/blocks/asana.ts +++ b/apps/sim/blocks/blocks/asana.ts @@ -48,12 +48,31 @@ export const AsanaBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + { + id: 'workspaceSelector', + title: 'Workspace', + type: 'project-selector', + canonicalParamId: 'workspace', + serviceId: 'asana', + selectorKey: 'asana.workspaces', + selectorAllowSearch: false, + placeholder: 'Select Asana workspace', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['create_task', 'get_projects', 'search_tasks'], + }, + required: true, + }, { id: 'workspace', title: 'Workspace GID', type: 'short-input', + canonicalParamId: 'workspace', required: true, placeholder: 'Enter Asana workspace GID', + mode: 'advanced', condition: { field: 'operation', value: ['create_task', 'get_projects', 'search_tasks'], @@ -81,11 +100,29 @@ export const AsanaBlock: BlockConfig = { value: ['update_task', 'add_comment'], }, }, + { + id: 'getTasksWorkspaceSelector', + title: 'Workspace', + type: 'project-selector', + canonicalParamId: 'getTasks_workspace', + serviceId: 'asana', + selectorKey: 'asana.workspaces', + selectorAllowSearch: false, + placeholder: 'Select Asana workspace', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['get_task'], + }, + }, { id: 'getTasks_workspace', title: 'Workspace GID', type: 'short-input', + canonicalParamId: 'getTasks_workspace', placeholder: 'Enter workspace GID', + mode: 'advanced', condition: { field: 'operation', value: ['get_task'], diff --git a/apps/sim/blocks/blocks/attio.ts b/apps/sim/blocks/blocks/attio.ts index 8e51dbbe713..aebea95d363 100644 --- a/apps/sim/blocks/blocks/attio.ts +++ b/apps/sim/blocks/blocks/attio.ts @@ -86,11 +86,47 @@ export const AttioBlock: BlockConfig = { }, // Record fields + { + id: 'objectTypeSelector', + title: 'Object Type', + type: 'project-selector', + canonicalParamId: 'objectType', + serviceId: 'attio', + selectorKey: 'attio.objects', + selectorAllowSearch: false, + placeholder: 'Select object type', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'assert_record', + ], + }, + required: { + field: 'operation', + value: [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'assert_record', + ], + }, + }, { id: 'objectType', title: 'Object Type', type: 'short-input', + canonicalParamId: 'objectType', placeholder: 'e.g. people, companies', + mode: 'advanced', condition: { field: 'operation', value: [ @@ -524,11 +560,49 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text. }, // List fields + { + id: 'listSelector', + title: 'List', + type: 'project-selector', + canonicalParamId: 'listIdOrSlug', + serviceId: 'attio', + selectorKey: 'attio.lists', + selectorAllowSearch: false, + placeholder: 'Select Attio list', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: [ + 'get_list', + 'update_list', + 'query_list_entries', + 'get_list_entry', + 'create_list_entry', + 'update_list_entry', + 'delete_list_entry', + ], + }, + required: { + field: 'operation', + value: [ + 'get_list', + 'update_list', + 'query_list_entries', + 'get_list_entry', + 'create_list_entry', + 'update_list_entry', + 'delete_list_entry', + ], + }, + }, { id: 'listIdOrSlug', title: 'List ID or Slug', type: 'short-input', + canonicalParamId: 'listIdOrSlug', placeholder: 'Enter the list ID or slug', + mode: 'advanced', condition: { field: 'operation', value: [ diff --git a/apps/sim/blocks/blocks/calcom.ts b/apps/sim/blocks/blocks/calcom.ts index a294c40b634..0a32aa854dc 100644 --- a/apps/sim/blocks/blocks/calcom.ts +++ b/apps/sim/blocks/blocks/calcom.ts @@ -65,11 +65,30 @@ export const CalComBlock: BlockConfig = { }, // === Create Booking fields === + { + id: 'eventTypeSelector', + title: 'Event Type', + type: 'project-selector', + canonicalParamId: 'eventTypeId', + serviceId: 'calcom', + selectorKey: 'calcom.eventTypes', + selectorAllowSearch: false, + placeholder: 'Select event type', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_create_booking', 'calcom_get_slots'], + }, + required: { field: 'operation', value: 'calcom_create_booking' }, + }, { id: 'eventTypeId', title: 'Event Type ID', type: 'short-input', + canonicalParamId: 'eventTypeId', placeholder: 'Enter event type ID (number)', + mode: 'advanced', condition: { field: 'operation', value: ['calcom_create_booking', 'calcom_get_slots'], @@ -261,11 +280,33 @@ Return ONLY the IANA timezone string - no explanations or quotes.`, }, // === Event Type fields === + { + id: 'eventTypeParamSelector', + title: 'Event Type', + type: 'project-selector', + canonicalParamId: 'eventTypeIdParam', + serviceId: 'calcom', + selectorKey: 'calcom.eventTypes', + selectorAllowSearch: false, + placeholder: 'Select event type', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'], + }, + required: { + field: 'operation', + value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'], + }, + }, { id: 'eventTypeIdParam', title: 'Event Type ID', type: 'short-input', + canonicalParamId: 'eventTypeIdParam', placeholder: 'Enter event type ID', + mode: 'advanced', condition: { field: 'operation', value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'], @@ -364,10 +405,27 @@ Return ONLY the IANA timezone string - no explanations or quotes.`, }, mode: 'advanced', }, + { + id: 'eventTypeScheduleSelector', + title: 'Schedule', + type: 'project-selector', + canonicalParamId: 'eventTypeScheduleId', + serviceId: 'calcom', + selectorKey: 'calcom.schedules', + selectorAllowSearch: false, + placeholder: 'Select schedule', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_create_event_type', 'calcom_update_event_type'], + }, + }, { id: 'eventTypeScheduleId', title: 'Schedule ID', type: 'short-input', + canonicalParamId: 'eventTypeScheduleId', placeholder: 'Assign to a specific schedule', condition: { field: 'operation', @@ -388,11 +446,33 @@ Return ONLY the IANA timezone string - no explanations or quotes.`, }, // === Schedule fields === + { + id: 'scheduleSelector', + title: 'Schedule', + type: 'project-selector', + canonicalParamId: 'scheduleId', + serviceId: 'calcom', + selectorKey: 'calcom.schedules', + selectorAllowSearch: false, + placeholder: 'Select schedule', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'], + }, + required: { + field: 'operation', + value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'], + }, + }, { id: 'scheduleId', title: 'Schedule ID', type: 'short-input', + canonicalParamId: 'scheduleId', placeholder: 'Enter schedule ID', + mode: 'advanced', condition: { field: 'operation', value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'], @@ -771,7 +851,10 @@ Return ONLY valid JSON - no explanations.`, cancellationReason: { type: 'string', description: 'Reason for cancellation' }, reschedulingReason: { type: 'string', description: 'Reason for rescheduling' }, bookingStatus: { type: 'string', description: 'Filter by booking status' }, - eventTypeIdParam: { type: 'number', description: 'Event type ID for get/update/delete' }, + eventTypeIdParam: { + type: 'number', + description: 'Event type ID for get/update/delete', + }, title: { type: 'string', description: 'Event type title' }, slug: { type: 'string', description: 'URL-friendly slug' }, eventLength: { type: 'number', description: 'Event duration in minutes' }, diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 180477fd044..b71bc653e0f 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -84,7 +84,6 @@ export const ConfluenceBlock: BlockConfig = { 'write:content.property:confluence', 'read:hierarchical-content:confluence', 'read:content.metadata:confluence', - 'read:user:confluence', ], placeholder: 'Select Confluence account', required: true, @@ -645,11 +644,44 @@ export const ConfluenceV2Block: BlockConfig = { ], }, }, + { + id: 'spaceSelector', + title: 'Space', + type: 'project-selector', + canonicalParamId: 'spaceId', + serviceId: 'confluence', + selectorKey: 'confluence.spaces', + selectorAllowSearch: false, + placeholder: 'Select Confluence space', + dependsOn: ['credential', 'domain'], + mode: 'basic', + required: true, + condition: { + field: 'operation', + value: [ + 'create', + 'get_space', + 'update_space', + 'delete_space', + 'list_pages_in_space', + 'search_in_space', + 'create_blogpost', + 'list_blogposts_in_space', + 'list_space_labels', + 'list_space_permissions', + 'list_space_properties', + 'create_space_property', + 'delete_space_property', + ], + }, + }, { id: 'spaceId', title: 'Space ID', type: 'short-input', + canonicalParamId: 'spaceId', placeholder: 'Enter Confluence space ID', + mode: 'advanced', required: true, condition: { field: 'operation', @@ -1250,7 +1282,6 @@ export const ConfluenceV2Block: BlockConfig = { ...rest } = params - // Use canonical param (serializer already handles basic/advanced mode) const effectivePageId = pageId ? String(pageId).trim() : '' if (operation === 'add_label') { @@ -1511,7 +1542,7 @@ export const ConfluenceV2Block: BlockConfig = { operation: { type: 'string', description: 'Operation to perform' }, domain: { type: 'string', description: 'Confluence domain' }, oauthCredential: { type: 'string', description: 'Confluence access token' }, - pageId: { type: 'string', description: 'Page identifier (canonical param)' }, + pageId: { type: 'string', description: 'Page identifier' }, spaceId: { type: 'string', description: 'Space identifier' }, blogPostId: { type: 'string', description: 'Blog post identifier' }, versionNumber: { type: 'number', description: 'Page version number' }, diff --git a/apps/sim/blocks/blocks/google_bigquery.ts b/apps/sim/blocks/blocks/google_bigquery.ts index 0ba15dfe565..1fdece82317 100644 --- a/apps/sim/blocks/blocks/google_bigquery.ts +++ b/apps/sim/blocks/blocks/google_bigquery.ts @@ -109,20 +109,52 @@ Return ONLY the SQL query - no explanations, no quotes, no extra text.`, condition: { field: 'operation', value: 'query' }, }, + { + id: 'datasetSelector', + title: 'Dataset', + type: 'project-selector', + canonicalParamId: 'datasetId', + serviceId: 'google-bigquery', + selectorKey: 'bigquery.datasets', + selectorAllowSearch: false, + placeholder: 'Select BigQuery dataset', + dependsOn: ['credential', 'projectId'], + mode: 'basic', + condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, + required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, + }, { id: 'datasetId', title: 'Dataset ID', type: 'short-input', + canonicalParamId: 'datasetId', placeholder: 'Enter BigQuery dataset ID', + mode: 'advanced', condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] }, }, + { + id: 'tableSelector', + title: 'Table', + type: 'file-selector', + canonicalParamId: 'tableId', + serviceId: 'google-bigquery', + selectorKey: 'bigquery.tables', + selectorAllowSearch: false, + placeholder: 'Select BigQuery table', + dependsOn: ['credential', 'projectId', 'datasetSelector'], + mode: 'basic', + condition: { field: 'operation', value: ['get_table', 'insert_rows'] }, + required: { field: 'operation', value: ['get_table', 'insert_rows'] }, + }, { id: 'tableId', title: 'Table ID', type: 'short-input', + canonicalParamId: 'tableId', placeholder: 'Enter BigQuery table ID', + mode: 'advanced', condition: { field: 'operation', value: ['get_table', 'insert_rows'] }, required: { field: 'operation', value: ['get_table', 'insert_rows'] }, }, diff --git a/apps/sim/blocks/blocks/google_tasks.ts b/apps/sim/blocks/blocks/google_tasks.ts index 850f824d509..ad63e6e1a72 100644 --- a/apps/sim/blocks/blocks/google_tasks.ts +++ b/apps/sim/blocks/blocks/google_tasks.ts @@ -51,12 +51,27 @@ export const GoogleTasksBlock: BlockConfig = { required: true, }, - // Task List ID - shown for all task operations (not list_task_lists) + // Task List - shown for all task operations (not list_task_lists) + { + id: 'taskListSelector', + title: 'Task List', + type: 'project-selector', + canonicalParamId: 'taskListId', + serviceId: 'google-tasks', + selectorKey: 'google.tasks.lists', + selectorAllowSearch: false, + placeholder: 'Select task list', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: 'list_task_lists', not: true }, + }, { id: 'taskListId', title: 'Task List ID', type: 'short-input', + canonicalParamId: 'taskListId', placeholder: 'Task list ID (leave empty for default list)', + mode: 'advanced', condition: { field: 'operation', value: 'list_task_lists', not: true }, }, @@ -210,7 +225,9 @@ Return ONLY the timestamp - no explanations, no extra text.`, params: (params) => { const { oauthCredential, operation, showCompleted, maxResults, ...rest } = params - const processedParams: Record = { ...rest } + const processedParams: Record = { + ...rest, + } if (maxResults && typeof maxResults === 'string') { processedParams.maxResults = Number.parseInt(maxResults, 10) diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index e1a1ae2da2d..916f0b2bd1e 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -106,11 +106,52 @@ export const JiraServiceManagementBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, + { + id: 'serviceDeskSelector', + title: 'Service Desk', + type: 'project-selector', + canonicalParamId: 'serviceDeskId', + serviceId: 'jira', + selectorKey: 'jsm.serviceDesks', + selectorAllowSearch: false, + placeholder: 'Select service desk', + dependsOn: ['credential', 'domain'], + mode: 'basic', + required: { + field: 'operation', + value: [ + 'get_request_types', + 'create_request', + 'get_customers', + 'add_customer', + 'get_organizations', + 'add_organization', + 'get_queues', + 'get_request_type_fields', + ], + }, + condition: { + field: 'operation', + value: [ + 'get_request_types', + 'create_request', + 'get_customers', + 'add_customer', + 'get_organizations', + 'add_organization', + 'get_queues', + 'get_requests', + 'get_request_type_fields', + ], + }, + }, { id: 'serviceDeskId', title: 'Service Desk ID', type: 'short-input', + canonicalParamId: 'serviceDeskId', placeholder: 'Enter service desk ID', + mode: 'advanced', required: { field: 'operation', value: [ @@ -139,12 +180,28 @@ export const JiraServiceManagementBlock: BlockConfig = { ], }, }, + { + id: 'requestTypeSelector', + title: 'Request Type', + type: 'file-selector', + canonicalParamId: 'requestTypeId', + serviceId: 'jira', + selectorKey: 'jsm.requestTypes', + selectorAllowSearch: false, + placeholder: 'Select request type', + dependsOn: ['credential', 'domain', 'serviceDeskSelector'], + mode: 'basic', + required: true, + condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] }, + }, { id: 'requestTypeId', title: 'Request Type ID', type: 'short-input', + canonicalParamId: 'requestTypeId', required: true, placeholder: 'Enter request type ID', + mode: 'advanced', condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] }, }, { diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index a73d01b48d3..ab90c179236 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -84,12 +84,36 @@ export const MicrosoftPlannerBlock: BlockConfig = { placeholder: 'Enter credential ID', }, - // Plan ID - for various operations + // Plan selector - basic mode + { + id: 'planSelector', + title: 'Plan', + type: 'project-selector', + canonicalParamId: 'planId', + serviceId: 'microsoft-planner', + selectorKey: 'microsoft.planner.plans', + selectorAllowSearch: false, + placeholder: 'Select a plan', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'], + }, + required: { + field: 'operation', + value: ['read_plan', 'list_buckets', 'create_bucket', 'create_task'], + }, + }, + + // Plan ID - advanced mode { id: 'planId', title: 'Plan ID', type: 'short-input', + canonicalParamId: 'planId', placeholder: 'Enter the plan ID', + mode: 'advanced', condition: { field: 'operation', value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'], @@ -110,7 +134,7 @@ export const MicrosoftPlannerBlock: BlockConfig = { serviceId: 'microsoft-planner', selectorKey: 'microsoft.planner', condition: { field: 'operation', value: ['read_task'] }, - dependsOn: ['credential', 'planId'], + dependsOn: ['credential', 'planSelector'], mode: 'basic', canonicalParamId: 'readTaskId', }, diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 92727444d94..f222565fdf4 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -53,47 +53,86 @@ export const NotionBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, - // Read/Write operation - Page ID + { + id: 'pageSelector', + title: 'Page', + type: 'file-selector', + canonicalParamId: 'pageId', + serviceId: 'notion', + selectorKey: 'notion.pages', + placeholder: 'Select Notion page', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['notion_read', 'notion_write'], + }, + required: true, + }, { id: 'pageId', title: 'Page ID', type: 'short-input', + canonicalParamId: 'pageId', placeholder: 'Enter Notion page ID', + mode: 'advanced', condition: { field: 'operation', - value: 'notion_read', + value: ['notion_read', 'notion_write'], }, required: true, }, { - id: 'databaseId', - title: 'Database ID', - type: 'short-input', - placeholder: 'Enter Notion database ID', + id: 'databaseSelector', + title: 'Database', + type: 'project-selector', + canonicalParamId: 'databaseId', + serviceId: 'notion', + selectorKey: 'notion.databases', + selectorAllowSearch: false, + placeholder: 'Select Notion database', + dependsOn: ['credential'], + mode: 'basic', condition: { field: 'operation', - value: 'notion_read_database', + value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'], }, required: true, }, { - id: 'pageId', - title: 'Page ID', + id: 'databaseId', + title: 'Database ID', type: 'short-input', - placeholder: 'Enter Notion page ID', + canonicalParamId: 'databaseId', + placeholder: 'Enter Notion database ID', + mode: 'advanced', condition: { field: 'operation', - value: 'notion_write', + value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'], }, required: true, }, - // Create operation fields + { + id: 'parentSelector', + title: 'Parent Page', + type: 'file-selector', + canonicalParamId: 'parentId', + serviceId: 'notion', + selectorKey: 'notion.pages', + placeholder: 'Select parent page', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] }, + required: true, + }, { id: 'parentId', title: 'Parent Page ID', type: 'short-input', + canonicalParamId: 'parentId', placeholder: 'ID of parent page', - condition: { field: 'operation', value: 'notion_create_page' }, + mode: 'advanced', + condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] }, required: true, }, { @@ -148,14 +187,6 @@ export const NotionBlock: BlockConfig = { }, }, // Query Database Fields - { - id: 'databaseId', - title: 'Database ID', - type: 'short-input', - placeholder: 'Enter Notion database ID', - condition: { field: 'operation', value: 'notion_query_database' }, - required: true, - }, { id: 'filter', title: 'Filter', @@ -218,14 +249,6 @@ export const NotionBlock: BlockConfig = { condition: { field: 'operation', value: 'notion_search' }, }, // Create Database Fields - { - id: 'parentId', - title: 'Parent Page ID', - type: 'short-input', - placeholder: 'ID of parent page where database will be created', - condition: { field: 'operation', value: 'notion_create_database' }, - required: true, - }, { id: 'title', title: 'Database Title', @@ -256,14 +279,6 @@ export const NotionBlock: BlockConfig = { }, }, // Add Database Row Fields - { - id: 'databaseId', - title: 'Database ID', - type: 'short-input', - placeholder: 'Enter Notion database ID', - condition: { field: 'operation', value: 'notion_add_database_row' }, - required: true, - }, { id: 'properties', title: 'Row Properties', @@ -404,6 +419,7 @@ export const NotionBlock: BlockConfig = { } // V2 Block with API-aligned outputs + export const NotionV2Block: BlockConfig = { type: 'notion_v2', name: 'Notion', diff --git a/apps/sim/blocks/blocks/pipedrive.ts b/apps/sim/blocks/blocks/pipedrive.ts index 543a6d0de34..55e5c331ff4 100644 --- a/apps/sim/blocks/blocks/pipedrive.ts +++ b/apps/sim/blocks/blocks/pipedrive.ts @@ -96,12 +96,35 @@ export const PipedriveBlock: BlockConfig = { placeholder: 'Filter by organization ID', condition: { field: 'operation', value: ['get_all_deals'] }, }, + { + id: 'pipelineSelector', + title: 'Pipeline', + type: 'project-selector', + canonicalParamId: 'pipeline_id', + serviceId: 'pipedrive', + selectorKey: 'pipedrive.pipelines', + selectorAllowSearch: false, + placeholder: 'Select pipeline', + dependsOn: ['credential'], + mode: 'basic', + condition: { + field: 'operation', + value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'], + }, + required: { field: 'operation', value: 'get_pipeline_deals' }, + }, { id: 'pipeline_id', title: 'Pipeline ID', type: 'short-input', - placeholder: 'Filter by pipeline ID ', - condition: { field: 'operation', value: ['get_all_deals'] }, + canonicalParamId: 'pipeline_id', + placeholder: 'Enter pipeline ID', + mode: 'advanced', + condition: { + field: 'operation', + value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'], + }, + required: { field: 'operation', value: 'get_pipeline_deals' }, }, { id: 'updated_since', @@ -174,13 +197,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, placeholder: 'Associated organization ID ', condition: { field: 'operation', value: ['create_deal'] }, }, - { - id: 'pipeline_id', - title: 'Pipeline ID', - type: 'short-input', - placeholder: 'Pipeline ID ', - condition: { field: 'operation', value: ['create_deal'] }, - }, { id: 'stage_id', title: 'Stage ID', @@ -329,14 +345,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n ], }, }, - { - id: 'pipeline_id', - title: 'Pipeline ID', - type: 'short-input', - placeholder: 'Enter pipeline ID', - required: true, - condition: { field: 'operation', value: ['get_pipeline_deals'] }, - }, { id: 'stage_id', title: 'Stage ID', diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index af54dc2f75a..2bfc08bcd8b 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -112,12 +112,26 @@ export const SharepointBlock: BlockConfig = { mode: 'advanced', }, + { + id: 'listSelector', + title: 'List', + type: 'file-selector', + canonicalParamId: 'listId', + serviceId: 'sharepoint', + selectorKey: 'sharepoint.lists', + selectorAllowSearch: false, + placeholder: 'Select a list', + dependsOn: ['credential', 'siteSelector'], + mode: 'basic', + condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] }, + }, { id: 'listId', title: 'List ID', type: 'short-input', - placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.', canonicalParamId: 'listId', + placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.', + mode: 'advanced', condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] }, }, @@ -425,7 +439,9 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, includeColumns, includeItems, files, // canonical param from uploadFiles (basic) or files (advanced) + driveId, // canonical param from driveId columnDefinitions, + listId, ...others } = rest as any @@ -457,7 +473,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, try { logger.info('SharepointBlock list item param check', { siteId: effectiveSiteId || undefined, - listId: (others as any)?.listId, + listId: listId, listTitle: (others as any)?.listTitle, itemId: sanitizedItemId, hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object', @@ -477,6 +493,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined, mimeType: mimeType, ...others, + ...(listId ? { listId } : {}), + ...(driveId ? { driveId } : {}), itemId: sanitizedItemId, listItemFields: parsedItemFields, includeColumns: coerceBoolean(includeColumns), @@ -517,10 +535,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, includeItems: { type: 'boolean', description: 'Include items in response' }, itemId: { type: 'string', description: 'List item ID (canonical param)' }, listItemFields: { type: 'string', description: 'List item fields (canonical param)' }, - driveId: { type: 'string', description: 'Document library (drive) ID (canonical param)' }, + driveId: { + type: 'string', + description: 'Document library (drive) ID', + }, folderPath: { type: 'string', description: 'Folder path for file upload' }, fileName: { type: 'string', description: 'File name override' }, - files: { type: 'array', description: 'Files to upload (canonical param)' }, + files: { type: 'array', description: 'Files to upload' }, }, outputs: { sites: { diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 777e060fe9b..bff115ebdcf 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -59,26 +59,50 @@ export const TrelloBlock: BlockConfig = { }, { - id: 'boardId', + id: 'boardSelector', title: 'Board', - type: 'short-input', - placeholder: 'Enter board ID', + type: 'project-selector', + canonicalParamId: 'boardId', + serviceId: 'trello', + selectorKey: 'trello.boards', + selectorAllowSearch: false, + placeholder: 'Select Trello board', + dependsOn: ['credential'], + mode: 'basic', condition: { field: 'operation', - value: 'trello_list_lists', + value: [ + 'trello_list_lists', + 'trello_list_cards', + 'trello_create_card', + 'trello_get_actions', + ], + }, + required: { + field: 'operation', + value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], }, - required: true, }, { id: 'boardId', - title: 'Board', + title: 'Board ID', type: 'short-input', - placeholder: 'Enter board ID or search for a board', + canonicalParamId: 'boardId', + placeholder: 'Enter board ID', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_list_cards', + value: [ + 'trello_list_lists', + 'trello_list_cards', + 'trello_create_card', + 'trello_get_actions', + ], + }, + required: { + field: 'operation', + value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], }, - required: true, }, { id: 'listId', @@ -90,17 +114,6 @@ export const TrelloBlock: BlockConfig = { value: 'trello_list_cards', }, }, - { - id: 'boardId', - title: 'Board', - type: 'short-input', - placeholder: 'Enter board ID or search for a board', - condition: { - field: 'operation', - value: 'trello_create_card', - }, - required: true, - }, { id: 'listId', title: 'List', @@ -278,16 +291,6 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex }, }, - { - id: 'boardId', - title: 'Board ID', - type: 'short-input', - placeholder: 'Enter board ID to get board actions', - condition: { - field: 'operation', - value: 'trello_get_actions', - }, - }, { id: 'cardId', title: 'Card ID', diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index 711ab3f7681..9b74422ae2c 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -77,12 +77,39 @@ export const ZoomBlock: BlockConfig = { value: ['zoom_create_meeting', 'zoom_list_meetings', 'zoom_list_recordings'], }, }, - // Meeting ID for get/update/delete/invitation/recordings/participants operations + // Meeting selector for get/update/delete/invitation/recordings/participants operations + { + id: 'meetingSelector', + title: 'Meeting', + type: 'project-selector', + canonicalParamId: 'meetingId', + serviceId: 'zoom', + selectorKey: 'zoom.meetings', + selectorAllowSearch: true, + placeholder: 'Select Zoom meeting', + dependsOn: ['credential'], + mode: 'basic', + required: true, + condition: { + field: 'operation', + value: [ + 'zoom_get_meeting', + 'zoom_update_meeting', + 'zoom_delete_meeting', + 'zoom_get_meeting_invitation', + 'zoom_get_meeting_recordings', + 'zoom_delete_recording', + 'zoom_list_past_participants', + ], + }, + }, { id: 'meetingId', title: 'Meeting ID', type: 'short-input', + canonicalParamId: 'meetingId', placeholder: 'Enter meeting ID', + mode: 'advanced', required: true, condition: { field: 'operation', @@ -114,7 +141,6 @@ export const ZoomBlock: BlockConfig = { title: 'Topic', type: 'short-input', placeholder: 'Meeting topic (optional)', - mode: 'advanced', condition: { field: 'operation', value: ['zoom_update_meeting'], diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 6b0674b7597..db0d6b28f04 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -10,6 +10,29 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const SELECTOR_STALE = 60 * 1000 +type AirtableBase = { id: string; name: string } +type AirtableTable = { id: string; name: string } +type AsanaWorkspace = { id: string; name: string } +type AttioObject = { id: string; name: string } +type AttioList = { id: string; name: string } +type BigQueryDataset = { + datasetReference: { datasetId: string; projectId: string } + friendlyName?: string +} +type BigQueryTable = { tableReference: { tableId: string }; friendlyName?: string } +type CalcomEventType = { id: string; title: string; slug: string } +type ConfluenceSpace = { id: string; name: string; key: string } +type JsmServiceDesk = { id: string; name: string } +type JsmRequestType = { id: string; name: string } +type NotionDatabase = { id: string; name: string } +type NotionPage = { id: string; name: string } +type PipedrivePipeline = { id: string; name: string } +type ZoomMeeting = { id: string; name: string } +type CalcomSchedule = { id: string; name: string } +type GoogleTaskList = { id: string; title: string } +type PlannerPlan = { id: string; title: string } +type SharepointList = { id: string; displayName: string } +type TrelloBoard = { id: string; name: string; closed?: boolean } type SlackChannel = { id: string; name: string } type SlackUser = { id: string; name: string; real_name: string } type FolderResponse = { id: string; name: string } @@ -37,6 +60,768 @@ const ensureKnowledgeBase = (context: SelectorContext): string => { } const registry: Record = { + 'airtable.bases': { + key: 'airtable.bases', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'airtable.bases', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'airtable.bases') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ bases: AirtableBase[] }>('/api/tools/airtable/bases', { + method: 'POST', + body, + }) + return (data.bases || []).map((base) => ({ + id: base.id, + label: base.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'airtable.bases') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + baseId: detailId, + }) + const data = await fetchJson<{ bases: AirtableBase[] }>('/api/tools/airtable/bases', { + method: 'POST', + body, + }) + const base = (data.bases || []).find((b) => b.id === detailId) ?? null + if (!base) return null + return { id: base.id, label: base.name } + }, + }, + 'airtable.tables': { + key: 'airtable.tables', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'airtable.tables', + context.credentialId ?? 'none', + context.baseId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.baseId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'airtable.tables') + if (!context.baseId) { + throw new Error('Missing base ID for airtable.tables selector') + } + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + baseId: context.baseId, + }) + const data = await fetchJson<{ tables: AirtableTable[] }>('/api/tools/airtable/tables', { + method: 'POST', + body, + }) + return (data.tables || []).map((table) => ({ + id: table.id, + label: table.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'airtable.tables') + if (!context.baseId) return null + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + baseId: context.baseId, + }) + const data = await fetchJson<{ tables: AirtableTable[] }>('/api/tools/airtable/tables', { + method: 'POST', + body, + }) + const table = (data.tables || []).find((t) => t.id === detailId) ?? null + if (!table) return null + return { id: table.id, label: table.name } + }, + }, + 'asana.workspaces': { + key: 'asana.workspaces', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'asana.workspaces', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'asana.workspaces') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ workspaces: AsanaWorkspace[] }>( + '/api/tools/asana/workspaces', + { method: 'POST', body } + ) + return (data.workspaces || []).map((ws) => ({ id: ws.id, label: ws.name })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'asana.workspaces') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ workspaces: AsanaWorkspace[] }>( + '/api/tools/asana/workspaces', + { method: 'POST', body } + ) + const ws = (data.workspaces || []).find((w) => w.id === detailId) ?? null + if (!ws) return null + return { id: ws.id, label: ws.name } + }, + }, + 'attio.objects': { + key: 'attio.objects', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'attio.objects', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'attio.objects') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ objects: AttioObject[] }>('/api/tools/attio/objects', { + method: 'POST', + body, + }) + return (data.objects || []).map((obj) => ({ + id: obj.id, + label: obj.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'attio.objects') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ objects: AttioObject[] }>('/api/tools/attio/objects', { + method: 'POST', + body, + }) + const obj = (data.objects || []).find((o) => o.id === detailId) ?? null + if (!obj) return null + return { id: obj.id, label: obj.name } + }, + }, + 'attio.lists': { + key: 'attio.lists', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'attio.lists', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'attio.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ lists: AttioList[] }>('/api/tools/attio/lists', { + method: 'POST', + body, + }) + return (data.lists || []).map((list) => ({ + id: list.id, + label: list.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'attio.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ lists: AttioList[] }>('/api/tools/attio/lists', { + method: 'POST', + body, + }) + const list = (data.lists || []).find((l) => l.id === detailId) ?? null + if (!list) return null + return { id: list.id, label: list.name } + }, + }, + 'bigquery.datasets': { + key: 'bigquery.datasets', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'bigquery.datasets', + context.credentialId ?? 'none', + context.projectId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.projectId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'bigquery.datasets') + if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + }) + const data = await fetchJson<{ datasets: BigQueryDataset[] }>( + '/api/tools/google_bigquery/datasets', + { method: 'POST', body } + ) + return (data.datasets || []).map((ds) => ({ + id: ds.datasetReference.datasetId, + label: ds.friendlyName || ds.datasetReference.datasetId, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.projectId) return null + const credentialId = ensureCredential(context, 'bigquery.datasets') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + }) + const data = await fetchJson<{ datasets: BigQueryDataset[] }>( + '/api/tools/google_bigquery/datasets', + { method: 'POST', body } + ) + const ds = + (data.datasets || []).find((d) => d.datasetReference.datasetId === detailId) ?? null + if (!ds) return null + return { + id: ds.datasetReference.datasetId, + label: ds.friendlyName || ds.datasetReference.datasetId, + } + }, + }, + 'bigquery.tables': { + key: 'bigquery.tables', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'bigquery.tables', + context.credentialId ?? 'none', + context.projectId ?? 'none', + context.datasetId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.credentialId && context.projectId && context.datasetId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'bigquery.tables') + if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector') + if (!context.datasetId) throw new Error('Missing dataset ID for bigquery.tables selector') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + datasetId: context.datasetId, + }) + const data = await fetchJson<{ tables: BigQueryTable[] }>( + '/api/tools/google_bigquery/tables', + { method: 'POST', body } + ) + return (data.tables || []).map((t) => ({ + id: t.tableReference.tableId, + label: t.friendlyName || t.tableReference.tableId, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.projectId || !context.datasetId) return null + const credentialId = ensureCredential(context, 'bigquery.tables') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + projectId: context.projectId, + datasetId: context.datasetId, + }) + const data = await fetchJson<{ tables: BigQueryTable[] }>( + '/api/tools/google_bigquery/tables', + { method: 'POST', body } + ) + const t = (data.tables || []).find((tbl) => tbl.tableReference.tableId === detailId) ?? null + if (!t) return null + return { id: t.tableReference.tableId, label: t.friendlyName || t.tableReference.tableId } + }, + }, + 'calcom.eventTypes': { + key: 'calcom.eventTypes', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'calcom.eventTypes', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'calcom.eventTypes') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ eventTypes: CalcomEventType[] }>( + '/api/tools/calcom/event-types', + { method: 'POST', body } + ) + return (data.eventTypes || []).map((et) => ({ + id: et.id, + label: et.title || et.slug, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'calcom.eventTypes') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ eventTypes: CalcomEventType[] }>( + '/api/tools/calcom/event-types', + { method: 'POST', body } + ) + const et = (data.eventTypes || []).find((e) => e.id === detailId) ?? null + if (!et) return null + return { id: et.id, label: et.title || et.slug } + }, + }, + 'calcom.schedules': { + key: 'calcom.schedules', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'calcom.schedules', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'calcom.schedules') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ schedules: CalcomSchedule[] }>('/api/tools/calcom/schedules', { + method: 'POST', + body, + }) + return (data.schedules || []).map((s) => ({ + id: s.id, + label: s.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'calcom.schedules') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ schedules: CalcomSchedule[] }>('/api/tools/calcom/schedules', { + method: 'POST', + body, + }) + const s = (data.schedules || []).find((sc) => sc.id === detailId) ?? null + if (!s) return null + return { id: s.id, label: s.name } + }, + }, + 'confluence.spaces': { + key: 'confluence.spaces', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'confluence.spaces', + context.credentialId ?? 'none', + context.domain ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.domain), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'confluence.spaces') + const domain = ensureDomain(context, 'confluence.spaces') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ spaces: ConfluenceSpace[] }>( + '/api/tools/confluence/selector-spaces', + { method: 'POST', body } + ) + return (data.spaces || []).map((space) => ({ + id: space.id, + label: `${space.name} (${space.key})`, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'confluence.spaces') + const domain = ensureDomain(context, 'confluence.spaces') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ spaces: ConfluenceSpace[] }>( + '/api/tools/confluence/selector-spaces', + { method: 'POST', body } + ) + const space = (data.spaces || []).find((s) => s.id === detailId) ?? null + if (!space) return null + return { id: space.id, label: `${space.name} (${space.key})` } + }, + }, + 'jsm.serviceDesks': { + key: 'jsm.serviceDesks', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'jsm.serviceDesks', + context.credentialId ?? 'none', + context.domain ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.domain), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'jsm.serviceDesks') + const domain = ensureDomain(context, 'jsm.serviceDesks') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ serviceDesks: JsmServiceDesk[] }>( + '/api/tools/jsm/selector-servicedesks', + { method: 'POST', body } + ) + return (data.serviceDesks || []).map((sd) => ({ + id: sd.id, + label: sd.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'jsm.serviceDesks') + const domain = ensureDomain(context, 'jsm.serviceDesks') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + }) + const data = await fetchJson<{ serviceDesks: JsmServiceDesk[] }>( + '/api/tools/jsm/selector-servicedesks', + { method: 'POST', body } + ) + const sd = (data.serviceDesks || []).find((s) => s.id === detailId) ?? null + if (!sd) return null + return { id: sd.id, label: sd.name } + }, + }, + 'jsm.requestTypes': { + key: 'jsm.requestTypes', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'jsm.requestTypes', + context.credentialId ?? 'none', + context.domain ?? 'none', + context.serviceDeskId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.credentialId && context.domain && context.serviceDeskId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'jsm.requestTypes') + const domain = ensureDomain(context, 'jsm.requestTypes') + if (!context.serviceDeskId) throw new Error('Missing serviceDeskId for jsm.requestTypes') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + serviceDeskId: context.serviceDeskId, + }) + const data = await fetchJson<{ requestTypes: JsmRequestType[] }>( + '/api/tools/jsm/selector-requesttypes', + { method: 'POST', body } + ) + return (data.requestTypes || []).map((rt) => ({ + id: rt.id, + label: rt.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'jsm.requestTypes') + const domain = ensureDomain(context, 'jsm.requestTypes') + if (!context.serviceDeskId) return null + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + domain, + serviceDeskId: context.serviceDeskId, + }) + const data = await fetchJson<{ requestTypes: JsmRequestType[] }>( + '/api/tools/jsm/selector-requesttypes', + { method: 'POST', body } + ) + const rt = (data.requestTypes || []).find((r) => r.id === detailId) ?? null + if (!rt) return null + return { id: rt.id, label: rt.name } + }, + }, + 'google.tasks.lists': { + key: 'google.tasks.lists', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'google.tasks.lists', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'google.tasks.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ taskLists: GoogleTaskList[] }>( + '/api/tools/google_tasks/task-lists', + { method: 'POST', body } + ) + return (data.taskLists || []).map((tl) => ({ id: tl.id, label: tl.title })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'google.tasks.lists') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ taskLists: GoogleTaskList[] }>( + '/api/tools/google_tasks/task-lists', + { method: 'POST', body } + ) + const tl = (data.taskLists || []).find((t) => t.id === detailId) ?? null + if (!tl) return null + return { id: tl.id, label: tl.title } + }, + }, + 'microsoft.planner.plans': { + key: 'microsoft.planner.plans', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'microsoft.planner.plans', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'microsoft.planner.plans') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ plans: PlannerPlan[] }>('/api/tools/microsoft_planner/plans', { + method: 'POST', + body, + }) + return (data.plans || []).map((plan) => ({ id: plan.id, label: plan.title })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'microsoft.planner.plans') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ plans: PlannerPlan[] }>('/api/tools/microsoft_planner/plans', { + method: 'POST', + body, + }) + const plan = (data.plans || []).find((p) => p.id === detailId) ?? null + if (!plan) return null + return { id: plan.id, label: plan.title } + }, + }, + 'notion.databases': { + key: 'notion.databases', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'notion.databases', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'notion.databases') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ databases: NotionDatabase[] }>('/api/tools/notion/databases', { + method: 'POST', + body, + }) + return (data.databases || []).map((db) => ({ + id: db.id, + label: db.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'notion.databases') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ databases: NotionDatabase[] }>('/api/tools/notion/databases', { + method: 'POST', + body, + }) + const db = (data.databases || []).find((d) => d.id === detailId) ?? null + if (!db) return null + return { id: db.id, label: db.name } + }, + }, + 'notion.pages': { + key: 'notion.pages', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'notion.pages', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'notion.pages') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pages: NotionPage[] }>('/api/tools/notion/pages', { + method: 'POST', + body, + }) + return (data.pages || []).map((page) => ({ + id: page.id, + label: page.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'notion.pages') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pages: NotionPage[] }>('/api/tools/notion/pages', { + method: 'POST', + body, + }) + const page = (data.pages || []).find((p) => p.id === detailId) ?? null + if (!page) return null + return { id: page.id, label: page.name } + }, + }, + 'pipedrive.pipelines': { + key: 'pipedrive.pipelines', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'pipedrive.pipelines', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'pipedrive.pipelines') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pipelines: PipedrivePipeline[] }>( + '/api/tools/pipedrive/pipelines', + { method: 'POST', body } + ) + return (data.pipelines || []).map((p) => ({ + id: p.id, + label: p.name, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'pipedrive.pipelines') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ pipelines: PipedrivePipeline[] }>( + '/api/tools/pipedrive/pipelines', + { method: 'POST', body } + ) + const p = (data.pipelines || []).find((pl) => pl.id === detailId) ?? null + if (!p) return null + return { id: p.id, label: p.name } + }, + }, + 'sharepoint.lists': { + key: 'sharepoint.lists', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'sharepoint.lists', + context.credentialId ?? 'none', + context.siteId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId && context.siteId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'sharepoint.lists') + if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ lists: SharepointList[] }>('/api/tools/sharepoint/lists', { + method: 'POST', + body, + }) + return (data.lists || []).map((list) => ({ id: list.id, label: list.displayName })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId || !context.siteId) return null + const credentialId = ensureCredential(context, 'sharepoint.lists') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + siteId: context.siteId, + }) + const data = await fetchJson<{ lists: SharepointList[] }>('/api/tools/sharepoint/lists', { + method: 'POST', + body, + }) + const list = (data.lists || []).find((l) => l.id === detailId) ?? null + if (!list) return null + return { id: list.id, label: list.displayName } + }, + }, + 'trello.boards': { + key: 'trello.boards', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'trello.boards', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'trello.boards') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ boards: TrelloBoard[] }>('/api/tools/trello/boards', { + method: 'POST', + body, + }) + return (data.boards || []) + .filter((board) => !board.closed) + .map((board) => ({ id: board.id, label: board.name })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'trello.boards') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ boards: TrelloBoard[] }>('/api/tools/trello/boards', { + method: 'POST', + body, + }) + const board = (data.boards || []).find((b) => b.id === detailId) ?? null + if (!board) return null + return { id: board.id, label: board.name } + }, + }, + 'zoom.meetings': { + key: 'zoom.meetings', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'zoom.meetings', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'zoom.meetings') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ meetings: ZoomMeeting[] }>('/api/tools/zoom/meetings', { + method: 'POST', + body, + }) + return (data.meetings || []).map((m) => ({ + id: m.id, + label: m.name || `Meeting ${m.id}`, + })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'zoom.meetings') + const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) + const data = await fetchJson<{ meetings: ZoomMeeting[] }>('/api/tools/zoom/meetings', { + method: 'POST', + body, + }) + const meeting = (data.meetings || []).find((m) => m.id === detailId) ?? null + if (!meeting) return null + return { id: meeting.id, label: meeting.name || `Meeting ${meeting.id}` } + }, + }, 'slack.channels': { key: 'slack.channels', staleTime: SELECTOR_STALE, @@ -242,10 +1027,16 @@ const registry: Record = { ], enabled: ({ context }) => Boolean(context.credentialId), fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'sharepoint.sites') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + }) const data = await fetchJson<{ files: { id: string; name: string }[] }>( '/api/tools/sharepoint/sites', { - searchParams: { credentialId: context.credentialId }, + method: 'POST', + body, } ) return (data.files || []).map((file) => ({ @@ -253,6 +1044,24 @@ const registry: Record = { label: file.name, })) }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'sharepoint.sites') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + }) + const data = await fetchJson<{ files: { id: string; name: string }[] }>( + '/api/tools/sharepoint/sites', + { + method: 'POST', + body, + } + ) + const site = (data.files || []).find((f) => f.id === detailId) ?? null + if (!site) return null + return { id: site.id, label: site.name } + }, }, 'microsoft.planner': { key: 'microsoft.planner', @@ -265,17 +1074,37 @@ const registry: Record = { ], enabled: ({ context }) => Boolean(context.credentialId && context.planId), fetchList: async ({ context }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'microsoft.planner') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + planId: context.planId, + }) const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', { - searchParams: { - credentialId: context.credentialId, - planId: context.planId, - }, + method: 'POST', + body, }) return (data.tasks || []).map((task) => ({ id: task.id, label: task.title, })) }, + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const credentialId = ensureCredential(context, 'microsoft.planner') + const body = JSON.stringify({ + credential: credentialId, + workflowId: context.workflowId, + planId: context.planId, + }) + const data = await fetchJson<{ tasks: PlannerTask[] }>('/api/tools/microsoft_planner/tasks', { + method: 'POST', + body, + }) + const task = (data.tasks || []).find((t) => t.id === detailId) ?? null + if (!task) return null + return { id: task.id, label: task.title } + }, }, 'jira.projects': { key: 'jira.projects', diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index 9f299d99d8f..81986860adb 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -18,6 +18,10 @@ export interface SelectorResolutionArgs { siteId?: string collectionId?: string spreadsheetId?: string + fileId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string } export function resolveSelectorForSubBlock( @@ -38,6 +42,10 @@ export function resolveSelectorForSubBlock( siteId: args.siteId, collectionId: args.collectionId, spreadsheetId: args.spreadsheetId, + fileId: args.fileId, + baseId: args.baseId, + datasetId: args.datasetId, + serviceDeskId: args.serviceDeskId, mimeType: subBlock.mimeType, }, allowSearch: subBlock.selectorAllowSearch ?? true, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index b884a471911..8f8beee32e3 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -2,6 +2,26 @@ import type React from 'react' import type { QueryKey } from '@tanstack/react-query' export type SelectorKey = + | 'airtable.bases' + | 'airtable.tables' + | 'asana.workspaces' + | 'attio.lists' + | 'attio.objects' + | 'bigquery.datasets' + | 'bigquery.tables' + | 'calcom.eventTypes' + | 'calcom.schedules' + | 'confluence.spaces' + | 'google.tasks.lists' + | 'jsm.requestTypes' + | 'jsm.serviceDesks' + | 'microsoft.planner.plans' + | 'notion.databases' + | 'notion.pages' + | 'pipedrive.pipelines' + | 'sharepoint.lists' + | 'trello.boards' + | 'zoom.meetings' | 'slack.channels' | 'slack.users' | 'gmail.labels' @@ -54,6 +74,9 @@ export interface SelectorContext { collectionId?: string spreadsheetId?: string excludeWorkflowId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 85a2aab98fd..4ed0770cdc7 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' +import { isEnvVarReference, isReference } from '@/executor/constants' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types' @@ -35,8 +36,10 @@ export function useSelectorOptionDetail( context: args.context, detailId: args.detailId, } + const hasRealDetailId = + Boolean(args.detailId) && !isReference(args.detailId!) && !isEnvVarReference(args.detailId!) const baseEnabled = - Boolean(args.detailId) && definition.fetchById !== undefined + hasRealDetailId && definition.fetchById !== undefined ? definition.enabled ? definition.enabled(queryArgs) : true diff --git a/apps/sim/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts index 91d6f7f8172..24d2fe51ebe 100644 --- a/apps/sim/hooks/use-selector-display-name.ts +++ b/apps/sim/hooks/use-selector-display-name.ts @@ -18,6 +18,13 @@ interface SelectorDisplayNameArgs { planId?: string teamId?: string knowledgeBaseId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string + siteId?: string + collectionId?: string + spreadsheetId?: string + fileId?: string } export function useSelectorDisplayName({ @@ -30,6 +37,13 @@ export function useSelectorDisplayName({ planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, }: SelectorDisplayNameArgs) { const detailId = typeof value === 'string' && value.length > 0 ? value : undefined @@ -43,6 +57,13 @@ export function useSelectorDisplayName({ planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, }) }, [ subBlock, @@ -54,6 +75,13 @@ export function useSelectorDisplayName({ planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, ]) const key = resolution?.key diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index cd005277ba1..a62bd657218 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -553,6 +553,51 @@ export function validateMicrosoftGraphId( return { isValid: true, sanitized: value } } +/** + * Validates SharePoint site IDs used in Microsoft Graph API. + * + * Site IDs are compound identifiers: `hostname,spsite-guid,spweb-guid` + * (e.g. `contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE`). + * The API also accepts partial forms like a single GUID or just a hostname. + * + * Allowed characters: alphanumeric, periods, hyphens, and commas. + * + * @param value - The SharePoint site ID to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + */ +export function validateSharePointSiteId( + value: string | null | undefined, + paramName = 'siteId' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { + isValid: false, + error: `${paramName} is required`, + } + } + + if (value.length > 512) { + return { + isValid: false, + error: `${paramName} exceeds maximum length`, + } + } + + if (!/^[a-zA-Z0-9.\-,]+$/.test(value)) { + logger.warn('Invalid characters in SharePoint site ID', { + paramName, + value: value.substring(0, 100), + }) + return { + isValid: false, + error: `${paramName} contains invalid characters`, + } + } + + return { isValid: true, sanitized: value } +} + /** * Validates Jira Cloud IDs (typically UUID format) * diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 4912654023d..9a041e7cc9c 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -52,6 +52,9 @@ interface ExtendedSelectorContext { siteId?: string collectionId?: string spreadsheetId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string } function getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string { @@ -163,6 +166,9 @@ async function resolveSelectorValue( siteId: extendedContext.siteId, collectionId: extendedContext.collectionId, spreadsheetId: extendedContext.spreadsheetId, + baseId: extendedContext.baseId, + datasetId: extendedContext.datasetId, + serviceDeskId: extendedContext.serviceDeskId, } if (definition.fetchById) { @@ -240,6 +246,9 @@ function extractExtendedContext( siteId: getStringValue('siteId'), collectionId: getStringValue('collectionId'), spreadsheetId: getStringValue('spreadsheetId') || getStringValue('fileId'), + baseId: getStringValue('baseId') || getStringValue('baseSelector'), + datasetId: getStringValue('datasetId') || getStringValue('datasetSelector'), + serviceDeskId: getStringValue('serviceDeskId') || getStringValue('serviceDeskSelector'), } } @@ -313,6 +322,9 @@ export async function resolveValueForDisplay( siteId: extendedContext.siteId, collectionId: extendedContext.collectionId, spreadsheetId: extendedContext.spreadsheetId, + baseId: extendedContext.baseId, + datasetId: extendedContext.datasetId, + serviceDeskId: extendedContext.serviceDeskId, }) if (resolution?.key) { From f1efc598d13e5da340f003a2d7746853817fef79 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 15:53:00 -0800 Subject: [PATCH 4/9] fix(selectors): resolve env var references at design time for selector context (#3446) * fix(selectors): resolve env var references at design time for selector context Selectors now resolve {{ENV_VAR}} references before building context and returning dependency values to consumers, enabling env-var-based credentials (e.g. {{SLACK_BOT_TOKEN}}) to work with selector dropdowns. Co-Authored-By: Claude Opus 4.6 * fix(selectors): prevent unresolved env var templates from leaking into context - Fall back to undefined instead of raw template string when env var is missing from store, so the null-check in the context loop discards it - Use resolvedDetailId in query cache key so React Query refetches when the underlying env var value changes Co-Authored-By: Claude Opus 4.6 * fix(selectors): use || for consistent empty-string env var handling Align use-selector-setup.ts with use-selector-query.ts by using || instead of ?? so empty-string env var values are treated as unset. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../sub-block/hooks/use-selector-setup.ts | 31 ++++++++++++++++--- .../sim/hooks/selectors/use-selector-query.ts | 22 ++++++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index e9acaca7dcb..cdbf1e17b62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -3,8 +3,9 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' import type { SubBlockConfig } from '@/blocks/types' -import { isEnvVarReference, isReference } from '@/executor/constants' +import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' +import { useEnvironmentStore } from '@/stores/settings/environment' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useDependsOnGate } from './use-depends-on-gate' @@ -30,23 +31,43 @@ export function useSelectorSetup( const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const workflowId = (params?.workflowId as string) || activeWorkflowId || '' + const envVariables = useEnvironmentStore((s) => s.variables) + const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate( blockId, subBlock, opts ) + const resolvedDependencyValues = useMemo(() => { + const resolved: Record = {} + for (const [key, value] of Object.entries(dependencyValues)) { + if (value === null || value === undefined) { + resolved[key] = value + continue + } + const str = String(value) + if (isEnvVarReference(str)) { + const varName = extractEnvVarName(str) + resolved[key] = envVariables[varName]?.value || undefined + } else { + resolved[key] = value + } + } + return resolved + }, [dependencyValues, envVariables]) + const selectorContext = useMemo(() => { const context: SelectorContext = { workflowId, mimeType: subBlock.mimeType, } - for (const [depKey, value] of Object.entries(dependencyValues)) { + for (const [depKey, value] of Object.entries(resolvedDependencyValues)) { if (value === null || value === undefined) continue const strValue = String(value) if (!strValue) continue - if (isReference(strValue) || isEnvVarReference(strValue)) continue + if (isReference(strValue)) continue const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey @@ -58,14 +79,14 @@ export function useSelectorSetup( } return context - }, [dependencyValues, canonicalIndex, workflowId, subBlock.mimeType]) + }, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType]) return { selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null, selectorContext, allowSearch: subBlock.selectorAllowSearch ?? true, disabled: finalDisabled || !subBlock.selectorKey, - dependencyValues, + dependencyValues: resolvedDependencyValues, } } diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 4ed0770cdc7..6486e769773 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,8 +1,9 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' -import { isEnvVarReference, isReference } from '@/executor/constants' +import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types' +import { useEnvironmentStore } from '@/stores/settings/environment' interface SelectorHookArgs extends Omit { search?: string @@ -30,14 +31,25 @@ export function useSelectorOptionDetail( key: SelectorKey, args: SelectorHookArgs & { detailId?: string } ) { + const envVariables = useEnvironmentStore((s) => s.variables) const definition = getSelectorDefinition(key) + + const resolvedDetailId = useMemo(() => { + if (!args.detailId) return undefined + if (isReference(args.detailId)) return undefined + if (isEnvVarReference(args.detailId)) { + const varName = extractEnvVarName(args.detailId) + return envVariables[varName]?.value || undefined + } + return args.detailId + }, [args.detailId, envVariables]) + const queryArgs: SelectorQueryArgs = { key, context: args.context, - detailId: args.detailId, + detailId: resolvedDetailId, } - const hasRealDetailId = - Boolean(args.detailId) && !isReference(args.detailId!) && !isEnvVarReference(args.detailId!) + const hasRealDetailId = Boolean(resolvedDetailId) const baseEnabled = hasRealDetailId && definition.fetchById !== undefined ? definition.enabled @@ -47,7 +59,7 @@ export function useSelectorOptionDetail( const enabled = args.enabled ?? baseEnabled const query = useQuery({ - queryKey: [...definition.getQueryKey(queryArgs), 'detail', args.detailId ?? 'none'], + queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], queryFn: () => definition.fetchById!(queryArgs), enabled, staleTime: definition.staleTime ?? 300_000, From a4d581c76f6481dc1176e1c25c7730537a1c4b5e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 6 Mar 2026 16:17:14 -0800 Subject: [PATCH 5/9] improvement(canonical): backfill for canonical modes on config changes (#3447) * improvement(canonical): backfill for canonical modes on config changes * persist data changes to db --- .../migrations/subblock-migrations.test.ts | 189 +++++++++++++++++- .../migrations/subblock-migrations.ts | 63 ++++++ apps/sim/lib/workflows/persistence/utils.ts | 18 +- 3 files changed, 265 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 47b0f26084f..fbd9f52f7a1 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -1,9 +1,12 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { BlockState } from '@/stores/workflows/workflow/types' -import { migrateSubblockIds } from './subblock-migrations' + +vi.unmock('@/blocks/registry') + +import { backfillCanonicalModes, migrateSubblockIds } from './subblock-migrations' function makeBlock(overrides: Partial & { type: string }): BlockState { return { @@ -181,3 +184,185 @@ describe('migrateSubblockIds', () => { expect(migrated).toBe(false) }) }) + +describe('backfillCanonicalModes', () => { + it('should add missing canonicalModes entry for knowledge block with basic value', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: 'kb-uuid', + }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.knowledgeBaseId).toBe('basic') + }) + + it('should resolve to advanced when only the advanced value is set', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + manualKnowledgeBaseId: { + id: 'manualKnowledgeBaseId', + type: 'short-input', + value: 'kb-uuid-manual', + }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.knowledgeBaseId).toBe('advanced') + }) + + it('should not overwrite existing canonicalModes entries', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: { canonicalModes: { knowledgeBaseId: 'advanced' } }, + subBlocks: { + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: 'kb-uuid', + }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(false) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.knowledgeBaseId).toBe('advanced') + }) + + it('should skip blocks with no canonical pairs in their config', () => { + const input: Record = { + b1: makeBlock({ + type: 'function', + data: {}, + subBlocks: { + code: { id: 'code', type: 'code', value: '' }, + }, + }), + } + + const { migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(false) + }) + + it('should not mutate the input blocks', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: 'kb-uuid', + }, + }, + }), + } + + const { blocks } = backfillCanonicalModes(input) + + expect(input.b1.data?.canonicalModes).toBeUndefined() + expect((blocks.b1.data?.canonicalModes as Record).knowledgeBaseId).toBe('basic') + expect(blocks).not.toBe(input) + }) + + it('should resolve correctly when existing field became the basic variant', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: 'kb-uuid', + }, + manualKnowledgeBaseId: { + id: 'manualKnowledgeBaseId', + type: 'short-input', + value: '', + }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.knowledgeBaseId).toBe('basic') + }) + + it('should resolve correctly when existing field became the advanced variant', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: '', + }, + manualKnowledgeBaseId: { + id: 'manualKnowledgeBaseId', + type: 'short-input', + value: 'manually-entered-kb-id', + }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.knowledgeBaseId).toBe('advanced') + }) + + it('should default to basic when neither value is set', () => { + const input: Record = { + b1: makeBlock({ + type: 'knowledge', + data: {}, + subBlocks: { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + const modes = blocks.b1.data?.canonicalModes as Record + expect(modes.knowledgeBaseId).toBe('basic') + }) +}) diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index fffdfdc9b60..ffe6f57885d 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -1,4 +1,11 @@ import { createLogger } from '@sim/logger' +import { + buildCanonicalIndex, + buildSubBlockValues, + isCanonicalPair, + resolveCanonicalMode, +} from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks' import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('SubblockMigrations') @@ -88,3 +95,59 @@ export function migrateSubblockIds(blocks: Record): { return { blocks: result, migrated: anyMigrated } } + +/** + * Backfills missing `canonicalModes` entries in block data. + * + * When a canonical pair is added to a block definition, existing blocks + * won't have the entry in `data.canonicalModes`. Without it the editor + * toggle may not render correctly. This resolves the correct mode based + * on which subblock value is populated and adds the missing entry. + */ +export function backfillCanonicalModes(blocks: Record): { + blocks: Record + migrated: boolean +} { + let anyMigrated = false + const result: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const blockConfig = getBlock(block.type) + if (!blockConfig?.subBlocks || !block.subBlocks) { + result[blockId] = block + continue + } + + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const pairs = Object.values(canonicalIndex.groupsById).filter(isCanonicalPair) + if (pairs.length === 0) { + result[blockId] = block + continue + } + + const existing = (block.data?.canonicalModes ?? {}) as Record + let patched: Record | null = null + + const values = buildSubBlockValues(block.subBlocks) + + for (const group of pairs) { + if (existing[group.canonicalId] != null) continue + + const resolved = resolveCanonicalMode(group, values) + if (!patched) patched = { ...existing } + patched[group.canonicalId] = resolved + } + + if (patched) { + anyMigrated = true + result[blockId] = { + ...block, + data: { ...(block.data ?? {}), canonicalModes: patched }, + } + } else { + result[blockId] = block + } + } + + return { blocks: result, migrated: anyMigrated } +} diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index c94883d6539..fa6a9bb5171 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -14,7 +14,10 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' import type { DbOrTx } from '@/lib/db/types' -import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' +import { + backfillCanonicalModes, + migrateSubblockIds, +} from '@/lib/workflows/migrations/subblock-migrations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' @@ -176,6 +179,11 @@ const applyBlockMigrations = createMigrationPipeline([ const { blocks, migrated } = migrateSubblockIds(ctx.blocks) return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, + + (ctx) => { + const { blocks, migrated } = backfillCanonicalModes(ctx.blocks) + return { ...ctx, blocks, migrated: ctx.migrated || migrated } + }, ]) /** @@ -410,10 +418,14 @@ export async function loadWorkflowFromNormalizedTables( Promise.resolve().then(async () => { try { for (const [blockId, block] of Object.entries(finalBlocks)) { - if (block.subBlocks !== blocksMap[blockId]?.subBlocks) { + if (block !== blocksMap[blockId]) { await db .update(workflowBlocks) - .set({ subBlocks: block.subBlocks, updatedAt: new Date() }) + .set({ + subBlocks: block.subBlocks, + data: block.data, + updatedAt: new Date(), + }) .where( and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)) ) From a71304200e0463e7212b08ac263952e085719287 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 17:08:25 -0800 Subject: [PATCH 6/9] improvement(oauth): centralize scopes and remove dead scope evaluation code (#3449) * improvement(oauth): centralize scopes and remove dead scope evaluation code Co-Authored-By: Claude Opus 4.6 * fix(oauth): fix stale scope-descriptions.ts references and add test coverage Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .claude/commands/add-block.md | 13 +- .claude/commands/add-integration.md | 31 +- .claude/commands/validate-integration.md | 22 +- .../api/auth/oauth/connections/route.test.ts | 70 +-- .../app/api/auth/oauth/connections/route.ts | 29 +- .../api/auth/oauth/credentials/route.test.ts | 17 +- .../app/api/auth/oauth/credentials/route.ts | 10 +- .../components/oauth-required-modal.tsx | 313 +----------- .../credential-selector.tsx | 2 +- .../components/tools/credential-selector.tsx | 2 +- apps/sim/blocks/blocks/agent.ts | 3 +- apps/sim/blocks/blocks/airtable.ts | 9 +- apps/sim/blocks/blocks/asana.ts | 3 +- apps/sim/blocks/blocks/confluence.ts | 72 +-- apps/sim/blocks/blocks/dropbox.ts | 11 +- apps/sim/blocks/blocks/gmail.ts | 15 +- apps/sim/blocks/blocks/google_bigquery.ts | 3 +- apps/sim/blocks/blocks/google_calendar.ts | 7 +- apps/sim/blocks/blocks/google_contacts.ts | 3 +- apps/sim/blocks/blocks/google_docs.ts | 6 +- apps/sim/blocks/blocks/google_drive.ts | 71 +-- apps/sim/blocks/blocks/google_forms.ts | 9 +- apps/sim/blocks/blocks/google_groups.ts | 6 +- apps/sim/blocks/blocks/google_meet.ts | 6 +- apps/sim/blocks/blocks/google_sheets.ts | 21 +- apps/sim/blocks/blocks/google_slides.ts | 6 +- apps/sim/blocks/blocks/google_tasks.ts | 3 +- apps/sim/blocks/blocks/google_vault.ts | 6 +- apps/sim/blocks/blocks/hubspot.ts | 27 +- apps/sim/blocks/blocks/jira.ts | 34 +- .../blocks/blocks/jira_service_management.ts | 38 +- apps/sim/blocks/blocks/linear.ts | 3 +- apps/sim/blocks/blocks/linkedin.ts | 3 +- apps/sim/blocks/blocks/microsoft_dataverse.ts | 9 +- apps/sim/blocks/blocks/microsoft_excel.ts | 19 +- apps/sim/blocks/blocks/microsoft_planner.ts | 11 +- apps/sim/blocks/blocks/microsoft_teams.ts | 24 +- apps/sim/blocks/blocks/onedrive.ts | 55 +-- apps/sim/blocks/blocks/outlook.ts | 18 +- apps/sim/blocks/blocks/pipedrive.ts | 11 +- apps/sim/blocks/blocks/reddit.ts | 20 +- apps/sim/blocks/blocks/salesforce.ts | 3 +- apps/sim/blocks/blocks/sharepoint.ts | 20 +- apps/sim/blocks/blocks/shopify.ts | 10 +- apps/sim/blocks/blocks/slack.ts | 18 +- apps/sim/blocks/blocks/trello.ts | 3 +- apps/sim/blocks/blocks/wealthbox.ts | 5 +- apps/sim/blocks/blocks/webflow.ts | 3 +- apps/sim/blocks/blocks/wordpress.ts | 3 +- apps/sim/blocks/blocks/x.ts | 19 +- apps/sim/blocks/blocks/zoom.ts | 15 +- apps/sim/hooks/use-oauth-scope-status.ts | 92 ---- apps/sim/lib/auth/auth.ts | 430 ++-------------- apps/sim/lib/oauth/oauth.ts | 46 +- apps/sim/lib/oauth/types.ts | 12 - apps/sim/lib/oauth/utils.test.ts | 315 +++++------- apps/sim/lib/oauth/utils.ts | 466 +++++++++++++++++- 57 files changed, 841 insertions(+), 1660 deletions(-) delete mode 100644 apps/sim/hooks/use-oauth-scope-status.ts diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 1765f7732fb..6d51062660d 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -20,6 +20,7 @@ When the user asks you to create a block: import { {ServiceName}Icon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { getScopesForService } from '@/lib/oauth/utils' export const {ServiceName}Block: BlockConfig = { type: '{service}', // snake_case identifier @@ -115,12 +116,17 @@ export const {ServiceName}Block: BlockConfig = { id: 'credential', title: 'Account', type: 'oauth-input', - serviceId: '{service}', // Must match OAuth provider + serviceId: '{service}', // Must match OAuth provider service key + requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils placeholder: 'Select account', required: true, } ``` +**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`. + +**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) @@ -624,6 +630,7 @@ export const registry: Record = { import { ServiceIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { getScopesForService } from '@/lib/oauth/utils' export const ServiceBlock: BlockConfig = { type: 'service', @@ -654,6 +661,7 @@ export const ServiceBlock: BlockConfig = { title: 'Service Account', type: 'oauth-input', serviceId: 'service', + requiredScopes: getScopesForService('service'), placeholder: 'Select account', required: true, }, @@ -792,7 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU - [ ] Conditions use correct syntax (field, value, not, and) - [ ] DependsOn set for fields that need other values - [ ] Required fields marked correctly (boolean or condition) -- [ ] OAuth inputs have correct `serviceId` +- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)` +- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes - [ ] Tools.access lists all tool IDs (snake_case) - [ ] Tools.config.tool returns correct tool ID (snake_case) - [ ] Outputs match tool outputs diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index aaa4cb857ce..c990f39a7b2 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -114,6 +114,7 @@ export const {service}{Action}Tool: ToolConfig = { import { {Service}Icon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import { getScopesForService } from '@/lib/oauth/utils' export const {Service}Block: BlockConfig = { type: '{service}', @@ -144,6 +145,7 @@ export const {Service}Block: BlockConfig = { title: '{Service} Account', type: 'oauth-input', serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), required: true, }, // Conditional fields per operation @@ -409,7 +411,7 @@ If creating V2 versions (API-aligned outputs): ### Block - [ ] Created `blocks/blocks/{service}.ts` - [ ] Defined operation dropdown with all operations -- [ ] Added credential field (oauth-input or short-input) +- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')` - [ ] Added conditional fields per operation - [ ] Set up dependsOn for cascading selectors - [ ] Configured tools.access with all tool IDs @@ -419,6 +421,12 @@ If creating V2 versions (API-aligned outputs): - [ ] If triggers: set `triggers.enabled` and `triggers.available` - [ ] If triggers: spread trigger subBlocks with `getTrigger()` +### OAuth Scopes (if OAuth service) +- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS` +- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` +- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode) +- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode) + ### Icon - [ ] Asked user to provide SVG - [ ] Added icon to `components/icons.tsx` @@ -717,6 +725,25 @@ Use `wandConfig` for fields that are hard to fill out manually: } ``` +### OAuth Scopes (Centralized System) + +Scopes are maintained in a single source of truth and reused everywhere: + +1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes` +2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI +3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils` +4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils` + +**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source. + +```typescript +// In auth.ts (Better Auth config) +scopes: getCanonicalScopesForProvider('{service}'), + +// In block credential sub-block +requiredScopes: getScopesForService('{service}'), +``` + ### Common Gotchas 1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration @@ -729,3 +756,5 @@ Use `wandConfig` for fields that are hard to fill out manually: 8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility 9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields 10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled +11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts +12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md index 77091b2b9dd..e36cefd319b 100644 --- a/.claude/commands/validate-integration.md +++ b/.claude/commands/validate-integration.md @@ -26,8 +26,9 @@ apps/sim/blocks/blocks/{service}.ts # Block definition apps/sim/tools/registry.ts # Tool registry entries for this service apps/sim/blocks/registry.ts # Block registry entry for this service apps/sim/components/icons.tsx # Icon definition -apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service) -apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service) +apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider() +apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes +apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI ``` ## Step 2: Pull API Documentation @@ -199,11 +200,14 @@ For **each tool** in `tools.access`: ## Step 5: Validate OAuth Scopes (if OAuth service) -- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration -- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes -- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes +Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`. + +- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes` +- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array +- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array +- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions) +- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` - [ ] No excess scopes that aren't needed by any tool -- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS` ## Step 6: Validate Pagination Consistency @@ -244,7 +248,8 @@ Group findings by severity: - Missing `.trim()` on ID fields in request URLs - Missing `?? null` on nullable response fields - Block condition array missing an operation that uses that field -- Missing scope description in `oauth-required-modal.tsx` +- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()` +- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` **Suggestion** (minor improvements): - Better description text @@ -273,7 +278,8 @@ After fixing, confirm: - [ ] Validated wandConfig on timestamps and complex inputs - [ ] Validated tools.config mapping, tool selector, and type coercions - [ ] Validated block outputs match what tools return, with typed JSON where possible -- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth) +- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays +- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes - [ ] Validated pagination consistency across tools and block - [ ] Validated error handling (error checks, meaningful messages) - [ ] Validated registry entries (tools and block, alphabetical, correct imports) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index eab4ecbc32c..dc6e165a261 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -6,40 +6,33 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockDb, - mockLogger, - mockParseProvider, - mockEvaluateScopeCoverage, - mockJwtDecode, - mockEq, -} = vi.hoisted(() => { - const db = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), +const { mockGetSession, mockDb, mockLogger, mockParseProvider, mockJwtDecode, mockEq } = vi.hoisted( + () => { + const db = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn(), + } + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + } + return { + mockGetSession: vi.fn(), + mockDb: db, + mockLogger: logger, + mockParseProvider: vi.fn(), + mockJwtDecode: vi.fn(), + mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + } } - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - trace: vi.fn(), - fatal: vi.fn(), - child: vi.fn(), - } - return { - mockGetSession: vi.fn(), - mockDb: db, - mockLogger: logger, - mockParseProvider: vi.fn(), - mockEvaluateScopeCoverage: vi.fn(), - mockJwtDecode: vi.fn(), - mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - } -}) +) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, @@ -66,7 +59,6 @@ vi.mock('@sim/logger', () => ({ vi.mock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, - evaluateScopeCoverage: mockEvaluateScopeCoverage, })) import { GET } from '@/app/api/auth/oauth/connections/route' @@ -83,16 +75,6 @@ describe('OAuth Connections API Route', () => { baseProvider: providerId.split('-')[0] || providerId, featureType: providerId.split('-')[1] || 'default', })) - - mockEvaluateScopeCoverage.mockImplementation( - (_providerId: string, _grantedScopes: string[]) => ({ - canonicalScopes: ['email', 'profile'], - grantedScopes: ['email', 'profile'], - missingScopes: [], - extraScopes: [], - requiresReauthorization: false, - }) - ) }) it('should return connections successfully', async () => { diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 148f4b20f20..3ef7e89b342 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import type { OAuthProvider } from '@/lib/oauth' -import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth' +import { parseProvider } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsAPI') @@ -49,8 +49,7 @@ export async function GET(request: NextRequest) { for (const acc of accounts) { const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider) - const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : [] - const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes) + const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : [] if (baseProvider) { // Try multiple methods to get a user-friendly display name @@ -96,10 +95,6 @@ export async function GET(request: NextRequest) { const accountSummary = { id: acc.id, name: displayName, - scopes: scopeEvaluation.grantedScopes, - missingScopes: scopeEvaluation.missingScopes, - extraScopes: scopeEvaluation.extraScopes, - requiresReauthorization: scopeEvaluation.requiresReauthorization, } if (existingConnection) { @@ -108,20 +103,8 @@ export async function GET(request: NextRequest) { existingConnection.accounts.push(accountSummary) existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes]) + new Set([...(existingConnection.scopes || []), ...scopes]) ) - existingConnection.missingScopes = Array.from( - new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes]) - ) - existingConnection.extraScopes = Array.from( - new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes]) - ) - existingConnection.canonicalScopes = - existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0 - ? existingConnection.canonicalScopes - : scopeEvaluation.canonicalScopes - existingConnection.requiresReauthorization = - existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization const existingTimestamp = existingConnection.lastConnected ? new Date(existingConnection.lastConnected).getTime() @@ -138,11 +121,7 @@ export async function GET(request: NextRequest) { baseProvider, featureType, isConnected: true, - scopes: scopeEvaluation.grantedScopes, - canonicalScopes: scopeEvaluation.canonicalScopes, - missingScopes: scopeEvaluation.missingScopes, - extraScopes: scopeEvaluation.extraScopes, - requiresReauthorization: scopeEvaluation.requiresReauthorization, + scopes, lastConnected: acc.updatedAt.toISOString(), accounts: [accountSummary], }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index bfae3a81789..9170ec8b974 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -7,7 +7,7 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => { +const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => { const logger = { info: vi.fn(), warn: vi.fn(), @@ -19,7 +19,6 @@ const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } } return { mockCheckSessionOrInternalAuth: vi.fn(), - mockEvaluateScopeCoverage: vi.fn(), mockLogger: logger, } }) @@ -28,10 +27,6 @@ vi.mock('@/lib/auth/hybrid', () => ({ checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, })) -vi.mock('@/lib/oauth', () => ({ - evaluateScopeCoverage: mockEvaluateScopeCoverage, -})) - vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('mock-request-id'), })) @@ -87,16 +82,6 @@ describe('OAuth Credentials API Route', () => { beforeEach(() => { vi.clearAllMocks() - - mockEvaluateScopeCoverage.mockImplementation( - (_providerId: string, grantedScopes: string[]) => ({ - canonicalScopes: grantedScopes, - grantedScopes, - missingScopes: [], - extraScopes: [], - requiresReauthorization: false, - }) - ) }) it('should handle unauthenticated user', async () => { diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index deb2100b568..6b096803b91 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -7,7 +7,6 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' -import { evaluateScopeCoverage } from '@/lib/oauth' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -39,8 +38,7 @@ function toCredentialResponse( scope: string | null ) { const storedScope = scope?.trim() - const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : [] - const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes) + const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : [] const [_, featureType = 'default'] = providerId.split('-') return { @@ -49,11 +47,7 @@ function toCredentialResponse( provider: providerId, lastUsed: updatedAt.toISOString(), isDefault: featureType === 'default', - scopes: scopeEvaluation.grantedScopes, - canonicalScopes: scopeEvaluation.canonicalScopes, - missingScopes: scopeEvaluation.missingScopes, - extraScopes: scopeEvaluation.extraScopes, - requiresReauthorization: scopeEvaluation.requiresReauthorization, + scopes, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 9aa2451e37b..d9bdcd6f44c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -15,6 +15,7 @@ import { import { client } from '@/lib/auth/auth-client' import { getProviderIdFromServiceId, + getScopeDescription, OAUTH_PROVIDERS, type OAuthProvider, parseProvider, @@ -33,318 +34,6 @@ export interface OAuthRequiredModalProps { onConnect?: () => Promise | void } -const SCOPE_DESCRIPTIONS: Record = { - 'https://www.googleapis.com/auth/gmail.send': 'Send emails', - 'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels', - 'https://www.googleapis.com/auth/gmail.modify': 'View and manage email messages', - 'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files', - 'https://www.googleapis.com/auth/drive': 'Access all Google Drive files', - 'https://www.googleapis.com/auth/calendar': 'View and manage calendar', - 'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts', - 'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks', - 'https://www.googleapis.com/auth/userinfo.email': 'View email address', - 'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info', - 'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms', - 'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms', - 'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery', - 'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery', - 'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage', - 'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups', - 'https://www.googleapis.com/auth/admin.directory.group.member': - 'Manage Google Workspace group memberships', - 'https://www.googleapis.com/auth/admin.directory.group.readonly': 'View Google Workspace groups', - 'https://www.googleapis.com/auth/admin.directory.group.member.readonly': - 'View Google Workspace group memberships', - 'https://www.googleapis.com/auth/meetings.space.created': - 'Create and manage Google Meet meeting spaces', - 'https://www.googleapis.com/auth/meetings.space.readonly': - 'View Google Meet meeting space details', - 'https://www.googleapis.com/auth/cloud-platform': - 'Full access to Google Cloud resources for Vertex AI', - 'read:confluence-content.all': 'Read all Confluence content', - 'read:confluence-space.summary': 'Read Confluence space information', - 'read:space:confluence': 'View Confluence spaces', - 'read:space-details:confluence': 'View detailed Confluence space information', - 'write:confluence-content': 'Create and edit Confluence pages', - 'write:confluence-space': 'Manage Confluence spaces', - 'write:confluence-file': 'Upload files to Confluence', - 'read:content:confluence': 'Read Confluence content', - 'read:page:confluence': 'View Confluence pages', - 'write:page:confluence': 'Create and update Confluence pages', - 'read:comment:confluence': 'View comments on Confluence pages', - 'write:comment:confluence': 'Create and update comments', - 'delete:comment:confluence': 'Delete comments from Confluence pages', - 'read:attachment:confluence': 'View attachments on Confluence pages', - 'write:attachment:confluence': 'Upload and manage attachments', - 'delete:attachment:confluence': 'Delete attachments from Confluence pages', - 'delete:page:confluence': 'Delete Confluence pages', - 'read:label:confluence': 'View labels on Confluence content', - 'write:label:confluence': 'Add and remove labels', - 'search:confluence': 'Search Confluence content', - 'readonly:content.attachment:confluence': 'View attachments', - 'read:blogpost:confluence': 'View Confluence blog posts', - 'write:blogpost:confluence': 'Create and update Confluence blog posts', - 'read:content.property:confluence': 'View properties on Confluence content', - 'write:content.property:confluence': 'Create and manage content properties', - 'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)', - 'read:content.metadata:confluence': 'View content metadata (required for ancestors)', - 'read:user:confluence': 'View Confluence user profiles', - 'read:task:confluence': 'View Confluence inline tasks', - 'write:task:confluence': 'Update Confluence inline tasks', - 'delete:blogpost:confluence': 'Delete Confluence blog posts', - 'write:space:confluence': 'Create and update Confluence spaces', - 'delete:space:confluence': 'Delete Confluence spaces', - 'read:space.property:confluence': 'View Confluence space properties', - 'write:space.property:confluence': 'Create and manage space properties', - 'read:space.permission:confluence': 'View Confluence space permissions', - 'read:me': 'Read profile information', - 'database.read': 'Read database', - 'database.write': 'Write to database', - 'projects.read': 'Read projects', - offline_access: 'Access account when not using the application', - repo: 'Access repositories', - workflow: 'Manage repository workflows', - 'read:user': 'Read public user information', - 'user:email': 'Access email address', - 'tweet.read': 'Read tweets and timeline', - 'tweet.write': 'Post and delete tweets', - 'tweet.moderate.write': 'Hide and unhide replies to tweets', - 'users.read': 'Read user profiles and account information', - 'follows.read': 'View followers and following lists', - 'follows.write': 'Follow and unfollow users', - 'bookmark.read': 'View bookmarked tweets', - 'bookmark.write': 'Add and remove bookmarks', - 'like.read': 'View liked tweets and liking users', - 'like.write': 'Like and unlike tweets', - 'block.read': 'View blocked users', - 'block.write': 'Block and unblock users', - 'mute.read': 'View muted users', - 'mute.write': 'Mute and unmute users', - 'offline.access': 'Access account when not using the application', - 'data.records:read': 'Read records', - 'data.records:write': 'Write to records', - 'schema.bases:read': 'View bases and tables', - 'webhook:manage': 'Manage webhooks', - 'page.read': 'Read Notion pages', - 'page.write': 'Write to Notion pages', - 'workspace.content': 'Read Notion content', - 'workspace.name': 'Read Notion workspace name', - 'workspace.read': 'Read Notion workspace', - 'workspace.write': 'Write to Notion workspace', - 'user.email:read': 'Read email address', - 'read:jira-user': 'Read Jira user', - 'read:jira-work': 'Read Jira work', - 'write:jira-work': 'Write to Jira work', - 'manage:jira-webhook': 'Register and manage Jira webhooks', - 'read:webhook:jira': 'View Jira webhooks', - 'write:webhook:jira': 'Create and update Jira webhooks', - 'delete:webhook:jira': 'Delete Jira webhooks', - 'read:issue-event:jira': 'Read Jira issue events', - 'write:issue:jira': 'Write to Jira issues', - 'read:project:jira': 'Read Jira projects', - 'read:issue-type:jira': 'Read Jira issue types', - 'read:issue-meta:jira': 'Read Jira issue meta', - 'read:issue-security-level:jira': 'Read Jira issue security level', - 'read:issue.vote:jira': 'Read Jira issue votes', - 'read:issue.changelog:jira': 'Read Jira issue changelog', - 'read:avatar:jira': 'Read Jira avatar', - 'read:issue:jira': 'Read Jira issues', - 'read:status:jira': 'Read Jira status', - 'read:user:jira': 'Read Jira user', - 'read:field-configuration:jira': 'Read Jira field configuration', - 'read:issue-details:jira': 'Read Jira issue details', - 'read:field:jira': 'Read Jira field configurations', - 'read:jql:jira': 'Use JQL to filter Jira issues', - 'read:comment.property:jira': 'Read Jira comment properties', - 'read:issue.property:jira': 'Read Jira issue properties', - 'delete:issue:jira': 'Delete Jira issues', - 'write:comment:jira': 'Add and update comments on Jira issues', - 'read:comment:jira': 'Read comments on Jira issues', - 'delete:comment:jira': 'Delete comments from Jira issues', - 'read:attachment:jira': 'Read attachments from Jira issues', - 'delete:attachment:jira': 'Delete attachments from Jira issues', - 'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues', - 'read:issue-worklog:jira': 'Read worklog entries from Jira issues', - 'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues', - 'write:issue-link:jira': 'Create links between Jira issues', - 'delete:issue-link:jira': 'Delete links between Jira issues', - 'User.Read': 'Read Microsoft user', - 'Chat.Read': 'Read Microsoft chats', - 'Chat.ReadWrite': 'Write to Microsoft chats', - 'Chat.ReadBasic': 'Read Microsoft chats', - 'ChatMessage.Send': 'Send chat messages', - 'Channel.ReadBasic.All': 'Read Microsoft channels', - 'ChannelMessage.Send': 'Write to Microsoft channels', - 'ChannelMessage.Read.All': 'Read Microsoft channels', - 'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels', - 'ChannelMember.Read.All': 'Read team channel members', - 'Group.Read.All': 'Read Microsoft groups', - 'Group.ReadWrite.All': 'Write to Microsoft groups', - 'Team.ReadBasic.All': 'Read Microsoft teams', - 'TeamMember.Read.All': 'Read team members', - 'Mail.ReadWrite': 'Write to Microsoft emails', - 'Mail.ReadBasic': 'Read Microsoft emails', - 'Mail.Read': 'Read Microsoft emails', - 'Mail.Send': 'Send emails', - 'Files.Read': 'Read OneDrive files', - 'Files.ReadWrite': 'Read and write OneDrive files', - 'Tasks.ReadWrite': 'Read and manage Planner tasks', - 'Sites.Read.All': 'Read Sharepoint sites', - 'Sites.ReadWrite.All': 'Read and write Sharepoint sites', - 'Sites.Manage.All': 'Manage Sharepoint sites', - openid: 'Standard authentication', - profile: 'Access profile information', - email: 'Access email address', - identify: 'Read Discord user', - bot: 'Read Discord bot', - 'messages.read': 'Read Discord messages', - guilds: 'Read Discord guilds', - 'guilds.members.read': 'Read Discord guild members', - identity: 'Access Reddit identity', - submit: 'Submit posts and comments', - vote: 'Vote on posts and comments', - save: 'Save and unsave posts and comments', - edit: 'Edit posts and comments', - subscribe: 'Subscribe and unsubscribe from subreddits', - history: 'Access Reddit history', - privatemessages: 'Access inbox and send private messages', - account: 'Update account preferences and settings', - mysubreddits: 'Access subscribed and moderated subreddits', - flair: 'Manage user and post flair', - report: 'Report posts and comments for rule violations', - modposts: 'Approve, remove, and moderate posts in moderated subreddits', - modflair: 'Manage flair in moderated subreddits', - modmail: 'Access and respond to moderator mail', - login: 'Access Wealthbox account', - data: 'Access Wealthbox data', - read: 'Read access to workspace', - write: 'Write access to Linear workspace', - 'channels:read': 'View public channels', - 'channels:history': 'Read channel messages', - 'groups:read': 'View private channels', - 'groups:history': 'Read private messages', - 'chat:write': 'Send messages', - 'chat:write.public': 'Post to public channels', - 'im:write': 'Send direct messages', - 'im:history': 'Read direct message history', - 'im:read': 'View direct message channels', - 'users:read': 'View workspace users', - 'files:write': 'Upload files', - 'files:read': 'Download and read files', - 'canvases:write': 'Create canvas documents', - 'reactions:write': 'Add emoji reactions to messages', - 'sites:read': 'View Webflow sites', - 'sites:write': 'Manage webhooks and site settings', - 'cms:read': 'View CMS content', - 'cms:write': 'Manage CMS content', - 'crm.objects.contacts.read': 'Read HubSpot contacts', - 'crm.objects.contacts.write': 'Create and update HubSpot contacts', - 'crm.objects.companies.read': 'Read HubSpot companies', - 'crm.objects.companies.write': 'Create and update HubSpot companies', - 'crm.objects.deals.read': 'Read HubSpot deals', - 'crm.objects.deals.write': 'Create and update HubSpot deals', - 'crm.objects.owners.read': 'Read HubSpot object owners', - 'crm.objects.users.read': 'Read HubSpot users', - 'crm.objects.users.write': 'Create and update HubSpot users', - 'crm.objects.marketing_events.read': 'Read HubSpot marketing events', - 'crm.objects.marketing_events.write': 'Create and update HubSpot marketing events', - 'crm.objects.line_items.read': 'Read HubSpot line items', - 'crm.objects.line_items.write': 'Create and update HubSpot line items', - 'crm.objects.quotes.read': 'Read HubSpot quotes', - 'crm.objects.quotes.write': 'Create and update HubSpot quotes', - 'crm.objects.appointments.read': 'Read HubSpot appointments', - 'crm.objects.appointments.write': 'Create and update HubSpot appointments', - 'crm.objects.carts.read': 'Read HubSpot shopping carts', - 'crm.objects.carts.write': 'Create and update HubSpot shopping carts', - 'crm.import': 'Import data into HubSpot', - 'crm.lists.read': 'Read HubSpot lists', - 'crm.lists.write': 'Create and update HubSpot lists', - tickets: 'Manage HubSpot tickets', - api: 'Access Salesforce API', - refresh_token: 'Maintain long-term access to Salesforce account', - default: 'Access Asana workspace', - base: 'Basic access to Pipedrive account', - 'deals:read': 'Read Pipedrive deals', - 'deals:full': 'Full access to manage Pipedrive deals', - 'contacts:read': 'Read Pipedrive contacts', - 'contacts:full': 'Full access to manage Pipedrive contacts', - 'leads:read': 'Read Pipedrive leads', - 'leads:full': 'Full access to manage Pipedrive leads', - 'activities:read': 'Read Pipedrive activities', - 'activities:full': 'Full access to manage Pipedrive activities', - 'mail:read': 'Read Pipedrive emails', - 'mail:full': 'Full access to manage Pipedrive emails', - 'projects:read': 'Read Pipedrive projects', - 'projects:full': 'Full access to manage Pipedrive projects', - 'webhooks:read': 'Read Pipedrive webhooks', - 'webhooks:full': 'Full access to manage Pipedrive webhooks', - w_member_social: 'Access LinkedIn profile', - // Box scopes - root_readwrite: 'Read and write all files and folders in Box account', - root_readonly: 'Read all files and folders in Box account', - // Shopify scopes (write_* implicitly includes read access) - write_products: 'Read and manage Shopify products', - write_orders: 'Read and manage Shopify orders', - write_customers: 'Read and manage Shopify customers', - write_inventory: 'Read and manage Shopify inventory levels', - read_locations: 'View store locations', - write_merchant_managed_fulfillment_orders: 'Create fulfillments for orders', - // Zoom scopes - 'user:read:user': 'View Zoom profile information', - 'meeting:write:meeting': 'Create Zoom meetings', - 'meeting:read:meeting': 'View Zoom meeting details', - 'meeting:read:list_meetings': 'List Zoom meetings', - 'meeting:update:meeting': 'Update Zoom meetings', - 'meeting:delete:meeting': 'Delete Zoom meetings', - 'meeting:read:invitation': 'View Zoom meeting invitations', - 'meeting:read:list_past_participants': 'View past meeting participants', - 'cloud_recording:read:list_user_recordings': 'List Zoom cloud recordings', - 'cloud_recording:read:list_recording_files': 'View recording files', - 'cloud_recording:delete:recording_file': 'Delete cloud recordings', - // Dropbox scopes - 'account_info.read': 'View Dropbox account information', - 'files.metadata.read': 'View file and folder names, sizes, and dates', - 'files.metadata.write': 'Modify file and folder metadata', - 'files.content.read': 'Download and read Dropbox files', - 'files.content.write': 'Upload, copy, move, and delete files in Dropbox', - 'sharing.read': 'View shared files and folders', - 'sharing.write': 'Share files and folders with others', - // WordPress.com scopes - global: 'Full access to manage WordPress.com sites, posts, pages, media, and settings', - // Spotify scopes - 'user-read-private': 'View Spotify account details', - 'user-read-email': 'View email address on Spotify', - 'user-library-read': 'View saved tracks and albums', - 'user-library-modify': 'Save and remove tracks and albums from library', - 'playlist-read-private': 'View private playlists', - 'playlist-read-collaborative': 'View collaborative playlists', - 'playlist-modify-public': 'Create and manage public playlists', - 'playlist-modify-private': 'Create and manage private playlists', - 'user-read-playback-state': 'View current playback state', - 'user-modify-playback-state': 'Control playback on Spotify devices', - 'user-read-currently-playing': 'View currently playing track', - 'user-read-recently-played': 'View recently played tracks', - 'user-top-read': 'View top artists and tracks', - 'user-follow-read': 'View followed artists and users', - 'user-follow-modify': 'Follow and unfollow artists and users', - 'user-read-playback-position': 'View playback position in podcasts', - 'ugc-image-upload': 'Upload images to Spotify playlists', - // Attio - 'record_permission:read-write': 'Read and write CRM records', - 'object_configuration:read-write': 'Read and manage object schemas', - 'list_configuration:read-write': 'Read and manage list configurations', - 'list_entry:read-write': 'Read and write list entries', - 'note:read-write': 'Read and write notes', - 'task:read-write': 'Read and write tasks', - 'comment:read-write': 'Read and write comments and threads', - 'user_management:read': 'View workspace members', - 'webhook:read-write': 'Manage webhooks', -} - -function getScopeDescription(scope: string): string { - return SCOPE_DESCRIPTIONS[scope] || scope -} - export function OAuthRequiredModal({ isOpen, onClose, 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 7011e69113f..fb45926d1f5 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 @@ -15,6 +15,7 @@ import { type OAuthProvider, parseProvider, } from '@/lib/oauth' +import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' 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' @@ -25,7 +26,6 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' -import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index 7c2c77a840c..f0610ba135b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -12,10 +12,10 @@ import { type OAuthService, parseProvider, } from '@/lib/oauth' +import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' -import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const getProviderIcon = (providerName: OAuthProvider) => { diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index efeac7da2bd..372864277c3 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { AgentIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { getApiKeyCondition, getModelOptions } from '@/blocks/utils' @@ -128,7 +129,7 @@ Return ONLY the JSON array.`, serviceId: 'vertex-ai', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], + requiredScopes: getScopesForService('vertex-ai'), placeholder: 'Select Google Cloud account', required: true, condition: { diff --git a/apps/sim/blocks/blocks/airtable.ts b/apps/sim/blocks/blocks/airtable.ts index f2b38efb309..bf1808c58fc 100644 --- a/apps/sim/blocks/blocks/airtable.ts +++ b/apps/sim/blocks/blocks/airtable.ts @@ -1,4 +1,5 @@ import { AirtableIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { AirtableResponse } from '@/tools/airtable/types' @@ -38,13 +39,7 @@ export const AirtableBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'airtable', - requiredScopes: [ - 'data.records:read', - 'data.records:write', - 'schema.bases:read', - 'user.email:read', - 'webhook:manage', - ], + requiredScopes: getScopesForService('airtable'), placeholder: 'Select Airtable account', required: true, }, diff --git a/apps/sim/blocks/blocks/asana.ts b/apps/sim/blocks/blocks/asana.ts index 25418864593..a92c7384a8e 100644 --- a/apps/sim/blocks/blocks/asana.ts +++ b/apps/sim/blocks/blocks/asana.ts @@ -1,4 +1,5 @@ import { AsanaIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { AsanaResponse } from '@/tools/asana/types' @@ -36,7 +37,7 @@ export const AsanaBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'asana', - requiredScopes: ['default'], + requiredScopes: getScopesForService('asana'), placeholder: 'Select Asana account', }, { diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index b71bc653e0f..ad52791c831 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -1,4 +1,5 @@ import { ConfluenceIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -55,36 +56,7 @@ export const ConfluenceBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'confluence', - requiredScopes: [ - 'read:confluence-content.all', - 'read:confluence-space.summary', - 'read:space:confluence', - 'read:space-details:confluence', - 'write:confluence-content', - 'write:confluence-space', - 'write:confluence-file', - 'read:content:confluence', - 'read:page:confluence', - 'write:page:confluence', - 'read:comment:confluence', - 'write:comment:confluence', - 'delete:comment:confluence', - 'read:attachment:confluence', - 'write:attachment:confluence', - 'delete:attachment:confluence', - 'delete:page:confluence', - 'read:label:confluence', - 'write:label:confluence', - 'search:confluence', - 'read:me', - 'offline_access', - 'read:blogpost:confluence', - 'write:blogpost:confluence', - 'read:content.property:confluence', - 'write:content.property:confluence', - 'read:hierarchical-content:confluence', - 'read:content.metadata:confluence', - ], + requiredScopes: getScopesForService('confluence'), placeholder: 'Select Confluence account', required: true, }, @@ -463,45 +435,7 @@ export const ConfluenceV2Block: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'confluence', - requiredScopes: [ - 'read:confluence-content.all', - 'read:confluence-space.summary', - 'read:space:confluence', - 'read:space-details:confluence', - 'write:confluence-content', - 'write:confluence-space', - 'write:confluence-file', - 'read:content:confluence', - 'read:page:confluence', - 'write:page:confluence', - 'read:comment:confluence', - 'write:comment:confluence', - 'delete:comment:confluence', - 'read:attachment:confluence', - 'write:attachment:confluence', - 'delete:attachment:confluence', - 'delete:page:confluence', - 'read:label:confluence', - 'write:label:confluence', - 'search:confluence', - 'read:me', - 'offline_access', - 'read:blogpost:confluence', - 'write:blogpost:confluence', - 'read:content.property:confluence', - 'write:content.property:confluence', - 'read:hierarchical-content:confluence', - 'read:content.metadata:confluence', - 'read:user:confluence', - 'read:task:confluence', - 'write:task:confluence', - 'delete:blogpost:confluence', - 'write:space:confluence', - 'delete:space:confluence', - 'read:space.property:confluence', - 'write:space.property:confluence', - 'read:space.permission:confluence', - ], + requiredScopes: getScopesForService('confluence'), placeholder: 'Select Confluence account', required: true, }, diff --git a/apps/sim/blocks/blocks/dropbox.ts b/apps/sim/blocks/blocks/dropbox.ts index be44b620bb0..cec6c26a853 100644 --- a/apps/sim/blocks/blocks/dropbox.ts +++ b/apps/sim/blocks/blocks/dropbox.ts @@ -1,4 +1,5 @@ import { DropboxIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -41,15 +42,7 @@ export const DropboxBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'dropbox', - requiredScopes: [ - 'account_info.read', - 'files.metadata.read', - 'files.metadata.write', - 'files.content.read', - 'files.content.write', - 'sharing.read', - 'sharing.write', - ], + requiredScopes: getScopesForService('dropbox'), placeholder: 'Select Dropbox account', required: true, }, diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index b5c1fc29dc5..fb210593a1c 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -1,4 +1,5 @@ import { GmailIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' @@ -79,11 +80,7 @@ export const GmailBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'gmail', - requiredScopes: [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.labels', - ], + requiredScopes: getScopesForService('gmail'), placeholder: 'Select Gmail account', required: true, }, @@ -222,7 +219,7 @@ Return ONLY the email body - no explanations, no extra text.`, canonicalParamId: 'folder', serviceId: 'gmail', selectorKey: 'gmail.labels', - requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'], + requiredScopes: getScopesForService('gmail'), placeholder: 'Select Gmail label/folder', dependsOn: ['credential'], mode: 'basic', @@ -303,7 +300,7 @@ Return ONLY the search query - no explanations, no extra text.`, canonicalParamId: 'addLabelIds', serviceId: 'gmail', selectorKey: 'gmail.labels', - requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'], + requiredScopes: getScopesForService('gmail'), placeholder: 'Select destination label', dependsOn: ['credential'], mode: 'basic', @@ -329,7 +326,7 @@ Return ONLY the search query - no explanations, no extra text.`, canonicalParamId: 'removeLabelIds', serviceId: 'gmail', selectorKey: 'gmail.labels', - requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'], + requiredScopes: getScopesForService('gmail'), placeholder: 'Select label to remove', dependsOn: ['credential'], mode: 'basic', @@ -382,7 +379,7 @@ Return ONLY the search query - no explanations, no extra text.`, canonicalParamId: 'manageLabelId', serviceId: 'gmail', selectorKey: 'gmail.labels', - requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'], + requiredScopes: getScopesForService('gmail'), placeholder: 'Select label', dependsOn: ['credential'], mode: 'basic', diff --git a/apps/sim/blocks/blocks/google_bigquery.ts b/apps/sim/blocks/blocks/google_bigquery.ts index 1fdece82317..44eb3d30a8a 100644 --- a/apps/sim/blocks/blocks/google_bigquery.ts +++ b/apps/sim/blocks/blocks/google_bigquery.ts @@ -1,4 +1,5 @@ import { GoogleBigQueryIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -36,7 +37,7 @@ export const GoogleBigQueryBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-bigquery', - requiredScopes: ['https://www.googleapis.com/auth/bigquery'], + requiredScopes: getScopesForService('google-bigquery'), placeholder: 'Select Google account', }, { diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index bd2ba6265bc..24a35e1fbf4 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -1,4 +1,5 @@ import { GoogleCalendarIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' @@ -43,7 +44,7 @@ export const GoogleCalendarBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-calendar', - requiredScopes: ['https://www.googleapis.com/auth/calendar'], + requiredScopes: getScopesForService('google-calendar'), placeholder: 'Select Google Calendar account', }, { @@ -64,7 +65,7 @@ export const GoogleCalendarBlock: BlockConfig = { serviceId: 'google-calendar', selectorKey: 'google.calendar', selectorAllowSearch: false, - requiredScopes: ['https://www.googleapis.com/auth/calendar'], + requiredScopes: getScopesForService('google-calendar'), placeholder: 'Select calendar', dependsOn: ['credential'], mode: 'basic', @@ -330,7 +331,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, serviceId: 'google-calendar', selectorKey: 'google.calendar', selectorAllowSearch: false, - requiredScopes: ['https://www.googleapis.com/auth/calendar'], + requiredScopes: getScopesForService('google-calendar'), placeholder: 'Select destination calendar', dependsOn: ['credential'], condition: { field: 'operation', value: 'move' }, diff --git a/apps/sim/blocks/blocks/google_contacts.ts b/apps/sim/blocks/blocks/google_contacts.ts index c2eb006df03..629b0a60018 100644 --- a/apps/sim/blocks/blocks/google_contacts.ts +++ b/apps/sim/blocks/blocks/google_contacts.ts @@ -1,4 +1,5 @@ import { GoogleContactsIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { GoogleContactsResponse } from '@/tools/google_contacts/types' @@ -37,7 +38,7 @@ export const GoogleContactsBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-contacts', - requiredScopes: ['https://www.googleapis.com/auth/contacts'], + requiredScopes: getScopesForService('google-contacts'), placeholder: 'Select Google account', }, { diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts index 380d969463a..ac9098395e3 100644 --- a/apps/sim/blocks/blocks/google_docs.ts +++ b/apps/sim/blocks/blocks/google_docs.ts @@ -1,4 +1,5 @@ import { GoogleDocsIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { GoogleDocsResponse } from '@/tools/google_docs/types' @@ -36,10 +37,7 @@ export const GoogleDocsBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-docs', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-docs'), placeholder: 'Select Google account', }, { diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 7f8481898f4..362d1c80426 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -1,4 +1,5 @@ import { GoogleDriveIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -48,10 +49,7 @@ export const GoogleDriveBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select Google Drive account', }, { @@ -138,10 +136,7 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr canonicalParamId: 'uploadFolderId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select a parent folder', mode: 'basic', @@ -211,10 +206,7 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr canonicalParamId: 'createFolderParentId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select a parent folder', mode: 'basic', @@ -239,10 +231,7 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr canonicalParamId: 'listFolderId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select a folder to list files from', mode: 'basic', @@ -299,10 +288,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing canonicalParamId: 'downloadFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to download', mode: 'basic', dependsOn: ['credential'], @@ -361,10 +347,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing canonicalParamId: 'getFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to get info for', mode: 'basic', dependsOn: ['credential'], @@ -389,10 +372,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing canonicalParamId: 'copyFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to copy', mode: 'basic', dependsOn: ['credential'], @@ -423,10 +403,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing canonicalParamId: 'copyDestFolderId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), mimeType: 'application/vnd.google-apps.folder', placeholder: 'Select destination folder (optional)', mode: 'basic', @@ -450,10 +427,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing canonicalParamId: 'updateFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to update', mode: 'basic', dependsOn: ['credential'], @@ -529,10 +503,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, canonicalParamId: 'trashFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to move to trash', mode: 'basic', dependsOn: ['credential'], @@ -557,10 +528,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, canonicalParamId: 'deleteFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to permanently delete', mode: 'basic', dependsOn: ['credential'], @@ -585,10 +553,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, canonicalParamId: 'shareFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to share', mode: 'basic', dependsOn: ['credential'], @@ -700,10 +665,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr canonicalParamId: 'unshareFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to remove sharing from', mode: 'basic', dependsOn: ['credential'], @@ -736,10 +698,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr canonicalParamId: 'listPermissionsFileId', serviceId: 'google-drive', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select a file to list permissions for', mode: 'basic', dependsOn: ['credential'], diff --git a/apps/sim/blocks/blocks/google_forms.ts b/apps/sim/blocks/blocks/google_forms.ts index 280e13c73fd..bf26311b387 100644 --- a/apps/sim/blocks/blocks/google_forms.ts +++ b/apps/sim/blocks/blocks/google_forms.ts @@ -1,4 +1,5 @@ import { GoogleFormsIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { getTrigger } from '@/triggers' @@ -38,13 +39,7 @@ export const GoogleFormsBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-forms', - requiredScopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/forms.body', - 'https://www.googleapis.com/auth/forms.responses.readonly', - ], + requiredScopes: getScopesForService('google-forms'), placeholder: 'Select Google account', }, { diff --git a/apps/sim/blocks/blocks/google_groups.ts b/apps/sim/blocks/blocks/google_groups.ts index b72a48e5680..c44830f3b79 100644 --- a/apps/sim/blocks/blocks/google_groups.ts +++ b/apps/sim/blocks/blocks/google_groups.ts @@ -1,4 +1,5 @@ import { GoogleGroupsIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -46,10 +47,7 @@ export const GoogleGroupsBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-groups', - requiredScopes: [ - 'https://www.googleapis.com/auth/admin.directory.group', - 'https://www.googleapis.com/auth/admin.directory.group.member', - ], + requiredScopes: getScopesForService('google-groups'), placeholder: 'Select Google Workspace account', }, { diff --git a/apps/sim/blocks/blocks/google_meet.ts b/apps/sim/blocks/blocks/google_meet.ts index b0524788dc8..28fa1d7f03e 100644 --- a/apps/sim/blocks/blocks/google_meet.ts +++ b/apps/sim/blocks/blocks/google_meet.ts @@ -1,4 +1,5 @@ import { GoogleMeetIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { GoogleMeetResponse } from '@/tools/google_meet/types' @@ -37,10 +38,7 @@ export const GoogleMeetBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-meet', - requiredScopes: [ - 'https://www.googleapis.com/auth/meetings.space.created', - 'https://www.googleapis.com/auth/meetings.space.readonly', - ], + requiredScopes: getScopesForService('google-meet'), placeholder: 'Select Google Meet account', }, { diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index f990adf13ae..31f9aab66e7 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -1,4 +1,5 @@ import { GoogleSheetsIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' @@ -40,10 +41,7 @@ export const GoogleSheetsBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-sheets', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-sheets'), placeholder: 'Select Google account', }, { @@ -63,10 +61,7 @@ export const GoogleSheetsBlock: BlockConfig = { canonicalParamId: 'spreadsheetId', serviceId: 'google-sheets', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-sheets'), mimeType: 'application/vnd.google-apps.spreadsheet', placeholder: 'Select a spreadsheet', dependsOn: ['credential'], @@ -339,10 +334,7 @@ export const GoogleSheetsV2Block: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-sheets', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-sheets'), placeholder: 'Select Google account', }, { @@ -362,10 +354,7 @@ export const GoogleSheetsV2Block: BlockConfig = { canonicalParamId: 'spreadsheetId', serviceId: 'google-sheets', selectorKey: 'google.drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-sheets'), mimeType: 'application/vnd.google-apps.spreadsheet', placeholder: 'Select a spreadsheet', dependsOn: ['credential'], diff --git a/apps/sim/blocks/blocks/google_slides.ts b/apps/sim/blocks/blocks/google_slides.ts index 100314d855a..9fc8fdbf1d4 100644 --- a/apps/sim/blocks/blocks/google_slides.ts +++ b/apps/sim/blocks/blocks/google_slides.ts @@ -1,4 +1,5 @@ import { GoogleSlidesIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -50,10 +51,7 @@ export const GoogleSlidesBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-drive', - requiredScopes: [ - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + requiredScopes: getScopesForService('google-drive'), placeholder: 'Select Google account', }, { diff --git a/apps/sim/blocks/blocks/google_tasks.ts b/apps/sim/blocks/blocks/google_tasks.ts index ad63e6e1a72..cd930ecb21e 100644 --- a/apps/sim/blocks/blocks/google_tasks.ts +++ b/apps/sim/blocks/blocks/google_tasks.ts @@ -1,4 +1,5 @@ import { GoogleTasksIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { GoogleTasksResponse } from '@/tools/google_tasks/types' @@ -38,7 +39,7 @@ export const GoogleTasksBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-tasks', - requiredScopes: ['https://www.googleapis.com/auth/tasks'], + requiredScopes: getScopesForService('google-tasks'), placeholder: 'Select Google Tasks account', }, { diff --git a/apps/sim/blocks/blocks/google_vault.ts b/apps/sim/blocks/blocks/google_vault.ts index 4f5183133fc..1cbb334e698 100644 --- a/apps/sim/blocks/blocks/google_vault.ts +++ b/apps/sim/blocks/blocks/google_vault.ts @@ -1,4 +1,5 @@ import { GoogleVaultIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -38,10 +39,7 @@ export const GoogleVaultBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'google-vault', - requiredScopes: [ - 'https://www.googleapis.com/auth/ediscovery', - 'https://www.googleapis.com/auth/devstorage.read_only', - ], + requiredScopes: getScopesForService('google-vault'), placeholder: 'Select Google Vault account', }, { diff --git a/apps/sim/blocks/blocks/hubspot.ts b/apps/sim/blocks/blocks/hubspot.ts index 706a4b9bb10..24bf1a39819 100644 --- a/apps/sim/blocks/blocks/hubspot.ts +++ b/apps/sim/blocks/blocks/hubspot.ts @@ -1,4 +1,5 @@ import { HubspotIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { HubSpotResponse } from '@/tools/hubspot/types' @@ -42,31 +43,7 @@ export const HubSpotBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'hubspot', - requiredScopes: [ - 'crm.objects.contacts.read', - 'crm.objects.contacts.write', - 'crm.objects.companies.read', - 'crm.objects.companies.write', - 'crm.objects.deals.read', - 'crm.objects.deals.write', - 'crm.objects.owners.read', - 'crm.objects.users.read', - 'crm.objects.users.write', - 'crm.objects.marketing_events.read', - 'crm.objects.marketing_events.write', - 'crm.objects.line_items.read', - 'crm.objects.line_items.write', - 'crm.objects.quotes.read', - 'crm.objects.quotes.write', - 'crm.objects.appointments.read', - 'crm.objects.appointments.write', - 'crm.objects.carts.read', - 'crm.objects.carts.write', - 'crm.import', - 'crm.lists.read', - 'crm.lists.write', - 'tickets', - ], + requiredScopes: getScopesForService('hubspot'), placeholder: 'Select HubSpot account', required: true, }, diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index b5c25317af5..3d7f0d8ede9 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -1,4 +1,5 @@ import { JiraIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -64,38 +65,7 @@ export const JiraBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'jira', - requiredScopes: [ - 'read:jira-work', - 'read:jira-user', - 'write:jira-work', - 'read:issue-event:jira', - 'write:issue:jira', - 'read:project:jira', - 'read:issue-type:jira', - 'read:me', - 'offline_access', - 'read:issue-meta:jira', - 'read:issue-security-level:jira', - 'read:issue.vote:jira', - 'read:issue.changelog:jira', - 'read:avatar:jira', - 'read:issue:jira', - 'read:status:jira', - 'read:user:jira', - 'read:field-configuration:jira', - 'read:issue-details:jira', - 'delete:issue:jira', - 'write:comment:jira', - 'read:comment:jira', - 'delete:comment:jira', - 'read:attachment:jira', - 'delete:attachment:jira', - 'write:issue-worklog:jira', - 'read:issue-worklog:jira', - 'delete:issue-worklog:jira', - 'write:issue-link:jira', - 'delete:issue-link:jira', - ], + requiredScopes: getScopesForService('jira'), placeholder: 'Select Jira account', }, { diff --git a/apps/sim/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index 916f0b2bd1e..b74fbe4a134 100644 --- a/apps/sim/blocks/blocks/jira_service_management.ts +++ b/apps/sim/blocks/blocks/jira_service_management.ts @@ -1,4 +1,5 @@ import { JiraServiceManagementIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { JsmResponse } from '@/tools/jsm/types' @@ -59,42 +60,7 @@ export const JiraServiceManagementBlock: BlockConfig = { mode: 'basic', required: true, serviceId: 'jira', - requiredScopes: [ - 'read:jira-user', - 'read:jira-work', - 'write:jira-work', - 'read:project:jira', - 'read:me', - 'offline_access', - 'read:issue:jira', - 'read:status:jira', - 'read:user:jira', - 'read:issue-details:jira', - 'write:comment:jira', - 'read:comment:jira', - 'read:servicedesk:jira-service-management', - 'read:requesttype:jira-service-management', - 'read:request:jira-service-management', - 'write:request:jira-service-management', - 'read:request.comment:jira-service-management', - 'write:request.comment:jira-service-management', - 'read:customer:jira-service-management', - 'write:customer:jira-service-management', - 'read:servicedesk.customer:jira-service-management', - 'write:servicedesk.customer:jira-service-management', - 'read:organization:jira-service-management', - 'write:organization:jira-service-management', - 'read:servicedesk.organization:jira-service-management', - 'write:servicedesk.organization:jira-service-management', - 'read:queue:jira-service-management', - 'read:request.sla:jira-service-management', - 'read:request.status:jira-service-management', - 'write:request.status:jira-service-management', - 'read:request.participant:jira-service-management', - 'write:request.participant:jira-service-management', - 'read:request.approval:jira-service-management', - 'write:request.approval:jira-service-management', - ], + requiredScopes: getScopesForService('jira'), placeholder: 'Select Jira account', }, { diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index c72348754b8..e1a922daacc 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1,4 +1,5 @@ import { LinearIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -132,7 +133,7 @@ export const LinearBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'linear', - requiredScopes: ['read', 'write'], + requiredScopes: getScopesForService('linear'), placeholder: 'Select Linear account', required: true, }, diff --git a/apps/sim/blocks/blocks/linkedin.ts b/apps/sim/blocks/blocks/linkedin.ts index 2440cd0091f..87fd163b3c1 100644 --- a/apps/sim/blocks/blocks/linkedin.ts +++ b/apps/sim/blocks/blocks/linkedin.ts @@ -1,4 +1,5 @@ import { LinkedInIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { LinkedInResponse } from '@/tools/linkedin/types' @@ -35,7 +36,7 @@ export const LinkedInBlock: BlockConfig = { serviceId: 'linkedin', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: ['profile', 'openid', 'email', 'w_member_social'], + requiredScopes: getScopesForService('linkedin'), placeholder: 'Select LinkedIn account', required: true, }, diff --git a/apps/sim/blocks/blocks/microsoft_dataverse.ts b/apps/sim/blocks/blocks/microsoft_dataverse.ts index 7bb17f12c04..531973197fa 100644 --- a/apps/sim/blocks/blocks/microsoft_dataverse.ts +++ b/apps/sim/blocks/blocks/microsoft_dataverse.ts @@ -1,4 +1,5 @@ import { MicrosoftDataverseIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -46,13 +47,7 @@ export const MicrosoftDataverseBlock: BlockConfig = { title: 'Microsoft Account', type: 'oauth-input', serviceId: 'microsoft-dataverse', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'https://dynamics.microsoft.com/user_impersonation', - 'offline_access', - ], + requiredScopes: getScopesForService('microsoft-dataverse'), placeholder: 'Select Microsoft account', required: true, }, diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index 7e22b6757ee..1329b6edcde 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -1,4 +1,5 @@ import { MicrosoftExcelIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' @@ -39,14 +40,7 @@ export const MicrosoftExcelBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'microsoft-excel', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('microsoft-excel'), placeholder: 'Select Microsoft account', required: true, }, @@ -366,14 +360,7 @@ export const MicrosoftExcelV2Block: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'microsoft-excel', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('microsoft-excel'), placeholder: 'Select Microsoft account', required: true, }, diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index ab90c179236..1a0d57d9be9 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -1,4 +1,5 @@ import { MicrosoftPlannerIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types' @@ -64,15 +65,7 @@ export const MicrosoftPlannerBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'microsoft-planner', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Group.ReadWrite.All', - 'Group.Read.All', - 'Tasks.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('microsoft-planner'), placeholder: 'Select Microsoft account', }, { diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 3285eeace1a..7b382374c8d 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -1,4 +1,5 @@ import { MicrosoftTeamsIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -47,28 +48,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'microsoft-teams', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'User.Read', - 'Chat.Read', - 'Chat.ReadWrite', - 'Chat.ReadBasic', - 'ChatMessage.Send', - 'Channel.ReadBasic.All', - 'ChannelMessage.Send', - 'ChannelMessage.Read.All', - 'ChannelMessage.ReadWrite', - 'ChannelMember.Read.All', - 'Group.Read.All', - 'Group.ReadWrite.All', - 'Team.ReadBasic.All', - 'TeamMember.Read.All', - 'offline_access', - 'Files.Read', - 'Sites.Read.All', - ], + requiredScopes: getScopesForService('microsoft-teams'), placeholder: 'Select Microsoft account', required: true, }, diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index 0912da950ee..b8d54b5e300 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { MicrosoftOneDriveIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -42,14 +43,7 @@ export const OneDriveBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'onedrive', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('onedrive'), placeholder: 'Select Microsoft account', }, { @@ -156,14 +150,7 @@ export const OneDriveBlock: BlockConfig = { canonicalParamId: 'uploadFolderId', serviceId: 'onedrive', selectorKey: 'onedrive.folders', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('onedrive'), mimeType: 'application/vnd.microsoft.graph.folder', placeholder: 'Select a parent folder', dependsOn: ['credential'], @@ -194,14 +181,7 @@ export const OneDriveBlock: BlockConfig = { canonicalParamId: 'createFolderParentId', serviceId: 'onedrive', selectorKey: 'onedrive.folders', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('onedrive'), mimeType: 'application/vnd.microsoft.graph.folder', placeholder: 'Select a parent folder', dependsOn: ['credential'], @@ -227,14 +207,7 @@ export const OneDriveBlock: BlockConfig = { canonicalParamId: 'listFolderId', serviceId: 'onedrive', selectorKey: 'onedrive.folders', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('onedrive'), mimeType: 'application/vnd.microsoft.graph.folder', placeholder: 'Select a folder to list files from', dependsOn: ['credential'], @@ -274,14 +247,7 @@ export const OneDriveBlock: BlockConfig = { canonicalParamId: 'downloadFileId', serviceId: 'onedrive', selectorKey: 'onedrive.files', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('onedrive'), mimeType: 'file', // Exclude folders, show only files placeholder: 'Select a file to download', mode: 'basic', @@ -315,14 +281,7 @@ export const OneDriveBlock: BlockConfig = { canonicalParamId: 'deleteFileId', serviceId: 'onedrive', selectorKey: 'onedrive.files', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('onedrive'), mimeType: 'file', // Exclude folders, show only files placeholder: 'Select a file to delete', mode: 'basic', diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 11997034cbb..2635a5be659 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -1,4 +1,5 @@ import { OutlookIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -42,16 +43,7 @@ export const OutlookBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'outlook', - requiredScopes: [ - 'Mail.ReadWrite', - 'Mail.ReadBasic', - 'Mail.Read', - 'Mail.Send', - 'offline_access', - 'openid', - 'profile', - 'email', - ], + requiredScopes: getScopesForService('outlook'), placeholder: 'Select Microsoft account', required: true, }, @@ -188,7 +180,7 @@ export const OutlookBlock: BlockConfig = { canonicalParamId: 'folder', serviceId: 'outlook', selectorKey: 'outlook.folders', - requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'], + requiredScopes: getScopesForService('outlook'), placeholder: 'Select Outlook folder', dependsOn: ['credential'], mode: 'basic', @@ -234,7 +226,7 @@ export const OutlookBlock: BlockConfig = { canonicalParamId: 'destinationId', serviceId: 'outlook', selectorKey: 'outlook.folders', - requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'], + requiredScopes: getScopesForService('outlook'), placeholder: 'Select destination folder', dependsOn: ['credential'], mode: 'basic', @@ -281,7 +273,7 @@ export const OutlookBlock: BlockConfig = { canonicalParamId: 'copyDestinationId', serviceId: 'outlook', selectorKey: 'outlook.folders', - requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'], + requiredScopes: getScopesForService('outlook'), placeholder: 'Select destination folder', dependsOn: ['credential'], mode: 'basic', diff --git a/apps/sim/blocks/blocks/pipedrive.ts b/apps/sim/blocks/blocks/pipedrive.ts index 55e5c331ff4..7b62906f93e 100644 --- a/apps/sim/blocks/blocks/pipedrive.ts +++ b/apps/sim/blocks/blocks/pipedrive.ts @@ -1,4 +1,5 @@ import { PipedriveIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { PipedriveResponse } from '@/tools/pipedrive/types' @@ -48,15 +49,7 @@ export const PipedriveBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'pipedrive', - requiredScopes: [ - 'base', - 'deals:full', - 'contacts:full', - 'leads:full', - 'activities:full', - 'mail:full', - 'projects:full', - ], + requiredScopes: getScopesForService('pipedrive'), placeholder: 'Select Pipedrive account', required: true, }, diff --git a/apps/sim/blocks/blocks/reddit.ts b/apps/sim/blocks/blocks/reddit.ts index 801e35b859e..d2ddb1d908e 100644 --- a/apps/sim/blocks/blocks/reddit.ts +++ b/apps/sim/blocks/blocks/reddit.ts @@ -1,4 +1,5 @@ import { RedditIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { RedditResponse } from '@/tools/reddit/types' @@ -49,24 +50,7 @@ export const RedditBlock: BlockConfig = { serviceId: 'reddit', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: [ - 'identity', - 'read', - 'submit', - 'vote', - 'save', - 'edit', - 'subscribe', - 'history', - 'privatemessages', - 'account', - 'mysubreddits', - 'flair', - 'report', - 'modposts', - 'modflair', - 'modmail', - ], + requiredScopes: getScopesForService('reddit'), placeholder: 'Select Reddit account', required: true, }, diff --git a/apps/sim/blocks/blocks/salesforce.ts b/apps/sim/blocks/blocks/salesforce.ts index bbfa8616efc..f6bbd422e63 100644 --- a/apps/sim/blocks/blocks/salesforce.ts +++ b/apps/sim/blocks/blocks/salesforce.ts @@ -1,4 +1,5 @@ import { SalesforceIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { SalesforceResponse } from '@/tools/salesforce/types' @@ -65,7 +66,7 @@ export const SalesforceBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'salesforce', - requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'], + requiredScopes: getScopesForService('salesforce'), placeholder: 'Select Salesforce account', required: true, }, diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index 2bfc08bcd8b..85ac2c34ba4 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { MicrosoftSharepointIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -41,15 +42,7 @@ export const SharepointBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'sharepoint', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Sites.Read.All', - 'Sites.ReadWrite.All', - 'Sites.Manage.All', - 'offline_access', - ], + requiredScopes: getScopesForService('sharepoint'), placeholder: 'Select Microsoft account', }, { @@ -68,14 +61,7 @@ export const SharepointBlock: BlockConfig = { canonicalParamId: 'siteId', serviceId: 'sharepoint', selectorKey: 'sharepoint.sites', - requiredScopes: [ - 'openid', - 'profile', - 'email', - 'Files.Read', - 'Files.ReadWrite', - 'offline_access', - ], + requiredScopes: getScopesForService('sharepoint'), mimeType: 'application/vnd.microsoft.graph.folder', placeholder: 'Select a site', dependsOn: ['credential'], diff --git a/apps/sim/blocks/blocks/shopify.ts b/apps/sim/blocks/blocks/shopify.ts index dc3f3cae622..7c4d12e7379 100644 --- a/apps/sim/blocks/blocks/shopify.ts +++ b/apps/sim/blocks/blocks/shopify.ts @@ -1,4 +1,5 @@ import { ShopifyIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -63,14 +64,7 @@ export const ShopifyBlock: BlockConfig = { serviceId: 'shopify', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: [ - 'write_products', - 'write_orders', - 'write_customers', - 'write_inventory', - 'read_locations', - 'write_merchant_managed_fulfillment_orders', - ], + requiredScopes: getScopesForService('shopify'), placeholder: 'Select Shopify account', required: true, }, diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 23ba4ccfdd0..a455c8f0392 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1,4 +1,5 @@ import { SlackIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -82,22 +83,7 @@ export const SlackBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'slack', - requiredScopes: [ - 'channels:read', - 'channels:history', - 'groups:read', - 'groups:history', - 'chat:write', - 'chat:write.public', - 'im:write', - 'im:history', - 'im:read', - 'users:read', - 'files:write', - 'files:read', - 'canvases:write', - 'reactions:write', - ], + requiredScopes: getScopesForService('slack'), placeholder: 'Select Slack workspace', dependsOn: ['authMethod'], condition: { diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index bff115ebdcf..0e4180d557a 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -1,4 +1,5 @@ import { TrelloIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' @@ -44,7 +45,7 @@ export const TrelloBlock: BlockConfig = { serviceId: 'trello', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: ['read', 'write'], + requiredScopes: getScopesForService('trello'), placeholder: 'Select Trello account', required: true, }, diff --git a/apps/sim/blocks/blocks/wealthbox.ts b/apps/sim/blocks/blocks/wealthbox.ts index 6454ac9a6a8..0a70229ead6 100644 --- a/apps/sim/blocks/blocks/wealthbox.ts +++ b/apps/sim/blocks/blocks/wealthbox.ts @@ -1,4 +1,5 @@ import { WealthboxIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { WealthboxResponse } from '@/tools/wealthbox/types' @@ -36,7 +37,7 @@ export const WealthboxBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'wealthbox', - requiredScopes: ['login', 'data'], + requiredScopes: getScopesForService('wealthbox'), placeholder: 'Select Wealthbox account', required: true, }, @@ -62,7 +63,7 @@ export const WealthboxBlock: BlockConfig = { type: 'file-selector', serviceId: 'wealthbox', selectorKey: 'wealthbox.contacts', - requiredScopes: ['login', 'data'], + requiredScopes: getScopesForService('wealthbox'), placeholder: 'Enter Contact ID', mode: 'basic', canonicalParamId: 'contactId', diff --git a/apps/sim/blocks/blocks/webflow.ts b/apps/sim/blocks/blocks/webflow.ts index 28bb73aeabc..6c2afb6577f 100644 --- a/apps/sim/blocks/blocks/webflow.ts +++ b/apps/sim/blocks/blocks/webflow.ts @@ -1,4 +1,5 @@ import { WebflowIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { WebflowResponse } from '@/tools/webflow/types' @@ -37,7 +38,7 @@ export const WebflowBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'webflow', - requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'], + requiredScopes: getScopesForService('webflow'), placeholder: 'Select Webflow account', required: true, }, diff --git a/apps/sim/blocks/blocks/wordpress.ts b/apps/sim/blocks/blocks/wordpress.ts index 2e2dafcc45b..305f0be30cb 100644 --- a/apps/sim/blocks/blocks/wordpress.ts +++ b/apps/sim/blocks/blocks/wordpress.ts @@ -1,4 +1,5 @@ import { WordpressIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' @@ -68,7 +69,7 @@ export const WordPressBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'wordpress', - requiredScopes: ['global'], + requiredScopes: getScopesForService('wordpress'), placeholder: 'Select WordPress account', required: true, }, diff --git a/apps/sim/blocks/blocks/x.ts b/apps/sim/blocks/blocks/x.ts index bc2b1409379..e1d93077ece 100644 --- a/apps/sim/blocks/blocks/x.ts +++ b/apps/sim/blocks/blocks/x.ts @@ -1,4 +1,5 @@ import { xIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' @@ -66,23 +67,7 @@ export const XBlock: BlockConfig = { serviceId: 'x', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: [ - 'tweet.read', - 'tweet.write', - 'tweet.moderate.write', - 'users.read', - 'follows.read', - 'follows.write', - 'bookmark.read', - 'bookmark.write', - 'like.read', - 'like.write', - 'block.read', - 'block.write', - 'mute.read', - 'mute.write', - 'offline.access', - ], + requiredScopes: getScopesForService('x'), placeholder: 'Select X account', }, { diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index 9b74422ae2c..4d9ecbd21dd 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -1,4 +1,5 @@ import { ZoomIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import type { ZoomResponse } from '@/tools/zoom/types' @@ -40,19 +41,7 @@ export const ZoomBlock: BlockConfig = { serviceId: 'zoom', canonicalParamId: 'oauthCredential', mode: 'basic', - requiredScopes: [ - 'user:read:user', - 'meeting:write:meeting', - 'meeting:read:meeting', - 'meeting:read:list_meetings', - 'meeting:update:meeting', - 'meeting:delete:meeting', - 'meeting:read:invitation', - 'meeting:read:list_past_participants', - 'cloud_recording:read:list_user_recordings', - 'cloud_recording:read:list_recording_files', - 'cloud_recording:delete:recording_file', - ], + requiredScopes: getScopesForService('zoom'), placeholder: 'Select Zoom account', required: true, }, diff --git a/apps/sim/hooks/use-oauth-scope-status.ts b/apps/sim/hooks/use-oauth-scope-status.ts deleted file mode 100644 index d2576e098e7..00000000000 --- a/apps/sim/hooks/use-oauth-scope-status.ts +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import type { Credential } from '@/lib/oauth' - -export interface OAuthScopeStatus { - requiresReauthorization: boolean - missingScopes: string[] - extraScopes: string[] - canonicalScopes: string[] - grantedScopes: string[] -} - -/** - * Extract scope status from a credential - */ -export function getCredentialScopeStatus(credential: Credential): OAuthScopeStatus { - return { - requiresReauthorization: credential.requiresReauthorization || false, - missingScopes: credential.missingScopes || [], - extraScopes: credential.extraScopes || [], - canonicalScopes: credential.canonicalScopes || [], - grantedScopes: credential.scopes || [], - } -} - -/** - * Check if a credential needs reauthorization - */ -export function credentialNeedsReauth(credential: Credential): boolean { - return credential.requiresReauthorization || false -} - -/** - * Check if any credentials in a list need reauthorization - */ -export function anyCredentialNeedsReauth(credentials: Credential[]): boolean { - return credentials.some(credentialNeedsReauth) -} - -/** - * Get all credentials that need reauthorization - */ -export function getCredentialsNeedingReauth(credentials: Credential[]): Credential[] { - return credentials.filter(credentialNeedsReauth) -} - -/** - * Scopes that control token behavior but are not returned in OAuth token responses. - * These should be ignored when validating credential scopes. - */ -const IGNORED_SCOPES = new Set([ - 'offline_access', // Microsoft - requests refresh token - 'refresh_token', // Salesforce - requests refresh token - 'offline.access', // Airtable - requests refresh token (note: dot not underscore) -]) - -/** - * Compute which of the provided requiredScopes are NOT granted by the credential. - * Note: Ignores special OAuth scopes that control token behavior (like offline_access) - * as they are not returned in the token response's scope list even when granted. - */ -export function getMissingRequiredScopes( - credential: Credential | undefined, - requiredScopes: string[] = [] -): string[] { - if (!credential) { - // Filter out ignored scopes from required scopes as they're not returned by OAuth providers - return requiredScopes.filter((s) => !IGNORED_SCOPES.has(s)) - } - - const granted = new Set((credential.scopes || []).map((s) => s)) - const missing: string[] = [] - - for (const s of requiredScopes) { - // Skip ignored scopes as providers don't return them in the scope list even when granted - if (IGNORED_SCOPES.has(s)) continue - - if (!granted.has(s)) missing.push(s) - } - - return missing -} - -/** - * Whether a credential needs an upgrade specifically for the provided required scopes. - */ -export function needsUpgradeForRequiredScopes( - credential: Credential | undefined, - requiredScopes: string[] = [] -): boolean { - return getMissingRequiredScopes(credential, requiredScopes).length > 0 -} diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 17fa684c324..1a257bdc7c3 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -77,6 +77,7 @@ import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' const logger = createLogger('Auth') import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const validStripeKey = env.STRIPE_SECRET_KEY @@ -762,7 +763,7 @@ export const auth = betterAuth({ prompt: 'consent', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', - scopes: ['user:email', 'repo', 'read:user', 'workflow'], + scopes: getCanonicalScopesForProvider('github-repo'), redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`, getUserInfo: async (tokens) => { try { @@ -837,13 +838,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.labels', - ], + scopes: getCanonicalScopesForProvider('google-email'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`, getUserInfo: async (tokens) => { @@ -879,11 +874,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/calendar', - ], + scopes: getCanonicalScopesForProvider('google-calendar'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`, getUserInfo: async (tokens) => { @@ -919,12 +910,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + scopes: getCanonicalScopesForProvider('google-drive'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`, getUserInfo: async (tokens) => { @@ -960,12 +946,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + scopes: getCanonicalScopesForProvider('google-docs'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`, getUserInfo: async (tokens) => { @@ -1001,12 +982,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive.file', - 'https://www.googleapis.com/auth/drive', - ], + scopes: getCanonicalScopesForProvider('google-sheets'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`, getUserInfo: async (tokens) => { @@ -1043,11 +1019,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/contacts', - ], + scopes: getCanonicalScopesForProvider('google-contacts'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`, getUserInfo: async (tokens) => { @@ -1083,13 +1055,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/forms.body', - 'https://www.googleapis.com/auth/forms.responses.readonly', - ], + scopes: getCanonicalScopesForProvider('google-forms'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`, getUserInfo: async (tokens) => { @@ -1125,11 +1091,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/bigquery', - ], + scopes: getCanonicalScopesForProvider('google-bigquery'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`, getUserInfo: async (tokens) => { @@ -1166,12 +1128,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/ediscovery', - 'https://www.googleapis.com/auth/devstorage.read_only', - ], + scopes: getCanonicalScopesForProvider('google-vault'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`, getUserInfo: async (tokens) => { @@ -1208,12 +1165,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/admin.directory.group', - 'https://www.googleapis.com/auth/admin.directory.group.member', - ], + scopes: getCanonicalScopesForProvider('google-groups'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`, getUserInfo: async (tokens) => { @@ -1250,12 +1202,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/meetings.space.created', - 'https://www.googleapis.com/auth/meetings.space.readonly', - ], + scopes: getCanonicalScopesForProvider('google-meet'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`, getUserInfo: async (tokens) => { @@ -1291,11 +1238,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/tasks', - ], + scopes: getCanonicalScopesForProvider('google-tasks'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`, getUserInfo: async (tokens) => { @@ -1332,11 +1275,7 @@ export const auth = betterAuth({ clientSecret: env.GOOGLE_CLIENT_SECRET as string, discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', - scopes: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/cloud-platform', - ], + scopes: getCanonicalScopesForProvider('vertex-ai'), prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`, getUserInfo: async (tokens) => { @@ -1374,28 +1313,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: [ - 'openid', - 'profile', - 'email', - 'User.Read', - 'Chat.Read', - 'Chat.ReadWrite', - 'Chat.ReadBasic', - 'ChatMessage.Send', - 'Channel.ReadBasic.All', - 'ChannelMessage.Send', - 'ChannelMessage.Read.All', - 'ChannelMessage.ReadWrite', - 'ChannelMember.Read.All', - 'Group.Read.All', - 'Group.ReadWrite.All', - 'Team.ReadBasic.All', - 'TeamMember.Read.All', - 'offline_access', - 'Files.Read', - 'Sites.Read.All', - ], + scopes: getCanonicalScopesForProvider('microsoft-teams'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1435,7 +1353,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], + scopes: getCanonicalScopesForProvider('microsoft-excel'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1474,13 +1392,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: [ - 'openid', - 'profile', - 'email', - 'https://dynamics.microsoft.com/user_impersonation', - 'offline_access', - ], + scopes: getCanonicalScopesForProvider('microsoft-dataverse'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1522,15 +1434,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: [ - 'openid', - 'profile', - 'email', - 'Group.ReadWrite.All', - 'Group.Read.All', - 'Tasks.ReadWrite', - 'offline_access', - ], + scopes: getCanonicalScopesForProvider('microsoft-planner'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1570,16 +1474,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: [ - 'openid', - 'profile', - 'email', - 'Mail.ReadWrite', - 'Mail.ReadBasic', - 'Mail.Read', - 'Mail.Send', - 'offline_access', - ], + scopes: getCanonicalScopesForProvider('outlook'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1619,7 +1514,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], + scopes: getCanonicalScopesForProvider('onedrive'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1659,15 +1554,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', userInfoUrl: 'https://graph.microsoft.com/v1.0/me', - scopes: [ - 'openid', - 'profile', - 'email', - 'Sites.Read.All', - 'Sites.ReadWrite.All', - 'Sites.Manage.All', - 'offline_access', - ], + scopes: getCanonicalScopesForProvider('sharepoint'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -1707,7 +1594,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize', tokenUrl: 'https://app.crmworkspace.com/oauth/token', userInfoUrl: 'https://dummy-not-used.wealthbox.com', // Dummy URL since no user info endpoint exists - scopes: ['login', 'data'], + scopes: getCanonicalScopesForProvider('wealthbox'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`, getUserInfo: async (_tokens) => { @@ -1740,15 +1627,7 @@ export const auth = betterAuth({ tokenUrl: 'https://oauth.pipedrive.com/oauth/token', userInfoUrl: 'https://api.pipedrive.com/v1/users/me', prompt: 'consent', - scopes: [ - 'base', - 'deals:full', - 'contacts:full', - 'leads:full', - 'activities:full', - 'mail:full', - 'projects:full', - ], + scopes: getCanonicalScopesForProvider('pipedrive'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`, getUserInfo: async (tokens) => { @@ -1797,31 +1676,7 @@ export const auth = betterAuth({ tokenUrl: 'https://api.hubapi.com/oauth/v1/token', userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens', prompt: 'consent', - scopes: [ - 'crm.objects.contacts.read', - 'crm.objects.contacts.write', - 'crm.objects.companies.read', - 'crm.objects.companies.write', - 'crm.objects.deals.read', - 'crm.objects.deals.write', - 'crm.objects.owners.read', - 'crm.objects.users.read', - 'crm.objects.users.write', - 'crm.objects.marketing_events.read', - 'crm.objects.marketing_events.write', - 'crm.objects.line_items.read', - 'crm.objects.line_items.write', - 'crm.objects.quotes.read', - 'crm.objects.quotes.write', - 'crm.objects.appointments.read', - 'crm.objects.appointments.write', - 'crm.objects.carts.read', - 'crm.objects.carts.write', - 'crm.import', - 'crm.lists.read', - 'crm.lists.write', - 'tickets', - ], + scopes: getCanonicalScopesForProvider('hubspot'), redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`, getUserInfo: async (tokens) => { try { @@ -1893,7 +1748,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', - scopes: ['api', 'refresh_token', 'openid', 'offline_access'], + scopes: getCanonicalScopesForProvider('salesforce'), pkce: true, prompt: 'consent', accessType: 'offline', @@ -1944,23 +1799,7 @@ export const auth = betterAuth({ tokenUrl: 'https://api.x.com/2/oauth2/token', userInfoUrl: 'https://api.x.com/2/users/me', accessType: 'offline', - scopes: [ - 'tweet.read', - 'tweet.write', - 'tweet.moderate.write', - 'users.read', - 'follows.read', - 'follows.write', - 'bookmark.read', - 'bookmark.write', - 'like.read', - 'like.write', - 'block.read', - 'block.write', - 'mute.read', - 'mute.write', - 'offline.access', - ], + scopes: getCanonicalScopesForProvider('x'), pkce: true, responseType: 'code', prompt: 'consent', @@ -2019,45 +1858,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://auth.atlassian.com/authorize', tokenUrl: 'https://auth.atlassian.com/oauth/token', userInfoUrl: 'https://api.atlassian.com/me', - scopes: [ - 'read:confluence-content.all', - 'read:confluence-space.summary', - 'read:space:confluence', - 'read:space-details:confluence', - 'write:confluence-content', - 'write:confluence-space', - 'write:confluence-file', - 'read:page:confluence', - 'write:page:confluence', - 'read:comment:confluence', - 'read:content:confluence', - 'write:comment:confluence', - 'delete:comment:confluence', - 'read:attachment:confluence', - 'write:attachment:confluence', - 'delete:attachment:confluence', - 'delete:page:confluence', - 'read:label:confluence', - 'write:label:confluence', - 'search:confluence', - 'read:me', - 'offline_access', - 'read:blogpost:confluence', - 'write:blogpost:confluence', - 'read:content.property:confluence', - 'write:content.property:confluence', - 'read:hierarchical-content:confluence', - 'read:content.metadata:confluence', - 'read:user:confluence', - 'read:task:confluence', - 'write:task:confluence', - 'delete:blogpost:confluence', - 'write:space:confluence', - 'delete:space:confluence', - 'read:space.property:confluence', - 'write:space.property:confluence', - 'read:space.permission:confluence', - ], + scopes: getCanonicalScopesForProvider('confluence'), responseType: 'code', pkce: true, accessType: 'offline', @@ -2109,67 +1910,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://auth.atlassian.com/authorize', tokenUrl: 'https://auth.atlassian.com/oauth/token', userInfoUrl: 'https://api.atlassian.com/me', - scopes: [ - 'read:jira-user', - 'read:jira-work', - 'write:jira-work', - 'write:issue:jira', - 'read:project:jira', - 'read:issue-type:jira', - 'read:me', - 'offline_access', - 'read:issue-meta:jira', - 'read:issue-security-level:jira', - 'read:issue.vote:jira', - 'read:issue.changelog:jira', - 'read:avatar:jira', - 'read:issue:jira', - 'read:status:jira', - 'read:user:jira', - 'read:field-configuration:jira', - 'read:issue-details:jira', - 'read:issue-event:jira', - 'delete:issue:jira', - 'write:comment:jira', - 'read:comment:jira', - 'delete:comment:jira', - 'read:attachment:jira', - 'delete:attachment:jira', - 'write:issue-worklog:jira', - 'read:issue-worklog:jira', - 'delete:issue-worklog:jira', - 'write:issue-link:jira', - 'delete:issue-link:jira', - // Jira Service Management scopes - 'read:servicedesk:jira-service-management', - 'read:requesttype:jira-service-management', - 'read:request:jira-service-management', - 'write:request:jira-service-management', - 'read:request.comment:jira-service-management', - 'write:request.comment:jira-service-management', - 'read:customer:jira-service-management', - 'write:customer:jira-service-management', - 'read:servicedesk.customer:jira-service-management', - 'write:servicedesk.customer:jira-service-management', - 'read:organization:jira-service-management', - 'write:organization:jira-service-management', - 'read:servicedesk.organization:jira-service-management', - 'write:servicedesk.organization:jira-service-management', - 'read:organization.user:jira-service-management', - 'write:organization.user:jira-service-management', - 'read:organization.property:jira-service-management', - 'write:organization.property:jira-service-management', - 'read:organization.profile:jira-service-management', - 'write:organization.profile:jira-service-management', - 'read:queue:jira-service-management', - 'read:request.sla:jira-service-management', - 'read:request.status:jira-service-management', - 'write:request.status:jira-service-management', - 'read:request.participant:jira-service-management', - 'write:request.participant:jira-service-management', - 'read:request.approval:jira-service-management', - 'write:request.approval:jira-service-management', - ], + scopes: getCanonicalScopesForProvider('jira'), responseType: 'code', pkce: true, accessType: 'offline', @@ -2221,13 +1962,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://airtable.com/oauth2/v1/authorize', tokenUrl: 'https://airtable.com/oauth2/v1/token', userInfoUrl: 'https://api.airtable.com/v0/meta/whoami', - scopes: [ - 'data.records:read', - 'data.records:write', - 'schema.bases:read', - 'user.email:read', - 'webhook:manage', - ], + scopes: getCanonicalScopesForProvider('airtable'), responseType: 'code', pkce: true, accessType: 'offline', @@ -2327,24 +2062,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent', tokenUrl: 'https://www.reddit.com/api/v1/access_token', userInfoUrl: 'https://oauth.reddit.com/api/v1/me', - scopes: [ - 'identity', - 'read', - 'submit', - 'vote', - 'save', - 'edit', - 'subscribe', - 'history', - 'privatemessages', - 'account', - 'mysubreddits', - 'flair', - 'report', - 'modposts', - 'modflair', - 'modmail', - ], + scopes: getCanonicalScopesForProvider('reddit'), responseType: 'code', pkce: false, accessType: 'offline', @@ -2394,7 +2112,7 @@ export const auth = betterAuth({ clientSecret: env.LINEAR_CLIENT_SECRET as string, authorizationUrl: 'https://linear.app/oauth/authorize', tokenUrl: 'https://api.linear.app/oauth/token', - scopes: ['read', 'write'], + scopes: getCanonicalScopesForProvider('linear'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`, pkce: true, @@ -2466,17 +2184,7 @@ export const auth = betterAuth({ clientSecret: env.ATTIO_CLIENT_SECRET as string, authorizationUrl: 'https://app.attio.com/authorize', tokenUrl: 'https://app.attio.com/oauth/token', - scopes: [ - 'record_permission:read-write', - 'object_configuration:read-write', - 'list_configuration:read-write', - 'list_entry:read-write', - 'note:read-write', - 'task:read-write', - 'comment:read-write', - 'user_management:read', - 'webhook:read-write', - ], + scopes: getCanonicalScopesForProvider('attio'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`, getUserInfo: async (tokens) => { @@ -2529,15 +2237,7 @@ export const auth = betterAuth({ clientSecret: env.DROPBOX_CLIENT_SECRET as string, authorizationUrl: 'https://www.dropbox.com/oauth2/authorize', tokenUrl: 'https://api.dropboxapi.com/oauth2/token', - scopes: [ - 'account_info.read', - 'files.metadata.read', - 'files.metadata.write', - 'files.content.read', - 'files.content.write', - 'sharing.read', - 'sharing.write', - ], + scopes: getCanonicalScopesForProvider('dropbox'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`, pkce: true, @@ -2593,7 +2293,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://app.asana.com/-/oauth_authorize', tokenUrl: 'https://app.asana.com/-/oauth_token', userInfoUrl: 'https://app.asana.com/api/1.0/users/me', - scopes: ['default'], + scopes: getCanonicalScopesForProvider('asana'), responseType: 'code', pkce: false, accessType: 'offline', @@ -2646,23 +2346,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://slack.com/oauth/v2/authorize', tokenUrl: 'https://slack.com/api/oauth.v2.access', userInfoUrl: 'https://slack.com/api/users.identity', - scopes: [ - // Bot token scopes only - app acts as a bot user - 'channels:read', - 'channels:history', - 'groups:read', - 'groups:history', - 'chat:write', - 'chat:write.public', - 'im:write', - 'im:history', - 'im:read', - 'users:read', - 'files:write', - 'files:read', - 'canvases:write', - 'reactions:write', - ], + scopes: getCanonicalScopesForProvider('slack'), responseType: 'code', accessType: 'offline', prompt: 'consent', @@ -2722,7 +2406,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://webflow.com/oauth/authorize', tokenUrl: 'https://api.webflow.com/oauth/access_token', userInfoUrl: 'https://api.webflow.com/v2/token/introspect', - scopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write', 'forms:read'], + scopes: getCanonicalScopesForProvider('webflow'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`, getUserInfo: async (tokens) => { @@ -2772,7 +2456,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', userInfoUrl: 'https://api.linkedin.com/v2/userinfo', - scopes: ['profile', 'openid', 'email', 'w_member_social'], + scopes: getCanonicalScopesForProvider('linkedin'), responseType: 'code', accessType: 'offline', prompt: 'consent', @@ -2822,19 +2506,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://zoom.us/oauth/authorize', tokenUrl: 'https://zoom.us/oauth/token', userInfoUrl: 'https://api.zoom.us/v2/users/me', - scopes: [ - 'user:read:user', - 'meeting:write:meeting', - 'meeting:read:meeting', - 'meeting:read:list_meetings', - 'meeting:update:meeting', - 'meeting:delete:meeting', - 'meeting:read:invitation', - 'meeting:read:list_past_participants', - 'cloud_recording:read:list_user_recordings', - 'cloud_recording:read:list_recording_files', - 'cloud_recording:delete:recording_file', - ], + scopes: getCanonicalScopesForProvider('zoom'), responseType: 'code', accessType: 'offline', authentication: 'basic', @@ -2886,25 +2558,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://accounts.spotify.com/authorize', tokenUrl: 'https://accounts.spotify.com/api/token', userInfoUrl: 'https://api.spotify.com/v1/me', - scopes: [ - 'user-read-private', - 'user-read-email', - 'user-library-read', - 'user-library-modify', - 'playlist-read-private', - 'playlist-read-collaborative', - 'playlist-modify-public', - 'playlist-modify-private', - 'user-read-playback-state', - 'user-modify-playback-state', - 'user-read-currently-playing', - 'user-read-recently-played', - 'user-top-read', - 'user-follow-read', - 'user-follow-modify', - 'user-read-playback-position', - 'ugc-image-upload', - ], + scopes: getCanonicalScopesForProvider('spotify'), responseType: 'code', authentication: 'basic', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`, @@ -2953,7 +2607,7 @@ export const auth = betterAuth({ authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize', tokenUrl: 'https://public-api.wordpress.com/oauth2/token', userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me', - scopes: ['global'], + scopes: getCanonicalScopesForProvider('wordpress'), responseType: 'code', prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`, @@ -3000,7 +2654,7 @@ export const auth = betterAuth({ clientId: env.CALCOM_CLIENT_ID as string, authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize', tokenUrl: 'https://app.cal.com/api/auth/oauth/token', - scopes: [], + scopes: getCanonicalScopesForProvider('calcom'), responseType: 'code', pkce: true, accessType: 'offline', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2cb5f1f1156..0e0221d743c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -63,6 +63,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GmailIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.labels', @@ -75,6 +77,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GoogleDriveIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive', ], @@ -86,6 +90,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GoogleDocsIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive', ], @@ -97,6 +103,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GoogleSheetsIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive', ], @@ -121,7 +129,11 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-calendar', icon: GoogleCalendarIcon, baseProviderIcon: GoogleIcon, - scopes: ['https://www.googleapis.com/auth/calendar'], + scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/calendar', + ], }, 'google-contacts': { name: 'Google Contacts', @@ -129,7 +141,11 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-contacts', icon: GoogleContactsIcon, baseProviderIcon: GoogleIcon, - scopes: ['https://www.googleapis.com/auth/contacts'], + scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/contacts', + ], }, 'google-bigquery': { name: 'Google BigQuery', @@ -137,7 +153,11 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-bigquery', icon: GoogleBigQueryIcon, baseProviderIcon: GoogleIcon, - scopes: ['https://www.googleapis.com/auth/bigquery'], + scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/bigquery', + ], }, 'google-tasks': { name: 'Google Tasks', @@ -145,7 +165,11 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'google-tasks', icon: GoogleTasksIcon, baseProviderIcon: GoogleIcon, - scopes: ['https://www.googleapis.com/auth/tasks'], + scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/tasks', + ], }, 'google-vault': { name: 'Google Vault', @@ -154,6 +178,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GoogleIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/ediscovery', 'https://www.googleapis.com/auth/devstorage.read_only', ], @@ -165,6 +191,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GoogleGroupsIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/admin.directory.group', 'https://www.googleapis.com/auth/admin.directory.group.member', ], @@ -176,6 +204,8 @@ export const OAUTH_PROVIDERS: Record = { icon: GoogleMeetIcon, baseProviderIcon: GoogleIcon, scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/meetings.space.created', 'https://www.googleapis.com/auth/meetings.space.readonly', ], @@ -186,7 +216,11 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'vertex-ai', icon: VertexIcon, baseProviderIcon: VertexIcon, - scopes: ['https://www.googleapis.com/auth/cloud-platform'], + scopes: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cloud-platform', + ], }, }, defaultService: 'gmail', @@ -671,7 +705,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'webflow', icon: WebflowIcon, baseProviderIcon: WebflowIcon, - scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write'], + scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write', 'forms:read'], }, }, defaultService: 'webflow', diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 23cfb721630..134e8513c86 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -122,14 +122,6 @@ export interface OAuthServiceMetadata { baseProvider: string } -export interface ScopeEvaluation { - canonicalScopes: string[] - grantedScopes: string[] - missingScopes: string[] - extraScopes: string[] - requiresReauthorization: boolean -} - export interface Credential { id: string name: string @@ -138,10 +130,6 @@ export interface Credential { lastUsed?: string isDefault?: boolean scopes?: string[] - canonicalScopes?: string[] - missingScopes?: string[] - extraScopes?: string[] - requiresReauthorization?: boolean } export interface ProviderConfig { diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index 08fa08a2295..797f928a9fa 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest' import type { OAuthProvider, OAuthServiceMetadata } from './types' import { - evaluateScopeCoverage, getAllOAuthServices, getCanonicalScopesForProvider, + getMissingRequiredScopes, getProviderIdFromServiceId, + getScopesForService, getServiceByProviderAndId, getServiceConfigByProviderId, - normalizeScopes, parseProvider, } from './utils' @@ -361,209 +361,6 @@ describe('getCanonicalScopesForProvider', () => { }) }) -describe('normalizeScopes', () => { - it.concurrent('should remove duplicates from scope array', () => { - const scopes = ['scope1', 'scope2', 'scope1', 'scope3', 'scope2'] - const normalized = normalizeScopes(scopes) - - expect(normalized.length).toBe(3) - expect(normalized).toContain('scope1') - expect(normalized).toContain('scope2') - expect(normalized).toContain('scope3') - }) - - it.concurrent('should trim whitespace from scopes', () => { - const scopes = [' scope1 ', 'scope2', ' scope3 '] - const normalized = normalizeScopes(scopes) - - expect(normalized).toEqual(['scope1', 'scope2', 'scope3']) - }) - - it.concurrent('should remove empty strings', () => { - const scopes = ['scope1', '', 'scope2', ' ', 'scope3'] - const normalized = normalizeScopes(scopes) - - expect(normalized.length).toBe(3) - expect(normalized).toEqual(['scope1', 'scope2', 'scope3']) - }) - - it.concurrent('should handle empty array', () => { - const normalized = normalizeScopes([]) - - expect(Array.isArray(normalized)).toBe(true) - expect(normalized.length).toBe(0) - }) - - it.concurrent('should handle array with only empty strings', () => { - const normalized = normalizeScopes(['', ' ', ' ']) - - expect(Array.isArray(normalized)).toBe(true) - expect(normalized.length).toBe(0) - }) - - it.concurrent('should preserve order of first occurrence', () => { - const scopes = ['scope3', 'scope1', 'scope2', 'scope1', 'scope3'] - const normalized = normalizeScopes(scopes) - - expect(normalized).toEqual(['scope3', 'scope1', 'scope2']) - }) - - it.concurrent('should handle scopes with special characters', () => { - const scopes = [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.send', - ] - const normalized = normalizeScopes(scopes) - - expect(normalized.length).toBe(2) - expect(normalized).toContain('https://www.googleapis.com/auth/gmail.send') - expect(normalized).toContain('https://www.googleapis.com/auth/gmail.modify') - }) - - it.concurrent('should handle single scope', () => { - const normalized = normalizeScopes(['scope1']) - - expect(normalized).toEqual(['scope1']) - }) - - it.concurrent('should handle scopes with mixed whitespace', () => { - const scopes = ['scope1', '\tscope2\t', '\nscope3\n', ' scope1 '] - const normalized = normalizeScopes(scopes) - - expect(normalized.length).toBe(3) - expect(normalized).toContain('scope1') - expect(normalized).toContain('scope2') - expect(normalized).toContain('scope3') - }) -}) - -describe('evaluateScopeCoverage', () => { - it.concurrent('should identify missing scopes', () => { - const evaluation = evaluateScopeCoverage('google-email', [ - 'https://www.googleapis.com/auth/gmail.send', - ]) - - expect(evaluation.missingScopes.length).toBeGreaterThan(0) - expect(evaluation.missingScopes).toContain('https://www.googleapis.com/auth/gmail.modify') - expect(evaluation.requiresReauthorization).toBe(true) - }) - - it.concurrent('should identify extra scopes', () => { - const evaluation = evaluateScopeCoverage('google-email', [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.labels', - 'https://www.googleapis.com/auth/calendar', - ]) - - expect(evaluation.extraScopes.length).toBe(1) - expect(evaluation.extraScopes).toContain('https://www.googleapis.com/auth/calendar') - }) - - it.concurrent('should return no missing scopes when all are present', () => { - const evaluation = evaluateScopeCoverage('google-email', [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.labels', - ]) - - expect(evaluation.missingScopes.length).toBe(0) - expect(evaluation.requiresReauthorization).toBe(false) - }) - - it.concurrent('should normalize granted scopes before evaluation', () => { - const evaluation = evaluateScopeCoverage('google-email', [ - ' https://www.googleapis.com/auth/gmail.send ', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.labels', - 'https://www.googleapis.com/auth/gmail.send', - ]) - - expect(evaluation.grantedScopes.length).toBe(3) - expect(evaluation.missingScopes.length).toBe(0) - expect(evaluation.requiresReauthorization).toBe(false) - }) - - it.concurrent('should handle empty granted scopes', () => { - const evaluation = evaluateScopeCoverage('google-email', []) - - expect(evaluation.grantedScopes.length).toBe(0) - expect(evaluation.missingScopes.length).toBeGreaterThan(0) - expect(evaluation.requiresReauthorization).toBe(true) - }) - - it.concurrent('should return correct structure', () => { - const evaluation = evaluateScopeCoverage('google-email', [ - 'https://www.googleapis.com/auth/gmail.send', - ]) - - expect(evaluation).toHaveProperty('canonicalScopes') - expect(evaluation).toHaveProperty('grantedScopes') - expect(evaluation).toHaveProperty('missingScopes') - expect(evaluation).toHaveProperty('extraScopes') - expect(evaluation).toHaveProperty('requiresReauthorization') - - expect(Array.isArray(evaluation.canonicalScopes)).toBe(true) - expect(Array.isArray(evaluation.grantedScopes)).toBe(true) - expect(Array.isArray(evaluation.missingScopes)).toBe(true) - expect(Array.isArray(evaluation.extraScopes)).toBe(true) - expect(typeof evaluation.requiresReauthorization).toBe('boolean') - }) - - it.concurrent('should handle provider with no scopes', () => { - const evaluation = evaluateScopeCoverage('notion', []) - - expect(evaluation.canonicalScopes.length).toBe(0) - expect(evaluation.missingScopes.length).toBe(0) - expect(evaluation.requiresReauthorization).toBe(false) - }) - - it.concurrent('should handle provider with no scopes but granted scopes present', () => { - const evaluation = evaluateScopeCoverage('notion', ['some.scope', 'another.scope']) - - expect(evaluation.canonicalScopes.length).toBe(0) - expect(evaluation.missingScopes.length).toBe(0) - expect(evaluation.extraScopes.length).toBe(2) - expect(evaluation.extraScopes).toContain('some.scope') - expect(evaluation.extraScopes).toContain('another.scope') - expect(evaluation.requiresReauthorization).toBe(false) - }) - - it.concurrent('should handle invalid provider', () => { - const evaluation = evaluateScopeCoverage('invalid-provider', ['scope1', 'scope2']) - - expect(evaluation.canonicalScopes.length).toBe(0) - expect(evaluation.grantedScopes.length).toBe(2) - expect(evaluation.missingScopes.length).toBe(0) - expect(evaluation.extraScopes.length).toBe(2) - expect(evaluation.requiresReauthorization).toBe(false) - }) - - it.concurrent('should work with Microsoft services', () => { - const evaluation = evaluateScopeCoverage('outlook', [ - 'openid', - 'profile', - 'email', - 'Mail.ReadWrite', - 'Mail.Send', - ]) - - expect(evaluation.canonicalScopes.length).toBeGreaterThan(0) - expect(evaluation.missingScopes.length).toBeGreaterThan(0) - expect(evaluation.requiresReauthorization).toBe(true) - }) - - it.concurrent('should handle exact match with no extra or missing scopes', () => { - const canonicalScopes = getCanonicalScopesForProvider('linear') - const evaluation = evaluateScopeCoverage('linear', [...canonicalScopes]) - - expect(evaluation.missingScopes.length).toBe(0) - expect(evaluation.extraScopes.length).toBe(0) - expect(evaluation.requiresReauthorization).toBe(false) - }) -}) - describe('parseProvider', () => { it.concurrent('should parse simple provider without hyphen', () => { const config = parseProvider('slack' as OAuthProvider) @@ -802,3 +599,111 @@ describe('parseProvider', () => { expect(config.featureType).toBe('sharepoint') }) }) + +describe('getScopesForService', () => { + it.concurrent('should return scopes for a valid serviceId', () => { + const scopes = getScopesForService('gmail') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBeGreaterThan(0) + expect(scopes).toContain('https://www.googleapis.com/auth/gmail.send') + }) + + it.concurrent('should return empty array for unknown serviceId', () => { + const scopes = getScopesForService('nonexistent-service') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) + + it.concurrent('should return new array instance (not reference)', () => { + const scopes1 = getScopesForService('gmail') + const scopes2 = getScopesForService('gmail') + + expect(scopes1).not.toBe(scopes2) + expect(scopes1).toEqual(scopes2) + }) + + it.concurrent('should work for Microsoft services', () => { + const scopes = getScopesForService('outlook') + + expect(scopes.length).toBeGreaterThan(0) + expect(scopes).toContain('Mail.ReadWrite') + }) + + it.concurrent('should return empty array for empty string', () => { + const scopes = getScopesForService('') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) +}) + +describe('getMissingRequiredScopes', () => { + it.concurrent('should return empty array when all scopes are granted', () => { + const credential = { scopes: ['read', 'write'] } + const missing = getMissingRequiredScopes(credential, ['read', 'write']) + + expect(missing).toEqual([]) + }) + + it.concurrent('should return missing scopes', () => { + const credential = { scopes: ['read'] } + const missing = getMissingRequiredScopes(credential, ['read', 'write']) + + expect(missing).toEqual(['write']) + }) + + it.concurrent('should return all required scopes when credential is undefined', () => { + const missing = getMissingRequiredScopes(undefined, ['read', 'write']) + + expect(missing).toEqual(['read', 'write']) + }) + + it.concurrent('should return all required scopes when credential has undefined scopes', () => { + const missing = getMissingRequiredScopes({ scopes: undefined }, ['read', 'write']) + + expect(missing).toEqual(['read', 'write']) + }) + + it.concurrent('should ignore offline_access in required scopes', () => { + const credential = { scopes: ['read'] } + const missing = getMissingRequiredScopes(credential, ['read', 'offline_access']) + + expect(missing).toEqual([]) + }) + + it.concurrent('should ignore refresh_token in required scopes', () => { + const credential = { scopes: ['read'] } + const missing = getMissingRequiredScopes(credential, ['read', 'refresh_token']) + + expect(missing).toEqual([]) + }) + + it.concurrent('should ignore offline.access in required scopes', () => { + const credential = { scopes: ['read'] } + const missing = getMissingRequiredScopes(credential, ['read', 'offline.access']) + + expect(missing).toEqual([]) + }) + + it.concurrent('should filter ignored scopes even when credential is undefined', () => { + const missing = getMissingRequiredScopes(undefined, ['read', 'offline_access', 'refresh_token']) + + expect(missing).toEqual(['read']) + }) + + it.concurrent('should return empty array when requiredScopes is empty', () => { + const credential = { scopes: ['read'] } + const missing = getMissingRequiredScopes(credential, []) + + expect(missing).toEqual([]) + }) + + it.concurrent('should return empty array when requiredScopes defaults to empty', () => { + const credential = { scopes: ['read'] } + const missing = getMissingRequiredScopes(credential) + + expect(missing).toEqual([]) + }) +}) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 989b0c3ce32..6cdee0220ed 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -4,9 +4,411 @@ import type { OAuthServiceConfig, OAuthServiceMetadata, ProviderConfig, - ScopeEvaluation, } from './types' +/** + * Centralized human-readable descriptions for OAuth scopes. + * Used by the OAuth Required Modal and available for any UI that needs to display scope info. + */ +export const SCOPE_DESCRIPTIONS: Record = { + // Google scopes + 'https://www.googleapis.com/auth/gmail.send': 'Send emails', + 'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels', + 'https://www.googleapis.com/auth/gmail.modify': 'View and manage email messages', + 'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files', + 'https://www.googleapis.com/auth/drive': 'Access all Google Drive files', + 'https://www.googleapis.com/auth/calendar': 'View and manage calendar', + 'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts', + 'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks', + 'https://www.googleapis.com/auth/userinfo.email': 'View email address', + 'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info', + 'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms', + 'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms', + 'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery', + 'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery', + 'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage', + 'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups', + 'https://www.googleapis.com/auth/admin.directory.group.member': + 'Manage Google Workspace group memberships', + 'https://www.googleapis.com/auth/admin.directory.group.readonly': 'View Google Workspace groups', + 'https://www.googleapis.com/auth/admin.directory.group.member.readonly': + 'View Google Workspace group memberships', + 'https://www.googleapis.com/auth/meetings.space.created': + 'Create and manage Google Meet meeting spaces', + 'https://www.googleapis.com/auth/meetings.space.readonly': + 'View Google Meet meeting space details', + 'https://www.googleapis.com/auth/cloud-platform': + 'Full access to Google Cloud resources for Vertex AI', + + // Confluence scopes + 'read:confluence-content.all': 'Read all Confluence content', + 'read:confluence-space.summary': 'Read Confluence space information', + 'read:space:confluence': 'View Confluence spaces', + 'read:space-details:confluence': 'View detailed Confluence space information', + 'write:confluence-content': 'Create and edit Confluence pages', + 'write:confluence-space': 'Manage Confluence spaces', + 'write:confluence-file': 'Upload files to Confluence', + 'read:content:confluence': 'Read Confluence content', + 'read:page:confluence': 'View Confluence pages', + 'write:page:confluence': 'Create and update Confluence pages', + 'read:comment:confluence': 'View comments on Confluence pages', + 'write:comment:confluence': 'Create and update comments', + 'delete:comment:confluence': 'Delete comments from Confluence pages', + 'read:attachment:confluence': 'View attachments on Confluence pages', + 'write:attachment:confluence': 'Upload and manage attachments', + 'delete:attachment:confluence': 'Delete attachments from Confluence pages', + 'delete:page:confluence': 'Delete Confluence pages', + 'read:label:confluence': 'View labels on Confluence content', + 'write:label:confluence': 'Add and remove labels', + 'search:confluence': 'Search Confluence content', + 'readonly:content.attachment:confluence': 'View attachments', + 'read:blogpost:confluence': 'View Confluence blog posts', + 'write:blogpost:confluence': 'Create and update Confluence blog posts', + 'read:content.property:confluence': 'View properties on Confluence content', + 'write:content.property:confluence': 'Create and manage content properties', + 'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)', + 'read:content.metadata:confluence': 'View content metadata (required for ancestors)', + 'read:user:confluence': 'View Confluence user profiles', + 'read:task:confluence': 'View Confluence inline tasks', + 'write:task:confluence': 'Update Confluence inline tasks', + 'delete:blogpost:confluence': 'Delete Confluence blog posts', + 'write:space:confluence': 'Create and update Confluence spaces', + 'delete:space:confluence': 'Delete Confluence spaces', + 'read:space.property:confluence': 'View Confluence space properties', + 'write:space.property:confluence': 'Create and manage space properties', + 'read:space.permission:confluence': 'View Confluence space permissions', + + // Common scopes + 'read:me': 'Read profile information', + offline_access: 'Access account when not using the application', + openid: 'Standard authentication', + profile: 'Access profile information', + email: 'Access email address', + + // Notion scopes + 'database.read': 'Read database', + 'database.write': 'Write to database', + 'projects.read': 'Read projects', + 'page.read': 'Read Notion pages', + 'page.write': 'Write to Notion pages', + 'workspace.content': 'Read Notion content', + 'workspace.name': 'Read Notion workspace name', + 'workspace.read': 'Read Notion workspace', + 'workspace.write': 'Write to Notion workspace', + 'user.email:read': 'Read email address', + + // GitHub scopes + repo: 'Access repositories', + workflow: 'Manage repository workflows', + 'read:user': 'Read public user information', + 'user:email': 'Access email address', + + // X (Twitter) scopes + 'tweet.read': 'Read tweets and timeline', + 'tweet.write': 'Post and delete tweets', + 'tweet.moderate.write': 'Hide and unhide replies to tweets', + 'users.read': 'Read user profiles and account information', + 'follows.read': 'View followers and following lists', + 'follows.write': 'Follow and unfollow users', + 'bookmark.read': 'View bookmarked tweets', + 'bookmark.write': 'Add and remove bookmarks', + 'like.read': 'View liked tweets and liking users', + 'like.write': 'Like and unlike tweets', + 'block.read': 'View blocked users', + 'block.write': 'Block and unblock users', + 'mute.read': 'View muted users', + 'mute.write': 'Mute and unmute users', + 'offline.access': 'Access account when not using the application', + + // Airtable scopes + 'data.records:read': 'Read records', + 'data.records:write': 'Write to records', + 'schema.bases:read': 'View bases and tables', + 'webhook:manage': 'Manage webhooks', + + // Jira scopes + 'read:jira-user': 'Read Jira user', + 'read:jira-work': 'Read Jira work', + 'write:jira-work': 'Write to Jira work', + 'manage:jira-webhook': 'Register and manage Jira webhooks', + 'read:webhook:jira': 'View Jira webhooks', + 'write:webhook:jira': 'Create and update Jira webhooks', + 'delete:webhook:jira': 'Delete Jira webhooks', + 'read:issue-event:jira': 'Read Jira issue events', + 'write:issue:jira': 'Write to Jira issues', + 'read:project:jira': 'Read Jira projects', + 'read:issue-type:jira': 'Read Jira issue types', + 'read:issue-meta:jira': 'Read Jira issue meta', + 'read:issue-security-level:jira': 'Read Jira issue security level', + 'read:issue.vote:jira': 'Read Jira issue votes', + 'read:issue.changelog:jira': 'Read Jira issue changelog', + 'read:avatar:jira': 'Read Jira avatar', + 'read:issue:jira': 'Read Jira issues', + 'read:status:jira': 'Read Jira status', + 'read:user:jira': 'Read Jira user', + 'read:field-configuration:jira': 'Read Jira field configuration', + 'read:issue-details:jira': 'Read Jira issue details', + 'read:field:jira': 'Read Jira field configurations', + 'read:jql:jira': 'Use JQL to filter Jira issues', + 'read:comment.property:jira': 'Read Jira comment properties', + 'read:issue.property:jira': 'Read Jira issue properties', + 'delete:issue:jira': 'Delete Jira issues', + 'write:comment:jira': 'Add and update comments on Jira issues', + 'read:comment:jira': 'Read comments on Jira issues', + 'delete:comment:jira': 'Delete comments from Jira issues', + 'read:attachment:jira': 'Read attachments from Jira issues', + 'delete:attachment:jira': 'Delete attachments from Jira issues', + 'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues', + 'read:issue-worklog:jira': 'Read worklog entries from Jira issues', + 'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues', + 'write:issue-link:jira': 'Create links between Jira issues', + 'delete:issue-link:jira': 'Delete links between Jira issues', + + // Jira Service Management scopes + 'read:servicedesk:jira-service-management': 'View service desks and their settings', + 'read:requesttype:jira-service-management': 'View request types available in service desks', + 'read:request:jira-service-management': 'View customer requests in service desks', + 'write:request:jira-service-management': 'Create customer requests in service desks', + 'read:request.comment:jira-service-management': 'View comments on customer requests', + 'write:request.comment:jira-service-management': 'Add comments to customer requests', + 'read:customer:jira-service-management': 'View customer information', + 'write:customer:jira-service-management': 'Create and manage customers', + 'read:servicedesk.customer:jira-service-management': 'View customers linked to service desks', + 'write:servicedesk.customer:jira-service-management': + 'Add and remove customers from service desks', + 'read:organization:jira-service-management': 'View organizations', + 'write:organization:jira-service-management': 'Create and manage organizations', + 'read:servicedesk.organization:jira-service-management': + 'View organizations linked to service desks', + 'write:servicedesk.organization:jira-service-management': + 'Add and remove organizations from service desks', + 'read:organization.user:jira-service-management': 'View users in organizations', + 'write:organization.user:jira-service-management': 'Add and remove users from organizations', + 'read:organization.property:jira-service-management': 'View organization properties', + 'write:organization.property:jira-service-management': + 'Create and manage organization properties', + 'read:organization.profile:jira-service-management': 'View organization profiles', + 'write:organization.profile:jira-service-management': 'Update organization profiles', + 'read:queue:jira-service-management': 'View service desk queues and their issues', + 'read:request.sla:jira-service-management': 'View SLA information for customer requests', + 'read:request.status:jira-service-management': 'View status of customer requests', + 'write:request.status:jira-service-management': 'Transition customer request status', + 'read:request.participant:jira-service-management': 'View participants on customer requests', + 'write:request.participant:jira-service-management': + 'Add and remove participants from customer requests', + 'read:request.approval:jira-service-management': 'View approvals on customer requests', + 'write:request.approval:jira-service-management': 'Approve or decline customer requests', + + // Microsoft scopes + 'User.Read': 'Read Microsoft user', + 'Chat.Read': 'Read Microsoft chats', + 'Chat.ReadWrite': 'Write to Microsoft chats', + 'Chat.ReadBasic': 'Read Microsoft chats', + 'ChatMessage.Send': 'Send chat messages', + 'Channel.ReadBasic.All': 'Read Microsoft channels', + 'ChannelMessage.Send': 'Write to Microsoft channels', + 'ChannelMessage.Read.All': 'Read Microsoft channels', + 'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels', + 'ChannelMember.Read.All': 'Read team channel members', + 'Group.Read.All': 'Read Microsoft groups', + 'Group.ReadWrite.All': 'Write to Microsoft groups', + 'Team.ReadBasic.All': 'Read Microsoft teams', + 'TeamMember.Read.All': 'Read team members', + 'Mail.ReadWrite': 'Write to Microsoft emails', + 'Mail.ReadBasic': 'Read Microsoft emails', + 'Mail.Read': 'Read Microsoft emails', + 'Mail.Send': 'Send emails', + 'Files.Read': 'Read OneDrive files', + 'Files.ReadWrite': 'Read and write OneDrive files', + 'Tasks.ReadWrite': 'Read and manage Planner tasks', + 'Sites.Read.All': 'Read Sharepoint sites', + 'Sites.ReadWrite.All': 'Read and write Sharepoint sites', + 'Sites.Manage.All': 'Manage Sharepoint sites', + 'https://dynamics.microsoft.com/user_impersonation': 'Access Microsoft Dataverse on your behalf', + + // Discord scopes + identify: 'Read Discord user', + bot: 'Read Discord bot', + 'messages.read': 'Read Discord messages', + guilds: 'Read Discord guilds', + 'guilds.members.read': 'Read Discord guild members', + + // Reddit scopes + identity: 'Access Reddit identity', + submit: 'Submit posts and comments', + vote: 'Vote on posts and comments', + save: 'Save and unsave posts and comments', + edit: 'Edit posts and comments', + subscribe: 'Subscribe and unsubscribe from subreddits', + history: 'Access Reddit history', + privatemessages: 'Access inbox and send private messages', + account: 'Update account preferences and settings', + mysubreddits: 'Access subscribed and moderated subreddits', + flair: 'Manage user and post flair', + report: 'Report posts and comments for rule violations', + modposts: 'Approve, remove, and moderate posts in moderated subreddits', + modflair: 'Manage flair in moderated subreddits', + modmail: 'Access and respond to moderator mail', + + // Wealthbox scopes + login: 'Access Wealthbox account', + data: 'Access Wealthbox data', + + // Linear scopes + read: 'Read access to workspace', + write: 'Write access to Linear workspace', + + // Slack scopes + 'channels:read': 'View public channels', + 'channels:history': 'Read channel messages', + 'groups:read': 'View private channels', + 'groups:history': 'Read private messages', + 'chat:write': 'Send messages', + 'chat:write.public': 'Post to public channels', + 'im:write': 'Send direct messages', + 'im:history': 'Read direct message history', + 'im:read': 'View direct message channels', + 'users:read': 'View workspace users', + 'files:write': 'Upload files', + 'files:read': 'Download and read files', + 'canvases:write': 'Create canvas documents', + 'reactions:write': 'Add emoji reactions to messages', + + // Webflow scopes + 'sites:read': 'View Webflow sites', + 'sites:write': 'Manage webhooks and site settings', + 'cms:read': 'View CMS content', + 'cms:write': 'Manage CMS content', + 'forms:read': 'View form submissions', + + // HubSpot scopes + 'crm.objects.contacts.read': 'Read HubSpot contacts', + 'crm.objects.contacts.write': 'Create and update HubSpot contacts', + 'crm.objects.companies.read': 'Read HubSpot companies', + 'crm.objects.companies.write': 'Create and update HubSpot companies', + 'crm.objects.deals.read': 'Read HubSpot deals', + 'crm.objects.deals.write': 'Create and update HubSpot deals', + 'crm.objects.owners.read': 'Read HubSpot object owners', + 'crm.objects.users.read': 'Read HubSpot users', + 'crm.objects.users.write': 'Create and update HubSpot users', + 'crm.objects.marketing_events.read': 'Read HubSpot marketing events', + 'crm.objects.marketing_events.write': 'Create and update HubSpot marketing events', + 'crm.objects.line_items.read': 'Read HubSpot line items', + 'crm.objects.line_items.write': 'Create and update HubSpot line items', + 'crm.objects.quotes.read': 'Read HubSpot quotes', + 'crm.objects.quotes.write': 'Create and update HubSpot quotes', + 'crm.objects.appointments.read': 'Read HubSpot appointments', + 'crm.objects.appointments.write': 'Create and update HubSpot appointments', + 'crm.objects.carts.read': 'Read HubSpot shopping carts', + 'crm.objects.carts.write': 'Create and update HubSpot shopping carts', + 'crm.import': 'Import data into HubSpot', + 'crm.lists.read': 'Read HubSpot lists', + 'crm.lists.write': 'Create and update HubSpot lists', + tickets: 'Manage HubSpot tickets', + + // Salesforce scopes + api: 'Access Salesforce API', + refresh_token: 'Maintain long-term access to Salesforce account', + + // Asana scopes + default: 'Access Asana workspace', + + // Pipedrive scopes + base: 'Basic access to Pipedrive account', + 'deals:read': 'Read Pipedrive deals', + 'deals:full': 'Full access to manage Pipedrive deals', + 'contacts:read': 'Read Pipedrive contacts', + 'contacts:full': 'Full access to manage Pipedrive contacts', + 'leads:read': 'Read Pipedrive leads', + 'leads:full': 'Full access to manage Pipedrive leads', + 'activities:read': 'Read Pipedrive activities', + 'activities:full': 'Full access to manage Pipedrive activities', + 'mail:read': 'Read Pipedrive emails', + 'mail:full': 'Full access to manage Pipedrive emails', + 'projects:read': 'Read Pipedrive projects', + 'projects:full': 'Full access to manage Pipedrive projects', + 'webhooks:read': 'Read Pipedrive webhooks', + 'webhooks:full': 'Full access to manage Pipedrive webhooks', + + // LinkedIn scopes + w_member_social: 'Access LinkedIn profile', + + // Box scopes + root_readwrite: 'Read and write all files and folders in Box account', + root_readonly: 'Read all files and folders in Box account', + + // Shopify scopes + write_products: 'Read and manage Shopify products', + write_orders: 'Read and manage Shopify orders', + write_customers: 'Read and manage Shopify customers', + write_inventory: 'Read and manage Shopify inventory levels', + read_locations: 'View store locations', + write_merchant_managed_fulfillment_orders: 'Create fulfillments for orders', + + // Zoom scopes + 'user:read:user': 'View Zoom profile information', + 'meeting:write:meeting': 'Create Zoom meetings', + 'meeting:read:meeting': 'View Zoom meeting details', + 'meeting:read:list_meetings': 'List Zoom meetings', + 'meeting:update:meeting': 'Update Zoom meetings', + 'meeting:delete:meeting': 'Delete Zoom meetings', + 'meeting:read:invitation': 'View Zoom meeting invitations', + 'meeting:read:list_past_participants': 'View past meeting participants', + 'cloud_recording:read:list_user_recordings': 'List Zoom cloud recordings', + 'cloud_recording:read:list_recording_files': 'View recording files', + 'cloud_recording:delete:recording_file': 'Delete cloud recordings', + + // Dropbox scopes + 'account_info.read': 'View Dropbox account information', + 'files.metadata.read': 'View file and folder names, sizes, and dates', + 'files.metadata.write': 'Modify file and folder metadata', + 'files.content.read': 'Download and read Dropbox files', + 'files.content.write': 'Upload, copy, move, and delete files in Dropbox', + 'sharing.read': 'View shared files and folders', + 'sharing.write': 'Share files and folders with others', + + // WordPress.com scopes + global: 'Full access to manage WordPress.com sites, posts, pages, media, and settings', + + // Spotify scopes + 'user-read-private': 'View Spotify account details', + 'user-read-email': 'View email address on Spotify', + 'user-library-read': 'View saved tracks and albums', + 'user-library-modify': 'Save and remove tracks and albums from library', + 'playlist-read-private': 'View private playlists', + 'playlist-read-collaborative': 'View collaborative playlists', + 'playlist-modify-public': 'Create and manage public playlists', + 'playlist-modify-private': 'Create and manage private playlists', + 'user-read-playback-state': 'View current playback state', + 'user-modify-playback-state': 'Control playback on Spotify devices', + 'user-read-currently-playing': 'View currently playing track', + 'user-read-recently-played': 'View recently played tracks', + 'user-top-read': 'View top artists and tracks', + 'user-follow-read': 'View followed artists and users', + 'user-follow-modify': 'Follow and unfollow artists and users', + 'user-read-playback-position': 'View playback position in podcasts', + 'ugc-image-upload': 'Upload images to Spotify playlists', + + // Attio scopes + 'record_permission:read-write': 'Read and write CRM records', + 'object_configuration:read-write': 'Read and manage object schemas', + 'list_configuration:read-write': 'Read and manage list configurations', + 'list_entry:read-write': 'Read and write list entries', + 'note:read-write': 'Read and write notes', + 'task:read-write': 'Read and write tasks', + 'comment:read-write': 'Read and write comments and threads', + 'user_management:read': 'View workspace members', + 'webhook:read-write': 'Manage webhooks', +} + +/** + * Get a human-readable description for a scope. + * Falls back to the raw scope string if no description is found. + */ +export function getScopeDescription(scope: string): string { + return SCOPE_DESCRIPTIONS[scope] || scope +} + /** * Returns a flat list of all available OAuth services with metadata. * This is safe to use on the server as it doesn't include React components. @@ -76,37 +478,53 @@ export function getCanonicalScopesForProvider(providerId: string): string[] { return service?.scopes ? [...service.scopes] : [] } -export function normalizeScopes(scopes: string[]): string[] { - const seen = new Set() - for (const scope of scopes) { - const trimmed = scope.trim() - if (trimmed && !seen.has(trimmed)) { - seen.add(trimmed) +/** + * Get canonical scopes for a service by its serviceId key in OAUTH_PROVIDERS. + * Useful for block definitions to reference scopes from the single source of truth. + */ +export function getScopesForService(serviceId: string): string[] { + for (const provider of Object.values(OAUTH_PROVIDERS)) { + const service = provider.services[serviceId] + if (service) { + return [...service.scopes] } } - return Array.from(seen) + return [] } -export function evaluateScopeCoverage( - providerId: string, - grantedScopes: string[] -): ScopeEvaluation { - const canonicalScopes = getCanonicalScopesForProvider(providerId) - const normalizedGranted = normalizeScopes(grantedScopes) +/** + * Scopes that control token behavior but are not returned in OAuth token responses. + * These should be ignored when validating credential scopes. + */ +const IGNORED_SCOPES = new Set([ + 'offline_access', // Microsoft - requests refresh token + 'refresh_token', // Salesforce - requests refresh token + 'offline.access', // Airtable - requests refresh token (note: dot not underscore) +]) - const canonicalSet = new Set(canonicalScopes) - const grantedSet = new Set(normalizedGranted) +/** + * Compute which of the provided requiredScopes are NOT granted by the credential. + * Note: Ignores special OAuth scopes that control token behavior (like offline_access) + * as they are not returned in the token response's scope list even when granted. + */ +export function getMissingRequiredScopes( + credential: { scopes?: string[] } | undefined, + requiredScopes: string[] = [] +): string[] { + if (!credential) { + return requiredScopes.filter((s) => !IGNORED_SCOPES.has(s)) + } - const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope)) - const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope)) + const granted = new Set(credential.scopes || []) + const missing: string[] = [] - return { - canonicalScopes, - grantedScopes: normalizedGranted, - missingScopes, - extraScopes, - requiresReauthorization: missingScopes.length > 0, + for (const s of requiredScopes) { + if (IGNORED_SCOPES.has(s)) continue + + if (!granted.has(s)) missing.push(s) } + + return missing } /** From e6a5e7f4e455d3592d94a49fd7614d00804c0146 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 6 Mar 2026 18:30:46 -0800 Subject: [PATCH 7/9] improvement(selectors): simplify selector context + add tests (#3453) * improvement(selectors): simplify selectorContext + add tests * fix resolve values fallback * another workflowid pass through --- .../sub-block/hooks/use-selector-setup.ts | 27 +-- .../editor/components/sub-block/sub-block.tsx | 4 +- .../workflow-block/workflow-block.tsx | 2 +- apps/sim/hooks/selectors/registry.ts | 206 +++++++++--------- apps/sim/hooks/selectors/resolution.ts | 36 +-- apps/sim/hooks/selectors/types.ts | 2 +- apps/sim/hooks/use-selector-display-name.ts | 8 +- .../workflows/comparison/resolve-values.ts | 103 ++------- .../lib/workflows/subblocks/context.test.ts | 125 +++++++++++ apps/sim/lib/workflows/subblocks/context.ts | 60 +++++ 10 files changed, 317 insertions(+), 256 deletions(-) create mode 100644 apps/sim/lib/workflows/subblocks/context.test.ts create mode 100644 apps/sim/lib/workflows/subblocks/context.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index cdbf1e17b62..98dc8c45606 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' +import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import type { SubBlockConfig } from '@/blocks/types' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' @@ -14,8 +15,7 @@ import { useDependsOnGate } from './use-depends-on-gate' * * Builds a `SelectorContext` by mapping each `dependsOn` entry through the * canonical index to its `canonicalParamId`, which maps directly to - * `SelectorContext` field names (e.g. `siteId`, `teamId`, `collectionId`). - * The one special case is `oauthCredential` which maps to `credentialId`. + * `SelectorContext` field names (e.g. `siteId`, `teamId`, `oauthCredential`). * * @param blockId - The block containing the selector sub-block * @param subBlock - The sub-block config (must have `selectorKey` set) @@ -70,11 +70,8 @@ export function useSelectorSetup( if (isReference(strValue)) continue const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey - - if (canonicalParamId === 'oauthCredential') { - context.credentialId = strValue - } else if (canonicalParamId in CONTEXT_FIELD_SET) { - ;(context as Record)[canonicalParamId] = strValue + if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) { + context[canonicalParamId as keyof SelectorContext] = strValue } } @@ -89,19 +86,3 @@ export function useSelectorSetup( dependencyValues: resolvedDependencyValues, } } - -const CONTEXT_FIELD_SET: Record = { - credentialId: true, - domain: true, - teamId: true, - projectId: true, - knowledgeBaseId: true, - planId: true, - siteId: true, - collectionId: true, - spreadsheetId: true, - fileId: true, - baseId: true, - datasetId: true, - serviceDeskId: true, -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 3887b46e55f..047fbc2d836 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -57,9 +57,9 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management' const SLACK_OVERRIDES: SelectorOverrides = { transformContext: (context, deps) => { const authMethod = deps.authMethod as string - const credentialId = + const oauthCredential = authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '') - return { ...context, credentialId } + return { ...context, oauthCredential } }, } 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 de5694b7b9b..5a559801917 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 @@ -578,7 +578,7 @@ const SubBlockRow = memo(function SubBlockRow({ subBlock, value: rawValue, workflowId, - credentialId: typeof credentialId === 'string' ? credentialId : undefined, + oauthCredential: typeof credentialId === 'string' ? credentialId : undefined, knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined, domain: domainValue, teamId: teamIdValue, diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index db0d6b28f04..fd050f97a6a 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -39,10 +39,10 @@ type FolderResponse = { id: string; name: string } type PlannerTask = { id: string; title: string } const ensureCredential = (context: SelectorContext, key: SelectorKey): string => { - if (!context.credentialId) { + if (!context.oauthCredential) { throw new Error(`Missing credential for selector ${key}`) } - return context.credentialId + return context.oauthCredential } const ensureDomain = (context: SelectorContext, key: SelectorKey): string => { @@ -66,9 +66,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'airtable.bases', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'airtable.bases') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -104,10 +104,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'airtable.tables', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.baseId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.baseId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.baseId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'airtable.tables') if (!context.baseId) { @@ -151,9 +151,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'asana.workspaces', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'asana.workspaces') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -182,9 +182,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'attio.objects', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'attio.objects') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -216,9 +216,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'attio.lists', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'attio.lists') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -250,10 +250,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'bigquery.datasets', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.projectId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.projectId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'bigquery.datasets') if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector') @@ -298,12 +298,12 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'bigquery.tables', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.projectId ?? 'none', context.datasetId ?? 'none', ], enabled: ({ context }) => - Boolean(context.credentialId && context.projectId && context.datasetId), + Boolean(context.oauthCredential && context.projectId && context.datasetId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'bigquery.tables') if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector') @@ -347,9 +347,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'calcom.eventTypes', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'calcom.eventTypes') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -381,9 +381,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'calcom.schedules', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'calcom.schedules') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -415,10 +415,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'confluence.spaces', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.domain ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.domain), + enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'confluence.spaces') const domain = ensureDomain(context, 'confluence.spaces') @@ -460,10 +460,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'jsm.serviceDesks', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.domain ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.domain), + enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'jsm.serviceDesks') const domain = ensureDomain(context, 'jsm.serviceDesks') @@ -505,12 +505,12 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'jsm.requestTypes', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.domain ?? 'none', context.serviceDeskId ?? 'none', ], enabled: ({ context }) => - Boolean(context.credentialId && context.domain && context.serviceDeskId), + Boolean(context.oauthCredential && context.domain && context.serviceDeskId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'jsm.requestTypes') const domain = ensureDomain(context, 'jsm.requestTypes') @@ -556,9 +556,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'google.tasks.lists', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'google.tasks.lists') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -587,9 +587,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'microsoft.planner.plans', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'microsoft.planner.plans') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -618,9 +618,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'notion.databases', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'notion.databases') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -652,9 +652,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'notion.pages', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'notion.pages') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -686,9 +686,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'pipedrive.pipelines', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'pipedrive.pipelines') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -720,10 +720,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'sharepoint.lists', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.siteId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.siteId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'sharepoint.lists') if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector') @@ -761,9 +761,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'trello.boards', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'trello.boards') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -794,9 +794,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'zoom.meetings', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'zoom.meetings') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -828,12 +828,12 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'slack.channels', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const body = JSON.stringify({ - credential: context.credentialId, + credential: context.oauthCredential, workflowId: context.workflowId, }) const data = await fetchJson<{ channels: SlackChannel[] }>('/api/tools/slack/channels', { @@ -852,12 +852,12 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'slack.users', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const body = JSON.stringify({ - credential: context.credentialId, + credential: context.oauthCredential, workflowId: context.workflowId, }) const data = await fetchJson<{ users: SlackUser[] }>('/api/tools/slack/users', { @@ -876,12 +876,12 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'gmail.labels', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', { - searchParams: { credentialId: context.credentialId }, + searchParams: { credentialId: context.oauthCredential }, }) return (data.labels || []).map((label) => ({ id: label.id, @@ -895,12 +895,12 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'outlook.folders', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const data = await fetchJson<{ folders: FolderResponse[] }>('/api/tools/outlook/folders', { - searchParams: { credentialId: context.credentialId }, + searchParams: { credentialId: context.oauthCredential }, }) return (data.folders || []).map((folder) => ({ id: folder.id, @@ -914,13 +914,13 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'google.calendar', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>( '/api/tools/google_calendar/calendars', - { searchParams: { credentialId: context.credentialId } } + { searchParams: { credentialId: context.oauthCredential } } ) return (data.calendars || []).map((calendar) => ({ id: calendar.id, @@ -934,11 +934,11 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'microsoft.teams', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { - const body = JSON.stringify({ credential: context.credentialId }) + const body = JSON.stringify({ credential: context.oauthCredential }) const data = await fetchJson<{ teams: { id: string; displayName: string }[] }>( '/api/tools/microsoft-teams/teams', { method: 'POST', body } @@ -955,11 +955,11 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'microsoft.chats', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { - const body = JSON.stringify({ credential: context.credentialId }) + const body = JSON.stringify({ credential: context.oauthCredential }) const data = await fetchJson<{ chats: { id: string; displayName: string }[] }>( '/api/tools/microsoft-teams/chats', { method: 'POST', body } @@ -976,13 +976,13 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'microsoft.channels', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.teamId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.teamId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), fetchList: async ({ context }: SelectorQueryArgs) => { const body = JSON.stringify({ - credential: context.credentialId, + credential: context.oauthCredential, teamId: context.teamId, }) const data = await fetchJson<{ channels: { id: string; displayName: string }[] }>( @@ -1001,14 +1001,14 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'wealthbox.contacts', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const data = await fetchJson<{ items: { id: string; name: string }[] }>( '/api/tools/wealthbox/items', { - searchParams: { credentialId: context.credentialId, type: 'contact' }, + searchParams: { credentialId: context.oauthCredential, type: 'contact' }, } ) return (data.items || []).map((item) => ({ @@ -1023,9 +1023,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'sharepoint.sites', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'sharepoint.sites') const body = JSON.stringify({ @@ -1069,10 +1069,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'microsoft.planner', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.planId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.planId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.planId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'microsoft.planner') const body = JSON.stringify({ @@ -1112,11 +1112,11 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'jira.projects', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.domain ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId && context.domain), + enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'jira.projects') const domain = ensureDomain(context, 'jira.projects') @@ -1171,12 +1171,12 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'jira.issues', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.domain ?? 'none', context.projectId ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId && context.domain), + enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'jira.issues') const domain = ensureDomain(context, 'jira.issues') @@ -1235,9 +1235,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'linear.teams', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'linear.teams') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -1260,10 +1260,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'linear.projects', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.teamId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.teamId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'linear.projects') const body = JSON.stringify({ @@ -1290,11 +1290,11 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'confluence.pages', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.domain ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId && context.domain), + enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'confluence.pages') const domain = ensureDomain(context, 'confluence.pages') @@ -1343,9 +1343,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'onedrive.files', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'onedrive.files') const data = await fetchJson<{ files: { id: string; name: string }[] }>( @@ -1366,9 +1366,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'onedrive.folders', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'onedrive.folders') const data = await fetchJson<{ files: { id: string; name: string }[] }>( @@ -1389,12 +1389,12 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'google.drive', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.mimeType ?? 'any', context.fileId ?? 'root', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'google.drive') const data = await fetchJson<{ files: { id: string; name: string }[] }>( @@ -1438,10 +1438,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'google.sheets', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.spreadsheetId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.spreadsheetId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'google.sheets') if (!context.spreadsheetId) { @@ -1469,10 +1469,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'microsoft.excel.sheets', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.spreadsheetId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.spreadsheetId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'microsoft.excel.sheets') if (!context.spreadsheetId) { @@ -1500,10 +1500,10 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'microsoft.excel', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'microsoft.excel') const data = await fetchJson<{ files: { id: string; name: string }[] }>( @@ -1528,10 +1528,10 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'microsoft.word', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'microsoft.word') const data = await fetchJson<{ files: { id: string; name: string }[] }>( @@ -1596,9 +1596,9 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'webflow.sites', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId), + enabled: ({ context }) => Boolean(context.oauthCredential), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'webflow.sites') const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId }) @@ -1621,10 +1621,10 @@ const registry: Record = { getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'webflow.collections', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.siteId ?? 'none', ], - enabled: ({ context }) => Boolean(context.credentialId && context.siteId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId), fetchList: async ({ context }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'webflow.collections') if (!context.siteId) { @@ -1654,11 +1654,11 @@ const registry: Record = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'webflow.items', - context.credentialId ?? 'none', + context.oauthCredential ?? 'none', context.collectionId ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.credentialId && context.collectionId), + enabled: ({ context }) => Boolean(context.oauthCredential && context.collectionId), fetchList: async ({ context, search }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'webflow.items') if (!context.collectionId) { diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index 81986860adb..38886d70de4 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -7,46 +7,16 @@ export interface SelectorResolution { allowSearch: boolean } -export interface SelectorResolutionArgs { - workflowId?: string - credentialId?: string - domain?: string - projectId?: string - planId?: string - teamId?: string - knowledgeBaseId?: string - siteId?: string - collectionId?: string - spreadsheetId?: string - fileId?: string - baseId?: string - datasetId?: string - serviceDeskId?: string -} - export function resolveSelectorForSubBlock( subBlock: SubBlockConfig, - args: SelectorResolutionArgs + context: SelectorContext ): SelectorResolution | null { if (!subBlock.selectorKey) return null return { key: subBlock.selectorKey, context: { - workflowId: args.workflowId, - credentialId: args.credentialId, - domain: args.domain, - projectId: args.projectId, - planId: args.planId, - teamId: args.teamId, - knowledgeBaseId: args.knowledgeBaseId, - siteId: args.siteId, - collectionId: args.collectionId, - spreadsheetId: args.spreadsheetId, - fileId: args.fileId, - baseId: args.baseId, - datasetId: args.datasetId, - serviceDeskId: args.serviceDeskId, - mimeType: subBlock.mimeType, + ...context, + mimeType: subBlock.mimeType ?? context.mimeType, }, allowSearch: subBlock.selectorAllowSearch ?? true, } diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 8f8beee32e3..87e1572ef57 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -61,7 +61,7 @@ export interface SelectorOption { export interface SelectorContext { workspaceId?: string workflowId?: string - credentialId?: string + oauthCredential?: string serviceId?: string domain?: string teamId?: string diff --git a/apps/sim/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts index 24d2fe51ebe..8275ca5ea77 100644 --- a/apps/sim/hooks/use-selector-display-name.ts +++ b/apps/sim/hooks/use-selector-display-name.ts @@ -12,7 +12,7 @@ interface SelectorDisplayNameArgs { subBlock?: SubBlockConfig value: unknown workflowId?: string - credentialId?: string + oauthCredential?: string domain?: string projectId?: string planId?: string @@ -31,7 +31,7 @@ export function useSelectorDisplayName({ subBlock, value, workflowId, - credentialId, + oauthCredential, domain, projectId, planId, @@ -51,7 +51,7 @@ export function useSelectorDisplayName({ if (!subBlock || !detailId) return null return resolveSelectorForSubBlock(subBlock, { workflowId, - credentialId, + oauthCredential, domain, projectId, planId, @@ -69,7 +69,7 @@ export function useSelectorDisplayName({ subBlock, detailId, workflowId, - credentialId, + oauthCredential, domain, projectId, planId, diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 9a041e7cc9c..1eb31d97ea3 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { getBlock } from '@/blocks/registry' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { CREDENTIAL_SET, isUuid } from '@/executor/constants' @@ -6,7 +7,7 @@ import { fetchCredentialSetById } from '@/hooks/queries/credential-sets' import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth-credentials' import { getSelectorDefinition } from '@/hooks/selectors/registry' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' -import type { SelectorKey } from '@/hooks/selectors/types' +import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('ResolveValues') @@ -39,24 +40,6 @@ interface ResolutionContext { blockId?: string } -/** - * Extended context extracted from block subBlocks for selector resolution - */ -interface ExtendedSelectorContext { - credentialId?: string - domain?: string - projectId?: string - planId?: string - teamId?: string - knowledgeBaseId?: string - siteId?: string - collectionId?: string - spreadsheetId?: string - baseId?: string - datasetId?: string - serviceDeskId?: string -} - function getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string { if (subBlockConfig?.title) { return subBlockConfig.title.toLowerCase() @@ -150,26 +133,10 @@ async function resolveWorkflow(workflowId: string): Promise { async function resolveSelectorValue( value: string, selectorKey: SelectorKey, - extendedContext: ExtendedSelectorContext, - workflowId: string + selectorContext: SelectorContext ): Promise { try { const definition = getSelectorDefinition(selectorKey) - const selectorContext = { - workflowId, - credentialId: extendedContext.credentialId, - domain: extendedContext.domain, - projectId: extendedContext.projectId, - planId: extendedContext.planId, - teamId: extendedContext.teamId, - knowledgeBaseId: extendedContext.knowledgeBaseId, - siteId: extendedContext.siteId, - collectionId: extendedContext.collectionId, - spreadsheetId: extendedContext.spreadsheetId, - baseId: extendedContext.baseId, - datasetId: extendedContext.datasetId, - serviceDeskId: extendedContext.serviceDeskId, - } if (definition.fetchById) { const result = await definition.fetchById({ @@ -219,37 +186,14 @@ export function formatValueForDisplay(value: unknown): string { return String(value) } -/** - * Extracts extended context from a block's subBlocks for selector resolution. - * This mirrors the context extraction done in the UI components. - */ -function extractExtendedContext( +function extractSelectorContext( blockId: string, - currentState: WorkflowState -): ExtendedSelectorContext { + currentState: WorkflowState, + workflowId: string +): SelectorContext { const block = currentState.blocks?.[blockId] - if (!block?.subBlocks) return {} - - const getStringValue = (id: string): string | undefined => { - const subBlock = block.subBlocks[id] as { value?: unknown } | undefined - const val = subBlock?.value - return typeof val === 'string' ? val : undefined - } - - return { - credentialId: getStringValue('credential'), - domain: getStringValue('domain'), - projectId: getStringValue('projectId'), - planId: getStringValue('planId'), - teamId: getStringValue('teamId'), - knowledgeBaseId: getStringValue('knowledgeBaseId'), - siteId: getStringValue('siteId'), - collectionId: getStringValue('collectionId'), - spreadsheetId: getStringValue('spreadsheetId') || getStringValue('fileId'), - baseId: getStringValue('baseId') || getStringValue('baseSelector'), - datasetId: getStringValue('datasetId') || getStringValue('datasetSelector'), - serviceDeskId: getStringValue('serviceDeskId') || getStringValue('serviceDeskSelector'), - } + if (!block?.subBlocks) return { workflowId } + return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId }) } /** @@ -277,9 +221,9 @@ export async function resolveValueForDisplay( const subBlockConfig = blockConfig?.subBlocks.find((sb) => sb.id === context.subBlockId) const semanticFallback = getSemanticFallback(context.subBlockId, subBlockConfig) - const extendedContext = context.blockId - ? extractExtendedContext(context.blockId, context.currentState) - : {} + const selectorCtx = context.blockId + ? extractSelectorContext(context.blockId, context.currentState, context.workflowId) + : { workflowId: context.workflowId } // Credential fields (oauth-input or credential subBlockId) const isCredentialField = @@ -311,29 +255,10 @@ export async function resolveValueForDisplay( // Selector types that require hydration (file-selector, sheet-selector, etc.) // These support external service IDs like Google Drive file IDs if (subBlockConfig && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) { - const resolution = resolveSelectorForSubBlock(subBlockConfig, { - workflowId: context.workflowId, - credentialId: extendedContext.credentialId, - domain: extendedContext.domain, - projectId: extendedContext.projectId, - planId: extendedContext.planId, - teamId: extendedContext.teamId, - knowledgeBaseId: extendedContext.knowledgeBaseId, - siteId: extendedContext.siteId, - collectionId: extendedContext.collectionId, - spreadsheetId: extendedContext.spreadsheetId, - baseId: extendedContext.baseId, - datasetId: extendedContext.datasetId, - serviceDeskId: extendedContext.serviceDeskId, - }) + const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx) if (resolution?.key) { - const label = await resolveSelectorValue( - value, - resolution.key, - extendedContext, - context.workflowId - ) + const label = await resolveSelectorValue(value, resolution.key, selectorCtx) if (label) { return { original: value, displayLabel: label, resolved: true } } diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts new file mode 100644 index 00000000000..c30f1f1b0af --- /dev/null +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +import { getAllBlocks } from '@/blocks/registry' +import { buildSelectorContextFromBlock, SELECTOR_CONTEXT_FIELDS } from './context' +import { buildCanonicalIndex, isCanonicalPair } from './visibility' + +describe('buildSelectorContextFromBlock', () => { + it('should extract knowledgeBaseId from knowledgeBaseSelector via canonical mapping', () => { + const ctx = buildSelectorContextFromBlock('knowledge', { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: 'kb-uuid-123', + }, + }) + + expect(ctx.knowledgeBaseId).toBe('kb-uuid-123') + }) + + it('should extract knowledgeBaseId from manualKnowledgeBaseId via canonical mapping', () => { + const ctx = buildSelectorContextFromBlock('knowledge', { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + manualKnowledgeBaseId: { + id: 'manualKnowledgeBaseId', + type: 'short-input', + value: 'manual-kb-id', + }, + }) + + expect(ctx.knowledgeBaseId).toBe('manual-kb-id') + }) + + it('should skip null/empty values', () => { + const ctx = buildSelectorContextFromBlock('knowledge', { + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: '', + }, + }) + + expect(ctx.knowledgeBaseId).toBeUndefined() + }) + + it('should return empty context for unknown block types', () => { + const ctx = buildSelectorContextFromBlock('nonexistent_block', { + foo: { id: 'foo', type: 'short-input', value: 'bar' }, + }) + + expect(ctx).toEqual({}) + }) + + it('should pass through workflowId from opts', () => { + const ctx = buildSelectorContextFromBlock( + 'knowledge', + { operation: { id: 'operation', type: 'dropdown', value: 'search' } }, + { workflowId: 'wf-123' } + ) + + expect(ctx.workflowId).toBe('wf-123') + }) + + it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => { + const ctx = buildSelectorContextFromBlock('knowledge', { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + query: { id: 'query', type: 'short-input', value: 'some search query' }, + }) + + expect((ctx as Record).query).toBeUndefined() + expect((ctx as Record).operation).toBeUndefined() + }) +}) + +describe('SELECTOR_CONTEXT_FIELDS validation', () => { + it('every entry must be a canonicalParamId (if a canonical pair exists) or a direct subblock ID', () => { + const allCanonicalParamIds = new Set() + const allSubBlockIds = new Set() + const idsInCanonicalPairs = new Set() + + for (const block of getAllBlocks()) { + const index = buildCanonicalIndex(block.subBlocks) + + for (const sb of block.subBlocks) { + allSubBlockIds.add(sb.id) + if (sb.canonicalParamId) { + allCanonicalParamIds.add(sb.canonicalParamId) + } + } + + for (const group of Object.values(index.groupsById)) { + if (!isCanonicalPair(group)) continue + if (group.basicId) idsInCanonicalPairs.add(group.basicId) + for (const advId of group.advancedIds) idsInCanonicalPairs.add(advId) + } + } + + const errors: string[] = [] + + for (const field of SELECTOR_CONTEXT_FIELDS) { + const f = field as string + if (allCanonicalParamIds.has(f)) continue + + if (idsInCanonicalPairs.has(f)) { + errors.push( + `"${f}" is a member subblock ID inside a canonical pair — use the canonicalParamId instead` + ) + continue + } + + if (!allSubBlockIds.has(f)) { + errors.push(`"${f}" is not a canonicalParamId or subblock ID in any block definition`) + } + } + + if (errors.length > 0) { + throw new Error(`SELECTOR_CONTEXT_FIELDS validation failed:\n${errors.join('\n')}`) + } + }) +}) diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts new file mode 100644 index 00000000000..9b43bc892f3 --- /dev/null +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -0,0 +1,60 @@ +import { getBlock } from '@/blocks' +import type { SelectorContext } from '@/hooks/selectors/types' +import type { SubBlockState } from '@/stores/workflows/workflow/types' +import { buildCanonicalIndex } from './visibility' + +/** + * Canonical param IDs (or raw subblock IDs) that correspond to SelectorContext fields. + * A subblock's resolved canonical key is set on the context only if it appears here. + */ +export const SELECTOR_CONTEXT_FIELDS = new Set([ + 'oauthCredential', + 'domain', + 'teamId', + 'projectId', + 'knowledgeBaseId', + 'planId', + 'siteId', + 'collectionId', + 'spreadsheetId', + 'fileId', + 'baseId', + 'datasetId', + 'serviceDeskId', +]) + +/** + * Builds a SelectorContext from a block's subBlocks using the canonical index. + * + * Iterates all subblocks, resolves each through canonicalIdBySubBlockId to get + * the canonical key, then checks it against SELECTOR_CONTEXT_FIELDS. + * This avoids hardcoding subblock IDs and automatically handles basic/advanced + * renames. + */ +export function buildSelectorContextFromBlock( + blockType: string, + subBlocks: Record, + opts?: { workflowId?: string } +): SelectorContext { + const context: SelectorContext = {} + if (opts?.workflowId) context.workflowId = opts.workflowId + + const blockConfig = getBlock(blockType) + if (!blockConfig) return context + + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + + for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { + const val = subBlock?.value + if (val === null || val === undefined) continue + const strValue = typeof val === 'string' ? val : String(val) + if (!strValue) continue + + const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockId + if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) { + context[canonicalKey as keyof SelectorContext] = strValue + } + } + + return context +} From 1d36b80172c4f0f30a6e1af7e9622a361f0b30b5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 6 Mar 2026 19:38:57 -0800 Subject: [PATCH 8/9] improvement(selectors): remove dead semantic fallback code (#3454) * improvement(selectors): simplify selectorContext + add tests * fix resolve values fallback * another workflowid pass through * remove dead code * make workspace id required --- .../workflows/comparison/resolve-values.ts | 57 ++----------------- apps/sim/lib/workflows/persistence/utils.ts | 15 +++-- 2 files changed, 17 insertions(+), 55 deletions(-) diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 1eb31d97ea3..cd675824b16 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -40,56 +40,8 @@ interface ResolutionContext { blockId?: string } -function getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string { - if (subBlockConfig?.title) { - return subBlockConfig.title.toLowerCase() - } - - const patterns: Record = { - credential: 'credential', - channel: 'channel', - channelId: 'channel', - user: 'user', - userId: 'user', - workflow: 'workflow', - workflowId: 'workflow', - file: 'file', - fileId: 'file', - folder: 'folder', - folderId: 'folder', - project: 'project', - projectId: 'project', - team: 'team', - teamId: 'team', - sheet: 'sheet', - sheetId: 'sheet', - document: 'document', - documentId: 'document', - knowledgeBase: 'knowledge base', - knowledgeBaseId: 'knowledge base', - server: 'server', - serverId: 'server', - tool: 'tool', - toolId: 'tool', - calendar: 'calendar', - calendarId: 'calendar', - label: 'label', - labelId: 'label', - site: 'site', - siteId: 'site', - collection: 'collection', - collectionId: 'collection', - item: 'item', - itemId: 'item', - contact: 'contact', - contactId: 'contact', - task: 'task', - taskId: 'task', - chat: 'chat', - chatId: 'chat', - } - - return patterns[subBlockId] || 'value' +function getSemanticFallback(subBlockConfig: SubBlockConfig): string { + return (subBlockConfig.title ?? subBlockConfig.id).toLowerCase() } async function resolveCredential(credentialId: string, workflowId: string): Promise { @@ -219,7 +171,10 @@ export async function resolveValueForDisplay( const blockConfig = getBlock(context.blockType) const subBlockConfig = blockConfig?.subBlocks.find((sb) => sb.id === context.subBlockId) - const semanticFallback = getSemanticFallback(context.subBlockId, subBlockConfig) + if (!subBlockConfig) { + return { original: value, displayLabel: formatValueForDisplay(value), resolved: false } + } + const semanticFallback = getSemanticFallback(subBlockConfig) const selectorCtx = context.blockId ? extractSelectorContext(context.blockId, context.currentState, context.workflowId) diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index fa6a9bb5171..89b7b7f6029 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -117,6 +117,10 @@ export async function loadDeployedWorkflowState( resolvedWorkspaceId = wfRow?.workspaceId ?? undefined } + if (!resolvedWorkspaceId) { + throw new Error(`Workflow ${workflowId} has no workspace`) + } + const { blocks: migratedBlocks } = await applyBlockMigrations( state.blocks || {}, resolvedWorkspaceId @@ -139,7 +143,7 @@ export async function loadDeployedWorkflowState( interface MigrationContext { blocks: Record - workspaceId?: string + workspaceId: string migrated: boolean } @@ -148,7 +152,7 @@ type BlockMigration = (ctx: MigrationContext) => MigrationContext | Promise, - workspaceId?: string + workspaceId: string ): Promise<{ blocks: Record; migrated: boolean }> => { let ctx: MigrationContext = { blocks, workspaceId, migrated: false } for (const migration of migrations) { @@ -170,7 +174,6 @@ const applyBlockMigrations = createMigrationPipeline([ }), async (ctx) => { - if (!ctx.workspaceId) return ctx const { blocks, migrated } = await migrateCredentialIds(ctx.blocks, ctx.workspaceId) return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, @@ -409,9 +412,13 @@ export async function loadWorkflowFromNormalizedTables( blocksMap[block.id] = assembled }) + if (!workflowRow?.workspaceId) { + throw new Error(`Workflow ${workflowId} has no workspace`) + } + const { blocks: finalBlocks, migrated } = await applyBlockMigrations( blocksMap, - workflowRow?.workspaceId ?? undefined + workflowRow.workspaceId ) if (migrated) { From 0a52b09debf602d64bbdb79ee8079390ecc5a48b Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 6 Mar 2026 19:52:37 -0800 Subject: [PATCH 9/9] feat(jira): add search_users tool for user lookup by email (#3451) * feat(jira): add search_users tool for user lookup by email * improvement(jira): reuse shared transformUser utility in search_users * improvement(jira): add pagination fields to search_users response * update * fix(jira): filter falsy entries before transforming search_users results * fix(jira): add defensive fallback for nullable transformUser in search_users * fix(jira): align search_users response type with transformUser return type --- apps/docs/content/docs/en/tools/jira.mdx | 32 +++++ apps/sim/blocks/blocks/jira.ts | 48 +++++++ apps/sim/tools/jira/index.ts | 2 + apps/sim/tools/jira/search_users.ts | 166 +++++++++++++++++++++++ apps/sim/tools/jira/types.ts | 29 ++++ apps/sim/tools/registry.ts | 2 + 6 files changed, 279 insertions(+) create mode 100644 apps/sim/tools/jira/search_users.ts diff --git a/apps/docs/content/docs/en/tools/jira.mdx b/apps/docs/content/docs/en/tools/jira.mdx index 5a89a6173b6..db433a3d4d9 100644 --- a/apps/docs/content/docs/en/tools/jira.mdx +++ b/apps/docs/content/docs/en/tools/jira.mdx @@ -1014,4 +1014,36 @@ Get Jira users. If an account ID is provided, returns a single user. Otherwise, | `startAt` | number | Pagination start index | | `maxResults` | number | Maximum results per page | +### `jira_search_users` + +Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) | +| `query` | string | Yes | A query string to search for users. Can be an email address, display name, or partial match. | +| `maxResults` | number | No | Maximum number of users to return \(default: 50, max: 1000\) | +| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) | +| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ts` | string | ISO 8601 timestamp of the operation | +| `users` | array | Array of matching Jira users | +| ↳ `accountId` | string | Atlassian account ID of the user | +| ↳ `displayName` | string | Display name of the user | +| ↳ `active` | boolean | Whether the user account is active | +| ↳ `emailAddress` | string | Email address of the user | +| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) | +| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) | +| ↳ `timeZone` | string | User timezone | +| ↳ `self` | string | REST API URL for this user | +| `total` | number | Number of users returned in this page \(may be less than total matches\) | +| `startAt` | number | Pagination start index | +| `maxResults` | number | Maximum results per page | + diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index 3d7f0d8ede9..a75b7c7b85c 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -47,6 +47,7 @@ export const JiraBlock: BlockConfig = { { label: 'Add Watcher', id: 'add_watcher' }, { label: 'Remove Watcher', id: 'remove_watcher' }, { label: 'Get Users', id: 'get_users' }, + { label: 'Search Users', id: 'search_users' }, ], value: () => 'read', }, @@ -673,6 +674,31 @@ Return ONLY the comment text - no explanations.`, placeholder: 'Maximum users to return (default: 50)', condition: { field: 'operation', value: 'get_users' }, }, + // Search Users fields + { + id: 'searchUsersQuery', + title: 'Search Query', + type: 'short-input', + required: true, + placeholder: 'Enter email address or display name to search', + condition: { field: 'operation', value: 'search_users' }, + }, + { + id: 'searchUsersMaxResults', + title: 'Max Results', + type: 'short-input', + placeholder: 'Maximum users to return (default: 50)', + condition: { field: 'operation', value: 'search_users' }, + mode: 'advanced', + }, + { + id: 'searchUsersStartAt', + title: 'Start At', + type: 'short-input', + placeholder: 'Pagination start index (default: 0)', + condition: { field: 'operation', value: 'search_users' }, + mode: 'advanced', + }, // Trigger SubBlocks ...getTrigger('jira_issue_created').subBlocks, ...getTrigger('jira_issue_updated').subBlocks, @@ -707,6 +733,7 @@ Return ONLY the comment text - no explanations.`, 'jira_add_watcher', 'jira_remove_watcher', 'jira_get_users', + 'jira_search_users', ], config: { tool: (params) => { @@ -767,6 +794,8 @@ Return ONLY the comment text - no explanations.`, return 'jira_remove_watcher' case 'get_users': return 'jira_get_users' + case 'search_users': + return 'jira_search_users' default: return 'jira_retrieve' } @@ -1023,6 +1052,18 @@ Return ONLY the comment text - no explanations.`, : undefined, } } + case 'search_users': { + return { + ...baseParams, + query: params.searchUsersQuery, + maxResults: params.searchUsersMaxResults + ? Number.parseInt(params.searchUsersMaxResults) + : undefined, + startAt: params.searchUsersStartAt + ? Number.parseInt(params.searchUsersStartAt) + : undefined, + } + } default: return baseParams } @@ -1102,6 +1143,13 @@ Return ONLY the comment text - no explanations.`, }, usersStartAt: { type: 'string', description: 'Pagination start index for users' }, usersMaxResults: { type: 'string', description: 'Maximum users to return' }, + // Search Users operation inputs + searchUsersQuery: { + type: 'string', + description: 'Search query (email address or display name)', + }, + searchUsersMaxResults: { type: 'string', description: 'Maximum users to return from search' }, + searchUsersStartAt: { type: 'string', description: 'Pagination start index for user search' }, }, outputs: { // Common outputs across all Jira operations diff --git a/apps/sim/tools/jira/index.ts b/apps/sim/tools/jira/index.ts index ced24d2d060..877fdb8b51d 100644 --- a/apps/sim/tools/jira/index.ts +++ b/apps/sim/tools/jira/index.ts @@ -17,6 +17,7 @@ import { jiraGetWorklogsTool } from '@/tools/jira/get_worklogs' import { jiraRemoveWatcherTool } from '@/tools/jira/remove_watcher' import { jiraRetrieveTool } from '@/tools/jira/retrieve' import { jiraSearchIssuesTool } from '@/tools/jira/search_issues' +import { jiraSearchUsersTool } from '@/tools/jira/search_users' import { jiraTransitionIssueTool } from '@/tools/jira/transition_issue' import { jiraUpdateTool } from '@/tools/jira/update' import { jiraUpdateCommentTool } from '@/tools/jira/update_comment' @@ -48,4 +49,5 @@ export { jiraAddWatcherTool, jiraRemoveWatcherTool, jiraGetUsersTool, + jiraSearchUsersTool, } diff --git a/apps/sim/tools/jira/search_users.ts b/apps/sim/tools/jira/search_users.ts new file mode 100644 index 00000000000..a0cc9dd49f0 --- /dev/null +++ b/apps/sim/tools/jira/search_users.ts @@ -0,0 +1,166 @@ +import type { JiraSearchUsersParams, JiraSearchUsersResponse } from '@/tools/jira/types' +import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types' +import { getJiraCloudId, transformUser } from '@/tools/jira/utils' +import type { ToolConfig } from '@/tools/types' + +export const jiraSearchUsersTool: ToolConfig = { + id: 'jira_search_users', + name: 'Jira Search Users', + description: + 'Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'jira', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Jira', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Jira domain (e.g., yourcompany.atlassian.net)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'A query string to search for users. Can be an email address, display name, or partial match.', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of users to return (default: 50, max: 1000)', + }, + startAt: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'The index of the first user to return (for pagination, default: 0)', + }, + cloudId: { + type: 'string', + required: false, + visibility: 'hidden', + description: + 'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.', + }, + }, + + request: { + url: (params: JiraSearchUsersParams) => { + if (params.cloudId) { + const queryParams = new URLSearchParams() + queryParams.append('query', params.query) + if (params.maxResults !== undefined) + queryParams.append('maxResults', String(params.maxResults)) + if (params.startAt !== undefined) queryParams.append('startAt', String(params.startAt)) + return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/user/search?${queryParams.toString()}` + } + return 'https://api.atlassian.com/oauth/token/accessible-resources' + }, + method: 'GET', + headers: (params: JiraSearchUsersParams) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response, params?: JiraSearchUsersParams) => { + const fetchUsers = async (cloudId: string) => { + const queryParams = new URLSearchParams() + queryParams.append('query', params!.query) + if (params!.maxResults !== undefined) + queryParams.append('maxResults', String(params!.maxResults)) + if (params!.startAt !== undefined) queryParams.append('startAt', String(params!.startAt)) + + const usersUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/user/search?${queryParams.toString()}` + + const usersResponse = await fetch(usersUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${params!.accessToken}`, + }, + }) + + if (!usersResponse.ok) { + let message = `Failed to search Jira users (${usersResponse.status})` + try { + const err = await usersResponse.json() + message = err?.errorMessages?.join(', ') || err?.message || message + } catch (_e) {} + throw new Error(message) + } + + return usersResponse.json() + } + + let data: any + + if (!params?.cloudId) { + const cloudId = await getJiraCloudId(params!.domain, params!.accessToken) + data = await fetchUsers(cloudId) + } else { + if (!response.ok) { + let message = `Failed to search Jira users (${response.status})` + try { + const err = await response.json() + message = err?.errorMessages?.join(', ') || err?.message || message + } catch (_e) {} + throw new Error(message) + } + data = await response.json() + } + + const users = Array.isArray(data) ? data.filter(Boolean) : [] + + return { + success: true, + output: { + ts: new Date().toISOString(), + users: users.map((user: any) => ({ + ...(transformUser(user) ?? { accountId: '', displayName: '' }), + self: user.self ?? null, + })), + total: users.length, + startAt: params?.startAt ?? 0, + maxResults: params?.maxResults ?? 50, + }, + } + }, + + outputs: { + ts: TIMESTAMP_OUTPUT, + users: { + type: 'array', + description: 'Array of matching Jira users', + items: { + type: 'object', + properties: { + ...USER_OUTPUT_PROPERTIES, + self: { + type: 'string', + description: 'REST API URL for this user', + optional: true, + }, + }, + }, + }, + total: { + type: 'number', + description: 'Number of users returned in this page (may be less than total matches)', + }, + startAt: { type: 'number', description: 'Pagination start index' }, + maxResults: { type: 'number', description: 'Maximum results per page' }, + }, +} diff --git a/apps/sim/tools/jira/types.ts b/apps/sim/tools/jira/types.ts index 527efa3a55f..74d98758196 100644 --- a/apps/sim/tools/jira/types.ts +++ b/apps/sim/tools/jira/types.ts @@ -1549,6 +1549,34 @@ export interface JiraGetUsersParams { cloudId?: string } +export interface JiraSearchUsersParams { + accessToken: string + domain: string + query: string + maxResults?: number + startAt?: number + cloudId?: string +} + +export interface JiraSearchUsersResponse extends ToolResponse { + output: { + ts: string + users: Array<{ + accountId: string + accountType?: string | null + active?: boolean | null + displayName: string + emailAddress?: string | null + avatarUrl?: string | null + timeZone?: string | null + self?: string | null + }> + total: number + startAt: number + maxResults: number + } +} + export interface JiraGetUsersResponse extends ToolResponse { output: { ts: string @@ -1594,3 +1622,4 @@ export type JiraResponse = | JiraAddWatcherResponse | JiraRemoveWatcherResponse | JiraGetUsersResponse + | JiraSearchUsersResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 06abac79559..3539724f68d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1085,6 +1085,7 @@ import { jiraRemoveWatcherTool, jiraRetrieveTool, jiraSearchIssuesTool, + jiraSearchUsersTool, jiraTransitionIssueTool, jiraUpdateCommentTool, jiraUpdateTool, @@ -2536,6 +2537,7 @@ export const tools: Record = { jira_add_watcher: jiraAddWatcherTool, jira_remove_watcher: jiraRemoveWatcherTool, jira_get_users: jiraGetUsersTool, + jira_search_users: jiraSearchUsersTool, jsm_get_service_desks: jsmGetServiceDesksTool, jsm_get_request_types: jsmGetRequestTypesTool, jsm_get_request_type_fields: jsmGetRequestTypeFieldsTool,