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/.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/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/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/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/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/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] 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..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,8 +2,11 @@ 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' +import { useEnvironmentStore } from '@/stores/settings/environment' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useDependsOnGate } from './use-depends-on-gate' @@ -12,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) @@ -29,53 +31,58 @@ 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)) 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 } } 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, } } - -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, -} 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 ae10a76a029..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 @@ -549,21 +549,48 @@ 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, 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, 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/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 63fc14b33dd..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, }, @@ -57,21 +52,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..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', }, { @@ -48,12 +49,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 +101,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..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,37 +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', - 'read:user:confluence', - ], + requiredScopes: getScopesForService('confluence'), placeholder: 'Select Confluence account', required: true, }, @@ -464,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, }, @@ -645,11 +578,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 +1216,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 +1476,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/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 0ba15dfe565..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', }, { @@ -109,20 +110,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_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 850f824d509..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', }, { @@ -51,12 +52,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 +226,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/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..a75b7c7b85c 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' @@ -46,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', }, @@ -64,38 +66,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', }, { @@ -703,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, @@ -737,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) => { @@ -797,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' } @@ -1053,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 } @@ -1132,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/blocks/blocks/jira_service_management.ts b/apps/sim/blocks/blocks/jira_service_management.ts index e1a1ae2da2d..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', }, { @@ -106,11 +72,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 +146,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/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 a73d01b48d3..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', }, { @@ -84,12 +77,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 +127,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/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/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/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 543a6d0de34..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, }, @@ -96,12 +89,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 +190,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 +338,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/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 af54dc2f75a..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'], @@ -112,12 +98,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 +425,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 +459,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 +479,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 +521,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/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 777e060fe9b..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, }, @@ -59,26 +60,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 +115,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 +292,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/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 711ab3f7681..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, }, @@ -77,12 +66,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 +130,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..fd050f97a6a 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -10,16 +10,39 @@ 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 } 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 => { @@ -37,18 +60,780 @@ const ensureKnowledgeBase = (context: SelectorContext): string => { } const registry: Record = { + 'airtable.bases': { + key: 'airtable.bases', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'airtable.bases', + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + context.baseId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + context.projectId ?? 'none', + ], + 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') + 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.oauthCredential ?? 'none', + context.projectId ?? 'none', + context.datasetId ?? 'none', + ], + enabled: ({ context }) => + 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') + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + context.domain ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && 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.oauthCredential ?? 'none', + context.domain ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && 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.oauthCredential ?? 'none', + context.domain ?? 'none', + context.serviceDeskId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential && 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.oauthCredential ?? 'none', + ], + 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 }) + 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.oauthCredential ?? 'none', + ], + 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 }) + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + context.siteId ?? 'none', + ], + 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') + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + 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, 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', { @@ -67,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', { @@ -91,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, @@ -110,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, @@ -129,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, @@ -149,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 } @@ -170,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 } @@ -191,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 }[] }>( @@ -216,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) => ({ @@ -238,14 +1023,20 @@ 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({ + 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', @@ -260,22 +1069,42 @@ 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({ + 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', @@ -283,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') @@ -342,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') @@ -406,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 }) @@ -431,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({ @@ -461,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') @@ -514,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 }[] }>( @@ -537,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 }[] }>( @@ -560,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 }[] }>( @@ -609,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) { @@ -640,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) { @@ -671,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 }[] }>( @@ -699,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 }[] }>( @@ -767,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 }) @@ -792,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) { @@ -825,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 9f299d99d8f..38886d70de4 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -7,38 +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 -} - 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, - 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 b884a471911..87e1572ef57 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' @@ -41,7 +61,7 @@ export interface SelectorOption { export interface SelectorContext { workspaceId?: string workflowId?: string - credentialId?: string + oauthCredential?: string serviceId?: string domain?: string teamId?: string @@ -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..6486e769773 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,7 +1,9 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' +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 @@ -29,14 +31,27 @@ 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(resolvedDetailId) const baseEnabled = - Boolean(args.detailId) && definition.fetchById !== undefined + hasRealDetailId && definition.fetchById !== undefined ? definition.enabled ? definition.enabled(queryArgs) : true @@ -44,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, 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/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts index 91d6f7f8172..8275ca5ea77 100644 --- a/apps/sim/hooks/use-selector-display-name.ts +++ b/apps/sim/hooks/use-selector-display-name.ts @@ -12,24 +12,38 @@ interface SelectorDisplayNameArgs { subBlock?: SubBlockConfig value: unknown workflowId?: string - credentialId?: string + oauthCredential?: string domain?: string projectId?: string planId?: string teamId?: string knowledgeBaseId?: string + baseId?: string + datasetId?: string + serviceDeskId?: string + siteId?: string + collectionId?: string + spreadsheetId?: string + fileId?: string } export function useSelectorDisplayName({ subBlock, value, workflowId, - credentialId, + oauthCredential, domain, projectId, planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, }: SelectorDisplayNameArgs) { const detailId = typeof value === 'string' && value.length > 0 ? value : undefined @@ -37,23 +51,37 @@ export function useSelectorDisplayName({ if (!subBlock || !detailId) return null return resolveSelectorForSubBlock(subBlock, { workflowId, - credentialId, + oauthCredential, domain, projectId, planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, }) }, [ subBlock, detailId, workflowId, - credentialId, + oauthCredential, domain, projectId, planId, teamId, knowledgeBaseId, + baseId, + datasetId, + serviceDeskId, + siteId, + collectionId, + spreadsheetId, + fileId, ]) const key = resolution?.key 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/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/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 } /** diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 4912654023d..cd675824b16 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,71 +40,8 @@ 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 -} - -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 { @@ -147,23 +85,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, - } if (definition.fetchById) { const result = await definition.fetchById({ @@ -213,34 +138,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'), - } + if (!block?.subBlocks) return { workflowId } + return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId }) } /** @@ -266,11 +171,14 @@ 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 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 = @@ -302,26 +210,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, - }) + 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/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..89b7b7f6029 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' @@ -114,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 @@ -136,7 +143,7 @@ export async function loadDeployedWorkflowState( interface MigrationContext { blocks: Record - workspaceId?: string + workspaceId: string migrated: boolean } @@ -145,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) { @@ -167,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 } }, @@ -176,6 +182,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 } + }, ]) /** @@ -401,19 +412,27 @@ 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) { 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)) ) 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 +} 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, 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",