mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
2 Commits
v0.5.108
...
waleedlati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136d5b5679 | ||
|
|
550e29d1af |
@@ -20,7 +20,6 @@ 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
|
||||
@@ -116,17 +115,12 @@ export const {ServiceName}Block: BlockConfig = {
|
||||
id: 'credential',
|
||||
title: 'Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: '{service}', // Must match OAuth provider service key
|
||||
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
|
||||
serviceId: '{service}', // Must match OAuth provider
|
||||
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.)
|
||||
@@ -630,7 +624,6 @@ export const registry: Record<string, BlockConfig> = {
|
||||
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',
|
||||
@@ -661,7 +654,6 @@ export const ServiceBlock: BlockConfig = {
|
||||
title: 'Service Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'service',
|
||||
requiredScopes: getScopesForService('service'),
|
||||
placeholder: 'Select account',
|
||||
required: true,
|
||||
},
|
||||
@@ -800,8 +792,7 @@ 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` and `requiredScopes: getScopesForService(serviceId)`
|
||||
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
|
||||
- [ ] OAuth inputs have correct `serviceId`
|
||||
- [ ] Tools.access lists all tool IDs (snake_case)
|
||||
- [ ] Tools.config.tool returns correct tool ID (snake_case)
|
||||
- [ ] Outputs match tool outputs
|
||||
|
||||
@@ -114,7 +114,6 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
|
||||
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}',
|
||||
@@ -145,7 +144,6 @@ export const {Service}Block: BlockConfig = {
|
||||
title: '{Service} Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: '{service}',
|
||||
requiredScopes: getScopesForService('{service}'),
|
||||
required: true,
|
||||
},
|
||||
// Conditional fields per operation
|
||||
@@ -411,7 +409,7 @@ If creating V2 versions (API-aligned outputs):
|
||||
### Block
|
||||
- [ ] Created `blocks/blocks/{service}.ts`
|
||||
- [ ] Defined operation dropdown with all operations
|
||||
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
|
||||
- [ ] Added credential field (oauth-input or short-input)
|
||||
- [ ] Added conditional fields per operation
|
||||
- [ ] Set up dependsOn for cascading selectors
|
||||
- [ ] Configured tools.access with all tool IDs
|
||||
@@ -421,12 +419,6 @@ 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`
|
||||
@@ -725,25 +717,6 @@ 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
|
||||
@@ -756,5 +729,3 @@ requiredScopes: getScopesForService('{service}'),
|
||||
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`
|
||||
|
||||
@@ -26,9 +26,8 @@ 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 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
|
||||
apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service)
|
||||
apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service)
|
||||
```
|
||||
|
||||
## Step 2: Pull API Documentation
|
||||
@@ -200,14 +199,11 @@ For **each tool** in `tools.access`:
|
||||
|
||||
## Step 5: Validate OAuth Scopes (if OAuth service)
|
||||
|
||||
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`
|
||||
- [ ] `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
|
||||
- [ ] 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
|
||||
|
||||
@@ -248,8 +244,7 @@ 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
|
||||
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
|
||||
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
|
||||
- Missing scope description in `oauth-required-modal.tsx`
|
||||
|
||||
**Suggestion** (minor improvements):
|
||||
- Better description text
|
||||
@@ -278,8 +273,7 @@ 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 use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
|
||||
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
|
||||
- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth)
|
||||
- [ ] Validated pagination consistency across tools and block
|
||||
- [ ] Validated error handling (error checks, meaningful messages)
|
||||
- [ ] Validated registry entries (tools and block, alphabetical, correct imports)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.3.10-alpine
|
||||
FROM oven/bun:1.3.9-alpine
|
||||
|
||||
# Install necessary packages for development
|
||||
RUN apk add --no-cache \
|
||||
|
||||
2
.github/workflows/docs-embeddings.yml
vendored
2
.github/workflows/docs-embeddings.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
4
.github/workflows/i18n.yml
vendored
4
.github/workflows/i18n.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- 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.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/migrations.yml
vendored
2
.github/workflows/migrations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/publish-ts-sdk.yml
vendored
2
.github/workflows/publish-ts-sdk.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
12
.github/workflows/test-build.yml
vendored
12
.github/workflows/test-build.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -90,16 +90,6 @@ jobs:
|
||||
|
||||
echo "✅ All feature flags are properly configured"
|
||||
|
||||
- name: Check subblock ID stability
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
|
||||
else
|
||||
BASE_REF="HEAD~1"
|
||||
fi
|
||||
bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF"
|
||||
|
||||
- name: Lint code
|
||||
run: bun run lint:check
|
||||
|
||||
|
||||
@@ -1014,36 +1014,4 @@ 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 |
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ These operations let your agents access and analyze Reddit content as part of yo
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, manage messages, and access user and subreddit info.
|
||||
Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, and manage your Reddit account.
|
||||
|
||||
|
||||
|
||||
@@ -39,15 +39,14 @@ Fetch posts from a subreddit with different sorting options
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `subreddit` | string | Yes | The subreddit to fetch posts from \(e.g., "technology", "news"\) |
|
||||
| `sort` | string | No | Sort method for posts \(e.g., "hot", "new", "top", "rising", "controversial"\). Default: "hot" |
|
||||
| `sort` | string | No | Sort method for posts \(e.g., "hot", "new", "top", "rising"\). Default: "hot" |
|
||||
| `limit` | number | No | Maximum number of posts to return \(e.g., 25\). Default: 10, max: 100 |
|
||||
| `time` | string | No | Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" \(default: "all"\) |
|
||||
| `time` | string | No | Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" \(default: "day"\) |
|
||||
| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) |
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) |
|
||||
| `sr_detail` | boolean | No | Expand subreddit details in the response |
|
||||
| `g` | string | No | Geo filter for posts \(e.g., "GLOBAL", "US", "AR", etc.\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -56,7 +55,6 @@ Fetch posts from a subreddit with different sorting options
|
||||
| `subreddit` | string | Name of the subreddit where posts were fetched from |
|
||||
| `posts` | array | Array of posts with title, author, URL, score, comments count, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Author username |
|
||||
| ↳ `url` | string | Post URL |
|
||||
@@ -68,8 +66,6 @@ Fetch posts from a subreddit with different sorting options
|
||||
| ↳ `selftext` | string | Text content for self posts |
|
||||
| ↳ `thumbnail` | string | Thumbnail URL |
|
||||
| ↳ `subreddit` | string | Subreddit name |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_get_comments`
|
||||
|
||||
@@ -87,9 +83,12 @@ Fetch comments from a specific Reddit post
|
||||
| `context` | number | No | Number of parent comments to include |
|
||||
| `showedits` | boolean | No | Show edit information for comments |
|
||||
| `showmore` | boolean | No | Include "load more comments" elements in the response |
|
||||
| `showtitle` | boolean | No | Include submission title in the response |
|
||||
| `threaded` | boolean | No | Return comments in threaded/nested format |
|
||||
| `truncate` | number | No | Integer to truncate comment depth |
|
||||
| `comment` | string | No | ID36 of a comment to focus on \(returns that comment thread\) |
|
||||
| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) |
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -97,7 +96,6 @@ Fetch comments from a specific Reddit post
|
||||
| --------- | ---- | ----------- |
|
||||
| `post` | object | Post information including ID, title, author, content, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Post author |
|
||||
| ↳ `selftext` | string | Post text content |
|
||||
@@ -106,7 +104,6 @@ Fetch comments from a specific Reddit post
|
||||
| ↳ `permalink` | string | Reddit permalink |
|
||||
| `comments` | array | Nested comments with author, body, score, timestamps, and replies |
|
||||
| ↳ `id` | string | Comment ID |
|
||||
| ↳ `name` | string | Thing fullname \(t1_xxxxx\) |
|
||||
| ↳ `author` | string | Comment author |
|
||||
| ↳ `body` | string | Comment text |
|
||||
| ↳ `score` | number | Comment score |
|
||||
@@ -138,7 +135,6 @@ Fetch controversial posts from a subreddit
|
||||
| `subreddit` | string | Name of the subreddit where posts were fetched from |
|
||||
| `posts` | array | Array of controversial posts with title, author, URL, score, comments count, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Author username |
|
||||
| ↳ `url` | string | Post URL |
|
||||
@@ -150,8 +146,6 @@ Fetch controversial posts from a subreddit
|
||||
| ↳ `selftext` | string | Text content for self posts |
|
||||
| ↳ `thumbnail` | string | Thumbnail URL |
|
||||
| ↳ `subreddit` | string | Subreddit name |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_search`
|
||||
|
||||
@@ -171,8 +165,6 @@ Search for posts within a subreddit
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) |
|
||||
| `type` | string | No | Type of search results: "link" \(posts\), "sr" \(subreddits\), or "user" \(users\). Default: "link" |
|
||||
| `sr_detail` | boolean | No | Expand subreddit details in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -181,7 +173,6 @@ Search for posts within a subreddit
|
||||
| `subreddit` | string | Name of the subreddit where search was performed |
|
||||
| `posts` | array | Array of search result posts with title, author, URL, score, comments count, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Author username |
|
||||
| ↳ `url` | string | Post URL |
|
||||
@@ -193,8 +184,6 @@ Search for posts within a subreddit
|
||||
| ↳ `selftext` | string | Text content for self posts |
|
||||
| ↳ `thumbnail` | string | Thumbnail URL |
|
||||
| ↳ `subreddit` | string | Subreddit name |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_submit_post`
|
||||
|
||||
@@ -211,9 +200,6 @@ Submit a new post to a subreddit (text or link)
|
||||
| `nsfw` | boolean | No | Mark post as NSFW |
|
||||
| `spoiler` | boolean | No | Mark post as spoiler |
|
||||
| `send_replies` | boolean | No | Send reply notifications to inbox \(default: true\) |
|
||||
| `flair_id` | string | No | Flair template UUID for the post \(max 36 characters\) |
|
||||
| `flair_text` | string | No | Flair text to display on the post \(max 64 characters\) |
|
||||
| `collection_id` | string | No | Collection UUID to add the post to |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -278,21 +264,6 @@ Save a Reddit post or comment to your saved items
|
||||
| `posts` | json | Posts data |
|
||||
| `post` | json | Single post data |
|
||||
| `comments` | json | Comments data |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `message` | string | Result message |
|
||||
| `data` | json | Response data |
|
||||
| `after` | string | Pagination cursor \(next page\) |
|
||||
| `before` | string | Pagination cursor \(previous page\) |
|
||||
| `id` | string | Entity ID |
|
||||
| `name` | string | Entity fullname |
|
||||
| `messages` | json | Messages data |
|
||||
| `display_name` | string | Subreddit display name |
|
||||
| `subscribers` | number | Subscriber count |
|
||||
| `description` | string | Description text |
|
||||
| `link_karma` | number | Link karma |
|
||||
| `comment_karma` | number | Comment karma |
|
||||
| `total_karma` | number | Total karma |
|
||||
| `icon_img` | string | Icon image URL |
|
||||
|
||||
### `reddit_reply`
|
||||
|
||||
@@ -304,7 +275,6 @@ Add a comment reply to a Reddit post or comment
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `parent_id` | string | Yes | Thing fullname to reply to \(e.g., "t3_abc123" for post, "t1_def456" for comment\) |
|
||||
| `text` | string | Yes | Comment text in markdown format \(e.g., "Great post! Here is my **reply**"\) |
|
||||
| `return_rtjson` | boolean | No | Return response in Rich Text JSON format |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -375,138 +345,4 @@ Subscribe or unsubscribe from a subreddit
|
||||
| `success` | boolean | Whether the subscription action was successful |
|
||||
| `message` | string | Success or error message |
|
||||
|
||||
### `reddit_get_me`
|
||||
|
||||
Get information about the authenticated Reddit user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | User ID |
|
||||
| `name` | string | Username |
|
||||
| `created_utc` | number | Account creation time in UTC epoch seconds |
|
||||
| `link_karma` | number | Total link karma |
|
||||
| `comment_karma` | number | Total comment karma |
|
||||
| `total_karma` | number | Combined total karma |
|
||||
| `is_gold` | boolean | Whether user has Reddit Premium |
|
||||
| `is_mod` | boolean | Whether user is a moderator |
|
||||
| `has_verified_email` | boolean | Whether email is verified |
|
||||
| `icon_img` | string | User avatar/icon URL |
|
||||
|
||||
### `reddit_get_user`
|
||||
|
||||
Get public profile information about any Reddit user by username
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `username` | string | Yes | Reddit username to look up \(e.g., "spez", "example_user"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | User ID |
|
||||
| `name` | string | Username |
|
||||
| `created_utc` | number | Account creation time in UTC epoch seconds |
|
||||
| `link_karma` | number | Total link karma |
|
||||
| `comment_karma` | number | Total comment karma |
|
||||
| `total_karma` | number | Combined total karma |
|
||||
| `is_gold` | boolean | Whether user has Reddit Premium |
|
||||
| `is_mod` | boolean | Whether user is a moderator |
|
||||
| `has_verified_email` | boolean | Whether email is verified |
|
||||
| `icon_img` | string | User avatar/icon URL |
|
||||
|
||||
### `reddit_send_message`
|
||||
|
||||
Send a private message to a Reddit user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `to` | string | Yes | Recipient username \(e.g., "example_user"\) or subreddit \(e.g., "/r/subreddit"\) |
|
||||
| `subject` | string | Yes | Message subject \(max 100 characters\) |
|
||||
| `text` | string | Yes | Message body in markdown format |
|
||||
| `from_sr` | string | No | Subreddit name to send the message from \(requires moderator mail permission\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the message was sent successfully |
|
||||
| `message` | string | Success or error message |
|
||||
|
||||
### `reddit_get_messages`
|
||||
|
||||
Retrieve private messages from your Reddit inbox
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `where` | string | No | Message folder to retrieve: "inbox" \(all\), "unread", "sent", "messages" \(direct messages only\), "comments" \(comment replies\), "selfreply" \(self-post replies\), or "mentions" \(username mentions\). Default: "inbox" |
|
||||
| `limit` | number | No | Maximum number of messages to return \(e.g., 25\). Default: 25, max: 100 |
|
||||
| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) |
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `mark` | boolean | No | Whether to mark fetched messages as read |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `messages` | array | Array of messages with sender, recipient, subject, body, and metadata |
|
||||
| ↳ `id` | string | Message ID |
|
||||
| ↳ `name` | string | Thing fullname \(t4_xxxxx\) |
|
||||
| ↳ `author` | string | Sender username |
|
||||
| ↳ `dest` | string | Recipient username |
|
||||
| ↳ `subject` | string | Message subject |
|
||||
| ↳ `body` | string | Message body text |
|
||||
| ↳ `created_utc` | number | Creation time in UTC epoch seconds |
|
||||
| ↳ `new` | boolean | Whether the message is unread |
|
||||
| ↳ `was_comment` | boolean | Whether the message is a comment reply |
|
||||
| ↳ `context` | string | Context URL for comment replies |
|
||||
| ↳ `distinguished` | string | Distinction: null/"moderator"/"admin" |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_get_subreddit_info`
|
||||
|
||||
Get metadata and information about a subreddit
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `subreddit` | string | Yes | The subreddit to get info about \(e.g., "technology", "programming", "news"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Subreddit ID |
|
||||
| `name` | string | Subreddit fullname \(t5_xxxxx\) |
|
||||
| `display_name` | string | Subreddit name without prefix |
|
||||
| `title` | string | Subreddit title |
|
||||
| `description` | string | Full subreddit description \(markdown\) |
|
||||
| `public_description` | string | Short public description |
|
||||
| `subscribers` | number | Number of subscribers |
|
||||
| `accounts_active` | number | Number of currently active users |
|
||||
| `created_utc` | number | Creation time in UTC epoch seconds |
|
||||
| `over18` | boolean | Whether the subreddit is NSFW |
|
||||
| `lang` | string | Primary language of the subreddit |
|
||||
| `subreddit_type` | string | Subreddit type: public, private, restricted, etc. |
|
||||
| `url` | string | Subreddit URL path \(e.g., /r/technology/\) |
|
||||
| `icon_img` | string | Subreddit icon URL |
|
||||
| `banner_img` | string | Subreddit banner URL |
|
||||
|
||||
|
||||
|
||||
@@ -69,9 +69,7 @@ Read records from a ServiceNow table
|
||||
| `number` | string | No | Record number \(e.g., INC0010001\) |
|
||||
| `query` | string | No | Encoded query string \(e.g., "active=true^priority=1"\) |
|
||||
| `limit` | number | No | Maximum number of records to return \(e.g., 10, 50, 100\) |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(e.g., 0, 10, 20\) |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., sys_id,number,short_description,state\) |
|
||||
| `displayValue` | string | No | Return display values for reference fields: "true" \(display only\), "false" \(sys_id only\), or "all" \(both\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Send, update, delete messages, manage views and modals, add or remove reactions, manage canvases, get channel info and user presence in Slack
|
||||
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -39,7 +39,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, open/update/push modal views, publish Home tab views, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
|
||||
|
||||
|
||||
@@ -799,313 +799,4 @@ Add an emoji reaction to a Slack message
|
||||
| ↳ `timestamp` | string | Message timestamp |
|
||||
| ↳ `reaction` | string | Emoji reaction name |
|
||||
|
||||
### `slack_remove_reaction`
|
||||
|
||||
Remove an emoji reaction from a Slack message
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
|
||||
| `timestamp` | string | Yes | Timestamp of the message to remove reaction from \(e.g., 1405894322.002768\) |
|
||||
| `name` | string | Yes | Name of the emoji reaction to remove \(without colons, e.g., thumbsup, heart, eyes\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Success message |
|
||||
| `metadata` | object | Reaction metadata |
|
||||
| ↳ `channel` | string | Channel ID |
|
||||
| ↳ `timestamp` | string | Message timestamp |
|
||||
| ↳ `reaction` | string | Emoji reaction name |
|
||||
|
||||
### `slack_get_channel_info`
|
||||
|
||||
Get detailed information about a Slack channel by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID to get information about \(e.g., C1234567890\) |
|
||||
| `includeNumMembers` | boolean | No | Whether to include the member count in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `channelInfo` | object | Detailed channel information |
|
||||
| ↳ `id` | string | Channel ID \(e.g., C1234567890\) |
|
||||
| ↳ `name` | string | Channel name without # prefix |
|
||||
| ↳ `is_channel` | boolean | Whether this is a channel |
|
||||
| ↳ `is_private` | boolean | Whether channel is private |
|
||||
| ↳ `is_archived` | boolean | Whether channel is archived |
|
||||
| ↳ `is_general` | boolean | Whether this is the general channel |
|
||||
| ↳ `is_member` | boolean | Whether the bot/user is a member |
|
||||
| ↳ `is_shared` | boolean | Whether channel is shared across workspaces |
|
||||
| ↳ `is_ext_shared` | boolean | Whether channel is externally shared |
|
||||
| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared |
|
||||
| ↳ `num_members` | number | Number of members in the channel |
|
||||
| ↳ `topic` | string | Channel topic |
|
||||
| ↳ `purpose` | string | Channel purpose/description |
|
||||
| ↳ `created` | number | Unix timestamp when channel was created |
|
||||
| ↳ `creator` | string | User ID of channel creator |
|
||||
| ↳ `updated` | number | Unix timestamp of last update |
|
||||
|
||||
### `slack_get_user_presence`
|
||||
|
||||
Check whether a Slack user is currently active or away
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `userId` | string | Yes | User ID to check presence for \(e.g., U1234567890\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `presence` | string | User presence status: "active" or "away" |
|
||||
| `online` | boolean | Whether user has an active client connection \(only available when checking own presence\) |
|
||||
| `autoAway` | boolean | Whether user was automatically set to away due to inactivity \(only available when checking own presence\) |
|
||||
| `manualAway` | boolean | Whether user manually set themselves as away \(only available when checking own presence\) |
|
||||
| `connectionCount` | number | Total number of active connections for the user \(only available when checking own presence\) |
|
||||
| `lastActivity` | number | Unix timestamp of last detected activity \(only available when checking own presence\) |
|
||||
|
||||
### `slack_edit_canvas`
|
||||
|
||||
Edit an existing Slack canvas by inserting, replacing, or deleting content
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `canvasId` | string | Yes | Canvas ID to edit \(e.g., F1234ABCD\) |
|
||||
| `operation` | string | Yes | Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename |
|
||||
| `content` | string | No | Markdown content for the operation \(required for insert/replace operations\) |
|
||||
| `sectionId` | string | No | Section ID to target \(required for insert_after, insert_before, replace, and delete\) |
|
||||
| `title` | string | No | New title for the canvas \(only used with rename operation\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Success message |
|
||||
|
||||
### `slack_create_channel_canvas`
|
||||
|
||||
Create a canvas pinned to a Slack channel as its resource hub
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID to create the canvas in \(e.g., C1234567890\) |
|
||||
| `title` | string | No | Title for the channel canvas |
|
||||
| `content` | string | No | Canvas content in markdown format |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `canvas_id` | string | ID of the created channel canvas |
|
||||
|
||||
### `slack_open_view`
|
||||
|
||||
Open a modal view in Slack using a trigger_id from an interaction payload. Used to display forms, confirmations, and other interactive modals.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `triggerId` | string | Yes | Exchange a trigger to post to the user. Obtained from an interaction payload \(e.g., slash command, button click\) |
|
||||
| `interactivityPointer` | string | No | Alternative to trigger_id for posting to user |
|
||||
| `view` | json | Yes | A view payload object defining the modal. Must include type \("modal"\), title, and blocks array |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The opened modal view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
### `slack_update_view`
|
||||
|
||||
Update an existing modal view in Slack. Identify the view by view_id or external_id, and provide the updated view payload.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `viewId` | string | No | Unique identifier of the view to update. Either viewId or externalId is required |
|
||||
| `externalId` | string | No | Developer-set unique identifier of the view to update \(max 255 chars\). Either viewId or externalId is required |
|
||||
| `hash` | string | No | View state hash to protect against race conditions. Obtained from a previous views response |
|
||||
| `view` | json | Yes | A view payload object defining the updated modal. Must include type \("modal"\), title, and blocks array. Use identical block_id and action_id values to preserve input data |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The updated modal view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
### `slack_push_view`
|
||||
|
||||
Push a new view onto an existing modal stack in Slack. Limited to 2 additional views after the initial modal is opened.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `triggerId` | string | Yes | Exchange a trigger to post to the user. Obtained from an interaction payload \(e.g., button click within an existing modal\) |
|
||||
| `interactivityPointer` | string | No | Alternative to trigger_id for posting to user |
|
||||
| `view` | json | Yes | A view payload object defining the modal to push. Must include type \("modal"\), title, and blocks array |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The pushed modal view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
### `slack_publish_view`
|
||||
|
||||
Publish a static view to a user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `userId` | string | Yes | The user ID to publish the Home tab view to \(e.g., U0BPQUNTA\) |
|
||||
| `hash` | string | No | View state hash to protect against race conditions. Obtained from a previous views response |
|
||||
| `view` | json | Yes | A view payload object defining the Home tab. Must include type \("home"\) and blocks array |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The published Home tab view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
|
||||
|
||||
@@ -6,33 +6,40 @@
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
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 {
|
||||
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 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,
|
||||
@@ -59,6 +66,7 @@ vi.mock('@sim/logger', () => ({
|
||||
|
||||
vi.mock('@/lib/oauth/utils', () => ({
|
||||
parseProvider: mockParseProvider,
|
||||
evaluateScopeCoverage: mockEvaluateScopeCoverage,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/auth/oauth/connections/route'
|
||||
@@ -75,6 +83,16 @@ 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 () => {
|
||||
|
||||
@@ -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 { parseProvider } from '@/lib/oauth'
|
||||
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth'
|
||||
|
||||
const logger = createLogger('OAuthConnectionsAPI')
|
||||
|
||||
@@ -49,7 +49,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
for (const acc of accounts) {
|
||||
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
|
||||
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
|
||||
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
|
||||
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
|
||||
|
||||
if (baseProvider) {
|
||||
// Try multiple methods to get a user-friendly display name
|
||||
@@ -95,6 +96,10 @@ 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) {
|
||||
@@ -103,8 +108,20 @@ export async function GET(request: NextRequest) {
|
||||
existingConnection.accounts.push(accountSummary)
|
||||
|
||||
existingConnection.scopes = Array.from(
|
||||
new Set([...(existingConnection.scopes || []), ...scopes])
|
||||
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
|
||||
)
|
||||
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()
|
||||
@@ -121,7 +138,11 @@ export async function GET(request: NextRequest) {
|
||||
baseProvider,
|
||||
featureType,
|
||||
isConnected: true,
|
||||
scopes,
|
||||
scopes: scopeEvaluation.grantedScopes,
|
||||
canonicalScopes: scopeEvaluation.canonicalScopes,
|
||||
missingScopes: scopeEvaluation.missingScopes,
|
||||
extraScopes: scopeEvaluation.extraScopes,
|
||||
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
||||
lastConnected: acc.updatedAt.toISOString(),
|
||||
accounts: [accountSummary],
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
|
||||
const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
@@ -19,6 +19,7 @@ const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
|
||||
}
|
||||
return {
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockEvaluateScopeCoverage: vi.fn(),
|
||||
mockLogger: logger,
|
||||
}
|
||||
})
|
||||
@@ -27,6 +28,10 @@ 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'),
|
||||
}))
|
||||
@@ -82,6 +87,16 @@ 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 () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
|
||||
@@ -38,7 +39,8 @@ function toCredentialResponse(
|
||||
scope: string | null
|
||||
) {
|
||||
const storedScope = scope?.trim()
|
||||
const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
||||
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
||||
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
|
||||
const [_, featureType = 'default'] = providerId.split('-')
|
||||
|
||||
return {
|
||||
@@ -47,7 +49,11 @@ function toCredentialResponse(
|
||||
provider: providerId,
|
||||
lastUsed: updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
scopes,
|
||||
scopes: scopeEvaluation.grantedScopes,
|
||||
canonicalScopes: scopeEvaluation.canonicalScopes,
|
||||
missingScopes: scopeEvaluation.missingScopes,
|
||||
extraScopes: scopeEvaluation.extraScopes,
|
||||
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,38 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import type { PlannerTask } from '@/tools/microsoft_planner/types'
|
||||
|
||||
const logger = createLogger('MicrosoftPlannerTasksAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, planId } = body
|
||||
const session = await getSession()
|
||||
|
||||
if (!credential) {
|
||||
logger.error(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const planId = searchParams.get('planId')
|
||||
|
||||
if (!credentialId) {
|
||||
logger.error(`[${requestId}] Missing credentialId parameter`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!planId) {
|
||||
logger.error(`[${requestId}] Missing planId in request`)
|
||||
logger.error(`[${requestId}] Missing planId parameter`)
|
||||
return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -33,35 +42,52 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: planIdValidation.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 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 accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
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 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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<string, unknown>) => ({
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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<string, unknown>) => ({
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,79 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
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 { 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 type { SharepointSite } from '@/tools/sharepoint/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SharePointSitesAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
/**
|
||||
* Get SharePoint sites from Microsoft Graph API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, query } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
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 { 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 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 })
|
||||
}
|
||||
|
||||
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 accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
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 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
const searchQuery = query || '*'
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const SlackRemoveReactionSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
channel: z.string().min(1, 'Channel is required'),
|
||||
timestamp: z.string().min(1, 'Message timestamp is required'),
|
||||
name: z.string().min(1, 'Emoji name is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = SlackRemoveReactionSchema.parse(body)
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/reactions.remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
name: validatedData.name,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to remove reaction',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: `Successfully removed :${validatedData.name}: reaction`,
|
||||
metadata: {
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
reaction: validatedData.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new Error(`Failed to download audio from URL: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,6 @@ async function fetchDocumentBytes(url: string): Promise<{ bytes: string; content
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new Error(`Failed to fetch document: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to generate TTS: ${response.status} ${response.statusText}` },
|
||||
|
||||
@@ -184,7 +184,6 @@ export async function POST(request: NextRequest) {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch image for Gemini' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -964,7 +964,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
logger.error(`[${requestId}] Error streaming block content:`, error)
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel().catch(() => {})
|
||||
reader.releaseLock()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export const ActionBar = memo(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-top-[46px] pointer-events-auto absolute right-0',
|
||||
'-top-[46px] absolute right-0',
|
||||
'flex flex-row items-center',
|
||||
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
||||
'gap-[5px] rounded-[10px] p-[5px]',
|
||||
|
||||
@@ -501,6 +501,17 @@ export function Chat() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExecuting && isStreaming) {
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
if (lastMessage?.isStreaming) {
|
||||
streamReaderRef.current?.cancel()
|
||||
streamReaderRef.current = null
|
||||
finalizeMessageStream(lastMessage.id)
|
||||
}
|
||||
}
|
||||
}, [isExecuting, isStreaming, workflowMessages, finalizeMessageStream])
|
||||
|
||||
const handleStopStreaming = useCallback(() => {
|
||||
streamReaderRef.current?.cancel()
|
||||
streamReaderRef.current = null
|
||||
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
|
||||
import {
|
||||
restoreCursorAfterInsertion,
|
||||
sanitizeForParsing,
|
||||
validateJavaScript,
|
||||
validatePython,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
|
||||
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||
@@ -166,7 +171,7 @@ interface CodeProps {
|
||||
defaultCollapsed?: boolean
|
||||
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
|
||||
showCopyButton?: boolean
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
onValidationChange?: (isValid: boolean, errorMessage?: string | null) => void
|
||||
wandConfig: {
|
||||
enabled: boolean
|
||||
prompt: string
|
||||
@@ -250,6 +255,18 @@ export const Code = memo(function Code({
|
||||
}
|
||||
}, [shouldValidateJson, trimmedCode])
|
||||
|
||||
const syntaxError = useMemo(() => {
|
||||
if (effectiveLanguage === 'json' || !trimmedCode) return null
|
||||
const sanitized = sanitizeForParsing(trimmedCode)
|
||||
if (effectiveLanguage === 'javascript') {
|
||||
return validateJavaScript(sanitized)
|
||||
}
|
||||
if (effectiveLanguage === 'python') {
|
||||
return validatePython(sanitized)
|
||||
}
|
||||
return null
|
||||
}, [effectiveLanguage, trimmedCode])
|
||||
|
||||
const gutterWidthPx = useMemo(() => {
|
||||
const lineCount = code.split('\n').length
|
||||
return calculateGutterWidth(lineCount)
|
||||
@@ -341,19 +358,21 @@ export const Code = memo(function Code({
|
||||
useEffect(() => {
|
||||
if (!onValidationChange) return
|
||||
|
||||
const isValid = !shouldValidateJson || isValidJson
|
||||
const isValid = (!shouldValidateJson || isValidJson) && !syntaxError
|
||||
|
||||
if (isValid) {
|
||||
onValidationChange(true)
|
||||
onValidationChange(true, null)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = !isValidJson ? 'Invalid JSON' : syntaxError
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(false)
|
||||
onValidationChange(false, errorMessage)
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [isValidJson, onValidationChange, shouldValidateJson])
|
||||
}, [isValidJson, syntaxError, onValidationChange, shouldValidateJson])
|
||||
|
||||
useEffect(() => {
|
||||
handleStreamStartRef.current = () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
getProviderIdFromServiceId,
|
||||
getScopeDescription,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
@@ -34,6 +33,318 @@ export interface OAuthRequiredModalProps {
|
||||
onConnect?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'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,
|
||||
|
||||
@@ -15,7 +15,6 @@ 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'
|
||||
@@ -26,6 +25,7 @@ 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'))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1969,8 +1969,9 @@ export const ToolInput = memo(function ToolInput({
|
||||
}
|
||||
|
||||
if (useSubBlocks && displaySubBlocks.length > 0) {
|
||||
const allBlockSubBlocks = toolBlock?.subBlocks || []
|
||||
const coveredParamIds = new Set(
|
||||
displaySubBlocks.flatMap((sb) => {
|
||||
allBlockSubBlocks.flatMap((sb) => {
|
||||
const ids = [sb.id]
|
||||
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
|
||||
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
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'
|
||||
|
||||
@@ -15,7 +12,8 @@ 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`, `oauthCredential`).
|
||||
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `collectionId`).
|
||||
* The one special case is `oauthCredential` which maps to `credentialId`.
|
||||
*
|
||||
* @param blockId - The block containing the selector sub-block
|
||||
* @param subBlock - The sub-block config (must have `selectorKey` set)
|
||||
@@ -31,58 +29,53 @@ 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<string, unknown> = {}
|
||||
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<SelectorContext>(() => {
|
||||
const context: SelectorContext = {
|
||||
workflowId,
|
||||
mimeType: subBlock.mimeType,
|
||||
}
|
||||
|
||||
for (const [depKey, value] of Object.entries(resolvedDependencyValues)) {
|
||||
for (const [depKey, value] of Object.entries(dependencyValues)) {
|
||||
if (value === null || value === undefined) continue
|
||||
const strValue = String(value)
|
||||
if (!strValue) continue
|
||||
if (isReference(strValue)) continue
|
||||
|
||||
const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey
|
||||
if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) {
|
||||
context[canonicalParamId as keyof SelectorContext] = strValue
|
||||
|
||||
if (canonicalParamId === 'oauthCredential') {
|
||||
context.credentialId = strValue
|
||||
} else if (canonicalParamId in CONTEXT_FIELD_SET) {
|
||||
;(context as Record<string, unknown>)[canonicalParamId] = strValue
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
|
||||
}, [dependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
|
||||
|
||||
return {
|
||||
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,
|
||||
selectorContext,
|
||||
allowSearch: subBlock.selectorAllowSearch ?? true,
|
||||
disabled: finalDisabled || !subBlock.selectorKey,
|
||||
dependencyValues: resolvedDependencyValues,
|
||||
dependencyValues,
|
||||
}
|
||||
}
|
||||
|
||||
const CONTEXT_FIELD_SET: Record<string, true> = {
|
||||
credentialId: true,
|
||||
domain: true,
|
||||
teamId: true,
|
||||
projectId: true,
|
||||
knowledgeBaseId: true,
|
||||
planId: true,
|
||||
siteId: true,
|
||||
collectionId: true,
|
||||
spreadsheetId: true,
|
||||
fileId: true,
|
||||
}
|
||||
|
||||
@@ -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 oauthCredential =
|
||||
const credentialId =
|
||||
authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
|
||||
return { ...context, oauthCredential }
|
||||
return { ...context, credentialId }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ const getPreviewValue = (
|
||||
* Renders the label with optional validation and description tooltips.
|
||||
*
|
||||
* @param config - The sub-block configuration defining the label content
|
||||
* @param isValidJson - Whether the JSON content is valid (for code blocks)
|
||||
* @param codeValidation - Validation state for code blocks (valid flag + optional error message)
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @param wandState - State and handlers for the inline AI generate feature
|
||||
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
|
||||
@@ -200,7 +200,7 @@ const getPreviewValue = (
|
||||
*/
|
||||
const renderLabel = (
|
||||
config: SubBlockConfig,
|
||||
isValidJson: boolean,
|
||||
codeValidation: { isValid: boolean; errorMessage: string | null },
|
||||
subBlockValues?: Record<string, any>,
|
||||
wandState?: {
|
||||
isSearchActive: boolean
|
||||
@@ -250,21 +250,18 @@ const renderLabel = (
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{labelSuffix}
|
||||
{config.type === 'code' &&
|
||||
config.language === 'json' &&
|
||||
!isValidJson &&
|
||||
!wandState?.isStreaming && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>
|
||||
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{config.type === 'code' && !codeValidation.isValid && !wandState?.isStreaming && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>
|
||||
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>{codeValidation.errorMessage ?? 'Syntax error'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</Label>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
|
||||
{showCopy && (
|
||||
@@ -466,7 +463,8 @@ function SubBlockComponent({
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
const [isValidCode, setIsValidCode] = useState(true)
|
||||
const [codeErrorMessage, setCodeErrorMessage] = useState<string | null>(null)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -484,8 +482,9 @@ function SubBlockComponent({
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleValidationChange = (isValid: boolean): void => {
|
||||
setIsValidJson(isValid)
|
||||
const handleValidationChange = (isValid: boolean, errorMessage?: string | null): void => {
|
||||
setIsValidCode(isValid)
|
||||
setCodeErrorMessage(errorMessage ?? null)
|
||||
}
|
||||
|
||||
const isWandEnabled = config.wandConfig?.enabled ?? false
|
||||
@@ -1151,7 +1150,7 @@ function SubBlockComponent({
|
||||
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
|
||||
{renderLabel(
|
||||
config,
|
||||
isValidJson,
|
||||
{ isValid: isValidCode, errorMessage: codeErrorMessage },
|
||||
subBlockValues,
|
||||
{
|
||||
isSearchActive,
|
||||
|
||||
@@ -40,10 +40,6 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
isAncestorProtected,
|
||||
isBlockProtected,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockType } from '@/blocks/types'
|
||||
@@ -111,11 +107,12 @@ export function Editor() {
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Check if block is locked (or inside a locked ancestor) and compute edit permission
|
||||
// Check if block is locked (or inside a locked container) and compute edit permission
|
||||
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
|
||||
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
|
||||
const parentId = currentBlock?.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
|
||||
const canEditBlock = userPermissions.canEdit && !isLocked
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -250,7 +247,10 @@ export function Editor() {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return
|
||||
|
||||
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (block.locked ?? false) || isParentLocked
|
||||
if (!userPermissions.canEdit || isLocked) return
|
||||
|
||||
renamingBlockIdRef.current = blockId
|
||||
setEditedName(block.name || '')
|
||||
@@ -364,11 +364,11 @@ export function Editor() {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */}
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
|
||||
{isLocked && currentBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isAncestorLocked ? (
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
@@ -385,8 +385,8 @@ export function Editor() {
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{isAncestorLocked
|
||||
? 'Ancestor container is locked'
|
||||
{isParentLocked
|
||||
? 'Parent container is locked'
|
||||
: userPermissions.canAdmin && currentBlock.locked
|
||||
? 'Unlock block'
|
||||
: 'Block is locked'}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeForParsing, validateJavaScript, validatePython } from './utils'
|
||||
|
||||
describe('sanitizeForParsing', () => {
|
||||
it('replaces <Block.output> references with valid identifiers', () => {
|
||||
const result = sanitizeForParsing('const x = <Block.output>')
|
||||
expect(result).not.toContain('<')
|
||||
expect(result).not.toContain('>')
|
||||
expect(result).toContain('__placeholder_')
|
||||
})
|
||||
|
||||
it('replaces {{ENV_VAR}} with valid identifiers', () => {
|
||||
const result = sanitizeForParsing('const url = {{API_URL}}')
|
||||
expect(result).not.toContain('{{')
|
||||
expect(result).not.toContain('}}')
|
||||
expect(result).toContain('__placeholder_')
|
||||
})
|
||||
|
||||
it('replaces nested path references like <Block.output[0].field>', () => {
|
||||
const result = sanitizeForParsing('const x = <Agent.response.choices[0].text>')
|
||||
expect(result).not.toContain('<Agent')
|
||||
})
|
||||
|
||||
it('replaces loop/parallel context references', () => {
|
||||
const result = sanitizeForParsing('const item = <loop.currentItem>')
|
||||
expect(result).not.toContain('<loop')
|
||||
})
|
||||
|
||||
it('replaces variable references', () => {
|
||||
const result = sanitizeForParsing('const v = <variable.myVar>')
|
||||
expect(result).not.toContain('<variable')
|
||||
})
|
||||
|
||||
it('handles multiple references in one string', () => {
|
||||
const code = 'const a = <Block1.out>; const b = {{SECRET}}; const c = <Block2.value>'
|
||||
const result = sanitizeForParsing(code)
|
||||
expect(result).not.toContain('<Block1')
|
||||
expect(result).not.toContain('{{SECRET}}')
|
||||
expect(result).not.toContain('<Block2')
|
||||
expect(result.match(/__placeholder_/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
it('does not replace regular JS comparison operators', () => {
|
||||
const code = 'if (a < b && c > d) {}'
|
||||
const result = sanitizeForParsing(code)
|
||||
expect(result).toBe(code)
|
||||
})
|
||||
|
||||
it('does not replace HTML tags that are not references', () => {
|
||||
const code = 'const html = "<div>hello</div>"'
|
||||
const result = sanitizeForParsing(code)
|
||||
expect(result).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateJavaScript', () => {
|
||||
it('returns null for valid JavaScript', () => {
|
||||
expect(validateJavaScript('const x = 1')).toBeNull()
|
||||
expect(validateJavaScript('function foo() { return 42 }')).toBeNull()
|
||||
expect(validateJavaScript('const arr = [1, 2, 3].map(x => x * 2)')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid async/await code', () => {
|
||||
expect(validateJavaScript('async function foo() { await bar() }')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for bare return statements (function block wraps in async fn)', () => {
|
||||
expect(validateJavaScript('return 42')).toBeNull()
|
||||
expect(validateJavaScript(sanitizeForParsing('return <Block.output>'))).toBeNull()
|
||||
expect(validateJavaScript('const x = 1\nreturn x')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for await at top level (wrapped in async fn)', () => {
|
||||
expect(validateJavaScript('const res = await fetch("url")')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid ES module syntax', () => {
|
||||
expect(validateJavaScript('import { foo } from "bar"')).toBeNull()
|
||||
expect(validateJavaScript('export default function() {}')).toBeNull()
|
||||
})
|
||||
|
||||
it('detects missing closing brace', () => {
|
||||
const result = validateJavaScript('function foo() {')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Syntax error')
|
||||
})
|
||||
|
||||
it('detects missing closing paren', () => {
|
||||
const result = validateJavaScript('console.log("hello"')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Syntax error')
|
||||
})
|
||||
|
||||
it('detects unexpected token', () => {
|
||||
const result = validateJavaScript('const = 5')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Syntax error')
|
||||
})
|
||||
|
||||
it('includes adjusted line and column in error message', () => {
|
||||
const result = validateJavaScript('const x = 1\nconst = 5')
|
||||
expect(result).toMatch(/line 2/)
|
||||
expect(result).toMatch(/col \d+/)
|
||||
})
|
||||
|
||||
it('returns null for empty code', () => {
|
||||
expect(validateJavaScript('')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not error on sanitized references', () => {
|
||||
const code = sanitizeForParsing('const x = <Block.output> + {{ENV_VAR}}')
|
||||
expect(validateJavaScript(code)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePython', () => {
|
||||
it('returns null for valid Python', () => {
|
||||
expect(validatePython('x = 1')).toBeNull()
|
||||
expect(validatePython('def foo():\n return 42')).toBeNull()
|
||||
expect(validatePython('arr = [1, 2, 3]')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for Python with comments', () => {
|
||||
expect(validatePython('x = 1 # this is a comment')).toBeNull()
|
||||
expect(validatePython('# full line comment\nx = 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for Python with strings containing brackets', () => {
|
||||
expect(validatePython('x = "hello (world)"')).toBeNull()
|
||||
expect(validatePython("x = 'brackets [here] {too}'")).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for triple-quoted strings', () => {
|
||||
expect(validatePython('x = """hello\nworld"""')).toBeNull()
|
||||
expect(validatePython("x = '''multi\nline\nstring'''")).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for triple-quoted strings with brackets', () => {
|
||||
expect(validatePython('x = """has { and ( inside"""')).toBeNull()
|
||||
})
|
||||
|
||||
it('detects unmatched opening paren', () => {
|
||||
const result = validatePython('foo(1, 2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("'('")
|
||||
})
|
||||
|
||||
it('detects unmatched closing paren', () => {
|
||||
const result = validatePython('foo)')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("')'")
|
||||
})
|
||||
|
||||
it('detects unmatched bracket', () => {
|
||||
const result = validatePython('arr = [1, 2')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("'['")
|
||||
})
|
||||
|
||||
it('detects unterminated string', () => {
|
||||
const result = validatePython('x = "hello')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Unterminated string')
|
||||
})
|
||||
|
||||
it('detects unterminated triple-quoted string', () => {
|
||||
const result = validatePython('x = """hello')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain('Unterminated triple-quoted string')
|
||||
})
|
||||
|
||||
it('includes line number in error', () => {
|
||||
const result = validatePython('x = 1\ny = (2')
|
||||
expect(result).toMatch(/line 2/)
|
||||
})
|
||||
|
||||
it('handles escaped quotes in strings', () => {
|
||||
expect(validatePython('x = "hello \\"world\\""')).toBeNull()
|
||||
expect(validatePython("x = 'it\\'s fine'")).toBeNull()
|
||||
})
|
||||
|
||||
it('handles brackets inside comments', () => {
|
||||
expect(validatePython('x = 1 # unmatched ( here')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty code', () => {
|
||||
expect(validatePython('')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not error on sanitized references', () => {
|
||||
const code = sanitizeForParsing('x = <Block.output> + {{ENV_VAR}}')
|
||||
expect(validatePython(code)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,17 @@
|
||||
import { parse } from 'acorn'
|
||||
|
||||
/**
|
||||
* Matches Sim block references: `<word.path>`, `<word.path[0].nested>`, `<loop.index>`, etc.
|
||||
* Must contain a dot (.) to distinguish from HTML tags or comparison operators.
|
||||
*/
|
||||
const REFERENCE_PATTERN = /<[a-zA-Z]\w*(?:\.\w+(?:\[\d+\])?)+>/g
|
||||
|
||||
/**
|
||||
* Matches Sim env-var placeholders: `{{WORD}}`, `{{MY_VAR}}`.
|
||||
* Only allows word characters (no spaces, special chars).
|
||||
*/
|
||||
const ENV_VAR_PATTERN = /\{\{\w+\}\}/g
|
||||
|
||||
/**
|
||||
* Restores the cursor position in a textarea after a dropdown insertion.
|
||||
* Schedules a macrotask (via setTimeout) that runs after React's controlled-component commit
|
||||
@@ -18,3 +32,132 @@ export function restoreCursorAfterInsertion(
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `<Block.output>` references and `{{ENV_VAR}}` placeholders with
|
||||
* valid JS/Python identifiers so the code can be parsed without false errors.
|
||||
*/
|
||||
export function sanitizeForParsing(code: string): string {
|
||||
let counter = 0
|
||||
let sanitized = code.replace(ENV_VAR_PATTERN, () => `__placeholder_${counter++}__`)
|
||||
sanitized = sanitized.replace(REFERENCE_PATTERN, () => `__placeholder_${counter++}__`)
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates JavaScript code for syntax errors using acorn.
|
||||
*
|
||||
* Tries two parse strategies to match the Function block's runtime behavior:
|
||||
* 1. As a module (`import`/`export` are valid at top level)
|
||||
* 2. Wrapped in `async () => { ... }` (bare `return`/`await` are valid)
|
||||
*
|
||||
* Only reports an error if both strategies fail, using the wrapped error
|
||||
* since that's the primary execution context.
|
||||
*
|
||||
* @returns Error message string, or null if valid.
|
||||
*/
|
||||
export function validateJavaScript(code: string): string | null {
|
||||
try {
|
||||
parse(code, { ecmaVersion: 'latest', sourceType: 'module' })
|
||||
return null
|
||||
} catch {
|
||||
// Module parse failed — try as function body (allows bare return/await)
|
||||
}
|
||||
|
||||
const wrapped = `(async () => {\n${code}\n})()`
|
||||
try {
|
||||
parse(wrapped, { ecmaVersion: 'latest', sourceType: 'script' })
|
||||
return null
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof SyntaxError) {
|
||||
const msg = e.message
|
||||
const match = msg.match(/\((\d+):(\d+)\)/)
|
||||
if (match) {
|
||||
const adjustedLine = Number(match[1]) - 1
|
||||
if (adjustedLine < 1) return null
|
||||
return `Syntax error at line ${adjustedLine}, col ${match[2]}: ${msg.replace(/\s*\(\d+:\d+\)/, '')}`
|
||||
}
|
||||
return `Syntax error: ${msg}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Python code for common syntax errors: unmatched brackets/parens,
|
||||
* unterminated strings (single-line and triple-quoted).
|
||||
* Processes the entire code string as a stream to correctly handle
|
||||
* multiline triple-quoted strings.
|
||||
*
|
||||
* @returns Error message string, or null if no issues detected.
|
||||
*/
|
||||
export function validatePython(code: string): string | null {
|
||||
const stack: { char: string; line: number }[] = []
|
||||
const openers: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
|
||||
const closers = new Set([')', ']', '}'])
|
||||
const openChars = new Set(['(', '[', '{'])
|
||||
|
||||
let line = 1
|
||||
let i = 0
|
||||
|
||||
while (i < code.length) {
|
||||
const ch = code[i]
|
||||
|
||||
if (ch === '\n') {
|
||||
line++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '#') {
|
||||
const newline = code.indexOf('\n', i)
|
||||
i = newline === -1 ? code.length : newline
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
const tripleQuote = ch.repeat(3)
|
||||
if (code.slice(i, i + 3) === tripleQuote) {
|
||||
const startLine = line
|
||||
const endIdx = code.indexOf(tripleQuote, i + 3)
|
||||
if (endIdx === -1) {
|
||||
return `Unterminated triple-quoted string starting at line ${startLine}`
|
||||
}
|
||||
for (let k = i; k < endIdx + 3; k++) {
|
||||
if (code[k] === '\n') line++
|
||||
}
|
||||
i = endIdx + 3
|
||||
continue
|
||||
}
|
||||
|
||||
const startLine = line
|
||||
i++
|
||||
while (i < code.length && code[i] !== ch && code[i] !== '\n') {
|
||||
if (code[i] === '\\') i++
|
||||
i++
|
||||
}
|
||||
if (i >= code.length || code[i] === '\n') {
|
||||
return `Unterminated string at line ${startLine}`
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (openChars.has(ch)) {
|
||||
stack.push({ char: ch, line })
|
||||
} else if (closers.has(ch)) {
|
||||
if (stack.length === 0 || stack[stack.length - 1].char !== openers[ch]) {
|
||||
return `Unmatched '${ch}' at line ${line}`
|
||||
}
|
||||
stack.pop()
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
const unmatched = stack[stack.length - 1]
|
||||
return `Unmatched '${unmatched.char}' opened at line ${unmatched.line}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { Badge } from '@/components/emcn'
|
||||
@@ -28,28 +28,6 @@ export interface SubflowNodeData {
|
||||
executionStatus?: 'success' | 'error' | 'not-executed'
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = {
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Reusable class names for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Subflow node component for loop and parallel execution containers.
|
||||
* Renders a resizable container with a header displaying the block name and icon,
|
||||
@@ -60,6 +38,7 @@ const getHandleClasses = (position: 'left' | 'right') => {
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -73,6 +52,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const isLocked = currentBlock?.locked ?? false
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Focus state
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
@@ -104,7 +84,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
}
|
||||
|
||||
return level
|
||||
}, [data?.parentId, getNodes])
|
||||
}, [id, data?.parentId, getNodes])
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
@@ -112,6 +92,27 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
const getHandleStyle = () => {
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
|
||||
@@ -126,37 +127,46 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
!!runPathStatus
|
||||
|
||||
/**
|
||||
* Compute the ring color for the subflow selection indicator.
|
||||
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
|
||||
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
|
||||
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
|
||||
* Compute the outline color for the subflow ring.
|
||||
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
|
||||
* child nodes are DOM children of parent nodes and paint over the parent's
|
||||
* internal ring overlay. Outline renders on the element's own compositing
|
||||
* layer, so it stays visible above nested child nodes.
|
||||
*/
|
||||
const getRingColor = (): string | undefined => {
|
||||
if (!hasRing) return undefined
|
||||
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
|
||||
if (diffStatus === 'new') return 'var(--brand-tertiary-2)'
|
||||
if (diffStatus === 'edited') return 'var(--warning)'
|
||||
if (runPathStatus === 'success') {
|
||||
return executionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
}
|
||||
if (runPathStatus === 'error') return 'var(--text-error)'
|
||||
return undefined
|
||||
}
|
||||
const ringColor = getRingColor()
|
||||
const outlineColor = hasRing
|
||||
? isFocused || isSelected || isPreviewSelected
|
||||
? 'var(--brand-secondary)'
|
||||
: diffStatus === 'new'
|
||||
? 'var(--brand-tertiary-2)'
|
||||
: diffStatus === 'edited'
|
||||
? 'var(--warning)'
|
||||
: runPathStatus === 'success'
|
||||
? executionStatus
|
||||
? 'var(--brand-tertiary-2)'
|
||||
: 'var(--border-success)'
|
||||
: runPathStatus === 'error'
|
||||
? 'var(--text-error)'
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className='group pointer-events-none relative'>
|
||||
<div className='group relative'>
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)] transition-block-bg'
|
||||
ref={blockRef}
|
||||
className={cn(
|
||||
'relative select-none rounded-[8px] border border-[var(--border-1)]',
|
||||
'transition-block-bg'
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
...(ringColor && {
|
||||
boxShadow: `0 0 0 1.75px ${ringColor}`,
|
||||
...(outlineColor && {
|
||||
outline: `1.75px solid ${outlineColor}`,
|
||||
outlineOffset: '-1px',
|
||||
}),
|
||||
}}
|
||||
data-node-id={id}
|
||||
@@ -171,7 +181,9 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
{/* Header Section — only interactive area for dragging */}
|
||||
<div
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
@@ -197,17 +209,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Click-catching background — selects this subflow when the body area is clicked.
|
||||
* No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
|
||||
* not as DOM children of this component, so child clicks never reach this div.
|
||||
*/}
|
||||
<div
|
||||
className='absolute inset-0 top-[44px] rounded-b-[8px]'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
/>
|
||||
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
@@ -216,9 +217,12 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
)}
|
||||
|
||||
<div
|
||||
className='relative h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
data-dragarea='true'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
@@ -251,7 +255,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
position={Position.Left}
|
||||
className={getHandleClasses('left')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
...getHandleStyle(),
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
@@ -262,7 +266,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
position={Position.Right}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
...HANDLE_STYLE,
|
||||
...getHandleStyle(),
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id={endHandleId}
|
||||
|
||||
@@ -527,8 +527,7 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
const { displayName: credentialName } = useCredentialName(
|
||||
credentialSourceId,
|
||||
credentialProviderId,
|
||||
workflowId,
|
||||
workspaceId
|
||||
workflowId
|
||||
)
|
||||
|
||||
const credentialId = dependencyValues.credential
|
||||
@@ -549,48 +548,21 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
return typeof option === 'string' ? option : option.label
|
||||
}, [subBlock, rawValue])
|
||||
|
||||
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 domainValue = getStringValue('domain')
|
||||
const teamIdValue = getStringValue('teamId')
|
||||
const projectIdValue = getStringValue('projectId')
|
||||
const planIdValue = getStringValue('planId')
|
||||
|
||||
const { displayName: selectorDisplayName } = useSelectorDisplayName({
|
||||
subBlock,
|
||||
value: rawValue,
|
||||
workflowId,
|
||||
oauthCredential: typeof credentialId === 'string' ? credentialId : undefined,
|
||||
credentialId: 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(
|
||||
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
TriggerUtils,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import {
|
||||
markOutgoingEdgesFromOutput,
|
||||
updateActiveBlockRefCount,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
|
||||
import { updateActiveBlockRefCount } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type {
|
||||
@@ -66,7 +63,7 @@ interface DebugValidationResult {
|
||||
interface BlockEventHandlerConfig {
|
||||
workflowId?: string
|
||||
executionIdRef: { current: string }
|
||||
workflowEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null }>
|
||||
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
|
||||
activeBlocksSet: Set<string>
|
||||
activeBlockRefCounts: Map<string, number>
|
||||
accumulatedBlockLogs: BlockLog[]
|
||||
@@ -338,9 +335,13 @@ export function useWorkflowExecution() {
|
||||
setActiveBlocks(workflowId, new Set(activeBlocksSet))
|
||||
}
|
||||
|
||||
const markOutgoingEdges = (blockId: string, output: Record<string, any> | undefined) => {
|
||||
const markIncomingEdges = (blockId: string) => {
|
||||
if (!workflowId) return
|
||||
markOutgoingEdgesFromOutput(blockId, output, workflowEdges, workflowId, setEdgeRunStatus)
|
||||
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
|
||||
incomingEdges.forEach((edge) => {
|
||||
const status = edge.sourceHandle === 'error' ? 'error' : 'success'
|
||||
setEdgeRunStatus(workflowId, edge.id, status)
|
||||
})
|
||||
}
|
||||
|
||||
const isContainerBlockType = (blockType?: string) => {
|
||||
@@ -459,6 +460,7 @@ export function useWorkflowExecution() {
|
||||
const onBlockStarted = (data: BlockStartedData) => {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, true)
|
||||
markIncomingEdges(data.blockId)
|
||||
|
||||
if (!includeStartConsoleEntry || !workflowId) return
|
||||
|
||||
@@ -485,7 +487,6 @@ export function useWorkflowExecution() {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
|
||||
markOutgoingEdges(data.blockId, data.output as Record<string, any> | undefined)
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: data.output,
|
||||
@@ -504,9 +505,7 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
|
||||
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
|
||||
const output = data.output as Record<string, any> | undefined
|
||||
const isEmptySubflow = Array.isArray(output?.results) && output.results.length === 0
|
||||
if (!isEmptySubflow) return
|
||||
return
|
||||
}
|
||||
|
||||
accumulatedBlockLogs.push(createBlockLogEntry(data, { success: true, output: data.output }))
|
||||
@@ -528,7 +527,6 @@ export function useWorkflowExecution() {
|
||||
if (isStaleExecution()) return
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
|
||||
markOutgoingEdges(data.blockId, { error: data.error })
|
||||
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
@@ -1126,7 +1124,9 @@ export function useWorkflowExecution() {
|
||||
{} as typeof workflowBlocks
|
||||
)
|
||||
|
||||
const isExecutingFromChat = overrideTriggerType === 'chat'
|
||||
const isExecutingFromChat =
|
||||
overrideTriggerType === 'chat' ||
|
||||
(workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput)
|
||||
|
||||
logger.info('Executing workflow', {
|
||||
isDiffMode: currentWorkflow.isDiffMode,
|
||||
@@ -1495,13 +1495,8 @@ export function useWorkflowExecution() {
|
||||
: null
|
||||
if (activeWorkflowId && !workflowExecState?.isDebugging) {
|
||||
setExecutionResult(executionResult)
|
||||
// For chat executions, don't set isExecuting=false here — the chat's
|
||||
// client-side stream wrapper still has buffered data to deliver.
|
||||
// The chat's finally block handles cleanup after the stream is fully consumed.
|
||||
if (!isExecutingFromChat) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||
}, 1000)
|
||||
@@ -1541,7 +1536,7 @@ export function useWorkflowExecution() {
|
||||
isPreExecutionError,
|
||||
})
|
||||
|
||||
if (activeWorkflowId && !isExecutingFromChat) {
|
||||
if (activeWorkflowId) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
@@ -1567,7 +1562,7 @@ export function useWorkflowExecution() {
|
||||
durationMs: data?.duration,
|
||||
})
|
||||
|
||||
if (activeWorkflowId && !isExecutingFromChat) {
|
||||
if (activeWorkflowId) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { isAncestorProtected, isBlockProtected } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
export { isAncestorProtected, isBlockProtected }
|
||||
|
||||
/**
|
||||
* Result of filtering protected blocks from a deletion operation
|
||||
@@ -15,6 +12,28 @@ export interface FilterProtectedBlocksResult {
|
||||
allProtected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected from editing/deletion.
|
||||
* A block is protected if it is locked or if its parent container is locked.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the block is protected
|
||||
*/
|
||||
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
|
||||
// Block is locked directly
|
||||
if (block.locked) return true
|
||||
|
||||
// Block is inside a locked container
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an edge is protected from modification.
|
||||
* An edge is protected only if its target block is protected.
|
||||
|
||||
@@ -29,62 +29,6 @@ export function updateActiveBlockRefCount(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a workflow edge should be marked as active based on its handle and the block output.
|
||||
* Mirrors the executor's EdgeManager.shouldActivateEdge logic on the client side.
|
||||
* Exclude sentinel handles here
|
||||
*/
|
||||
function shouldActivateEdgeClient(
|
||||
handle: string | null | undefined,
|
||||
output: Record<string, any> | undefined
|
||||
): boolean {
|
||||
if (!handle) return true
|
||||
|
||||
if (handle.startsWith('condition-')) {
|
||||
return output?.selectedOption === handle.substring('condition-'.length)
|
||||
}
|
||||
|
||||
if (handle.startsWith('router-')) {
|
||||
return output?.selectedRoute === handle.substring('router-'.length)
|
||||
}
|
||||
|
||||
switch (handle) {
|
||||
case 'error':
|
||||
return !!output?.error
|
||||
case 'source':
|
||||
return !output?.error
|
||||
case 'loop-start-source':
|
||||
case 'loop-end-source':
|
||||
case 'parallel-start-source':
|
||||
case 'parallel-end-source':
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function markOutgoingEdgesFromOutput(
|
||||
blockId: string,
|
||||
output: Record<string, any> | undefined,
|
||||
workflowEdges: Array<{
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string | null
|
||||
}>,
|
||||
workflowId: string,
|
||||
setEdgeRunStatus: (wfId: string, edgeId: string, status: 'success' | 'error') => void
|
||||
): void {
|
||||
const outgoing = workflowEdges.filter((edge) => edge.source === blockId)
|
||||
for (const edge of outgoing) {
|
||||
const handle = edge.sourceHandle
|
||||
if (shouldActivateEdgeClient(handle, output)) {
|
||||
const status = handle === 'error' ? 'error' : output?.error ? 'error' : 'success'
|
||||
setEdgeRunStatus(workflowId, edge.id, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionOptions {
|
||||
workflowInput?: any
|
||||
onStream?: (se: StreamingExecution) => Promise<void>
|
||||
@@ -191,6 +135,13 @@ export async function executeWorkflowWithFullLogging(
|
||||
true
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
const incomingEdges = workflowEdges.filter(
|
||||
(edge) => edge.target === event.data.blockId
|
||||
)
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(wfId, edge.id, 'success')
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@@ -204,13 +155,6 @@ export async function executeWorkflowWithFullLogging(
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'success')
|
||||
markOutgoingEdgesFromOutput(
|
||||
event.data.blockId,
|
||||
event.data.output,
|
||||
workflowEdges,
|
||||
wfId,
|
||||
setEdgeRunStatus
|
||||
)
|
||||
|
||||
addConsole({
|
||||
input: event.data.input || {},
|
||||
@@ -250,13 +194,6 @@ export async function executeWorkflowWithFullLogging(
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'error')
|
||||
markOutgoingEdgesFromOutput(
|
||||
event.data.blockId,
|
||||
{ error: event.data.error },
|
||||
workflowEdges,
|
||||
wfId,
|
||||
setEdgeRunStatus
|
||||
)
|
||||
|
||||
addConsole({
|
||||
input: event.data.input || {},
|
||||
|
||||
@@ -196,14 +196,17 @@ const edgeTypes: EdgeTypes = {
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
const reactFlowStyles = [
|
||||
'bg-[var(--bg)]',
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[1001]',
|
||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||
'[&_.react-flow__pane]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__pane]:select-none',
|
||||
'[&_.react-flow__selectionpane]:select-none',
|
||||
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
@@ -2409,12 +2412,6 @@ const WorkflowContent = React.memo(() => {
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
|
||||
|
||||
// Compute zIndex for blocks inside containers so they render above the
|
||||
// parent subflow's interactive body area (which needs pointer-events for
|
||||
// click-to-select). Container nodes use zIndex: depth (0, 1, 2...),
|
||||
// so child blocks use a baseline that is always above any container.
|
||||
const childZIndex = block.data?.parentId ? 1000 : undefined
|
||||
|
||||
// Create stable node object - React Flow will handle shallow comparison
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
@@ -2423,7 +2420,6 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle,
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
...(childZIndex !== undefined && { zIndex: childZIndex }),
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
@@ -3772,20 +3768,21 @@ const WorkflowContent = React.memo(() => {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden'>
|
||||
<div className='relative h-full w-full flex-1'>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<div
|
||||
className='h-[18px] w-[18px] animate-spin rounded-full'
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div
|
||||
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isWorkflowReady && (
|
||||
<>
|
||||
@@ -3838,7 +3835,7 @@ const WorkflowContent = React.memo(() => {
|
||||
noWheelClassName='allow-scroll'
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={effectivePermissions.canEdit}
|
||||
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
|
||||
@@ -3850,7 +3847,7 @@ const WorkflowContent = React.memo(() => {
|
||||
elevateEdgesOnSelect={true}
|
||||
onlyRenderVisibleElements={false}
|
||||
deleteKeyCode={null}
|
||||
elevateNodesOnSelect={false}
|
||||
elevateNodesOnSelect={true}
|
||||
autoPanOnConnect={effectivePermissions.canEdit}
|
||||
autoPanOnNodeDrag={effectivePermissions.canEdit}
|
||||
/>
|
||||
|
||||
@@ -145,7 +145,7 @@ interface PreviewWorkflowProps {
|
||||
/** Cursor style to show when hovering the canvas */
|
||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||
executedBlocks?: Record<string, { status: string; output?: unknown }>
|
||||
executedBlocks?: Record<string, { status: string }>
|
||||
/** Currently selected block ID for highlighting */
|
||||
selectedBlockId?: string | null
|
||||
/** Skips expensive subblock computations for thumbnails/template previews */
|
||||
@@ -274,9 +274,9 @@ export function PreviewWorkflow({
|
||||
|
||||
/** Maps base block IDs to execution data, handling parallel iteration variants (blockId₍n₎). */
|
||||
const blockExecutionMap = useMemo(() => {
|
||||
if (!executedBlocks) return new Map<string, { status: string; output?: unknown }>()
|
||||
if (!executedBlocks) return new Map<string, { status: string }>()
|
||||
|
||||
const map = new Map<string, { status: string; output?: unknown }>()
|
||||
const map = new Map<string, { status: string }>()
|
||||
for (const [key, value] of Object.entries(executedBlocks)) {
|
||||
// Extract base ID (remove iteration suffix like ₍0₎)
|
||||
const baseId = key.includes('₍') ? key.split('₍')[0] : key
|
||||
@@ -451,6 +451,7 @@ export function PreviewWorkflow({
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
/** Edge is green if target executed and source condition met by edge type. */
|
||||
const getEdgeExecutionStatus = (edge: {
|
||||
source: string
|
||||
target: string
|
||||
@@ -462,40 +463,17 @@ export function PreviewWorkflow({
|
||||
if (!targetStatus?.executed) return 'not-executed'
|
||||
|
||||
const sourceStatus = getBlockExecutionStatus(edge.source)
|
||||
if (!sourceStatus?.executed) return 'not-executed'
|
||||
const { sourceHandle } = edge
|
||||
|
||||
const handle = edge.sourceHandle
|
||||
if (!handle) {
|
||||
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
|
||||
if (sourceHandle === 'error') {
|
||||
return sourceStatus?.status === 'error' ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
const sourceOutput = blockExecutionMap.get(edge.source)?.output as
|
||||
| Record<string, any>
|
||||
| undefined
|
||||
|
||||
if (handle.startsWith('condition-')) {
|
||||
const conditionValue = handle.substring('condition-'.length)
|
||||
return sourceOutput?.selectedOption === conditionValue ? 'success' : 'not-executed'
|
||||
if (sourceHandle === 'loop-start-source' || sourceHandle === 'parallel-start-source') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (handle.startsWith('router-')) {
|
||||
const routeId = handle.substring('router-'.length)
|
||||
return sourceOutput?.selectedRoute === routeId ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
switch (handle) {
|
||||
case 'error':
|
||||
return sourceStatus.status === 'error' ? 'error' : 'not-executed'
|
||||
case 'source':
|
||||
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
|
||||
case 'loop-start-source':
|
||||
case 'loop-end-source':
|
||||
case 'parallel-start-source':
|
||||
case 'parallel-end-source':
|
||||
return 'success'
|
||||
default:
|
||||
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
|
||||
}
|
||||
return sourceStatus?.status === 'success' ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
return (workflowState.edges || []).map((edge) => {
|
||||
|
||||
@@ -667,18 +667,15 @@ describe.concurrent('Blocks Module', () => {
|
||||
const errors: string[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
// 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 allSubBlockIds = new Set(block.subBlocks.map((sb) => sb.id))
|
||||
const canonicalParamIds = new Set(
|
||||
nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId)
|
||||
block.subBlocks.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 = nonTriggerSubBlocks.find(
|
||||
const matchingSubBlock = block.subBlocks.find(
|
||||
(sb) => sb.id === canonicalId && !sb.canonicalParamId
|
||||
)
|
||||
if (matchingSubBlock) {
|
||||
@@ -860,10 +857,6 @@ 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())
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -129,7 +128,7 @@ Return ONLY the JSON array.`,
|
||||
serviceId: 'vertex-ai',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
requiredScopes: getScopesForService('vertex-ai'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
placeholder: 'Select Google Cloud account',
|
||||
required: true,
|
||||
condition: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -39,7 +38,13 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'airtable',
|
||||
requiredScopes: getScopesForService('airtable'),
|
||||
requiredScopes: [
|
||||
'data.records:read',
|
||||
'data.records:write',
|
||||
'schema.bases:read',
|
||||
'user.email:read',
|
||||
'webhook:manage',
|
||||
],
|
||||
placeholder: 'Select Airtable account',
|
||||
required: true,
|
||||
},
|
||||
@@ -52,51 +57,21 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
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)',
|
||||
mode: 'advanced',
|
||||
dependsOn: ['credential'],
|
||||
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)',
|
||||
mode: 'advanced',
|
||||
dependsOn: ['credential', 'baseId'],
|
||||
condition: { field: 'operation', value: ['listBases', 'listTables'], not: true },
|
||||
required: { field: 'operation', value: ['listBases', 'listTables'], not: true },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -37,7 +36,7 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'asana',
|
||||
requiredScopes: getScopesForService('asana'),
|
||||
requiredScopes: ['default'],
|
||||
placeholder: 'Select Asana account',
|
||||
},
|
||||
{
|
||||
@@ -49,31 +48,12 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
|
||||
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'],
|
||||
@@ -101,29 +81,11 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
|
||||
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'],
|
||||
|
||||
@@ -86,47 +86,11 @@ export const AttioBlock: BlockConfig<AttioResponse> = {
|
||||
},
|
||||
|
||||
// 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: [
|
||||
@@ -560,49 +524,11 @@ 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: [
|
||||
|
||||
@@ -65,30 +65,11 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
|
||||
},
|
||||
|
||||
// === 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'],
|
||||
@@ -280,33 +261,11 @@ 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'],
|
||||
@@ -405,27 +364,10 @@ 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',
|
||||
@@ -446,33 +388,11 @@ 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'],
|
||||
@@ -851,10 +771,7 @@ 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' },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -56,7 +55,37 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'confluence',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select Confluence account',
|
||||
required: true,
|
||||
},
|
||||
@@ -435,7 +464,45 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'confluence',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select Confluence account',
|
||||
required: true,
|
||||
},
|
||||
@@ -578,44 +645,11 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -1216,6 +1250,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
...rest
|
||||
} = params
|
||||
|
||||
// Use canonical param (serializer already handles basic/advanced mode)
|
||||
const effectivePageId = pageId ? String(pageId).trim() : ''
|
||||
|
||||
if (operation === 'add_label') {
|
||||
@@ -1476,7 +1511,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
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' },
|
||||
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
|
||||
spaceId: { type: 'string', description: 'Space identifier' },
|
||||
blogPostId: { type: 'string', description: 'Blog post identifier' },
|
||||
versionNumber: { type: 'number', description: 'Page version number' },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -42,7 +41,15 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'dropbox',
|
||||
requiredScopes: getScopesForService('dropbox'),
|
||||
requiredScopes: [
|
||||
'account_info.read',
|
||||
'files.metadata.read',
|
||||
'files.metadata.write',
|
||||
'files.content.read',
|
||||
'files.content.write',
|
||||
'sharing.read',
|
||||
'sharing.write',
|
||||
],
|
||||
placeholder: 'Select Dropbox account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -80,7 +79,11 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'gmail',
|
||||
requiredScopes: getScopesForService('gmail'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/gmail.send',
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
],
|
||||
placeholder: 'Select Gmail account',
|
||||
required: true,
|
||||
},
|
||||
@@ -219,7 +222,7 @@ Return ONLY the email body - no explanations, no extra text.`,
|
||||
canonicalParamId: 'folder',
|
||||
serviceId: 'gmail',
|
||||
selectorKey: 'gmail.labels',
|
||||
requiredScopes: getScopesForService('gmail'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
placeholder: 'Select Gmail label/folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
@@ -300,7 +303,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
canonicalParamId: 'addLabelIds',
|
||||
serviceId: 'gmail',
|
||||
selectorKey: 'gmail.labels',
|
||||
requiredScopes: getScopesForService('gmail'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
placeholder: 'Select destination label',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
@@ -326,7 +329,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
canonicalParamId: 'removeLabelIds',
|
||||
serviceId: 'gmail',
|
||||
selectorKey: 'gmail.labels',
|
||||
requiredScopes: getScopesForService('gmail'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
placeholder: 'Select label to remove',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
@@ -379,7 +382,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
canonicalParamId: 'manageLabelId',
|
||||
serviceId: 'gmail',
|
||||
selectorKey: 'gmail.labels',
|
||||
requiredScopes: getScopesForService('gmail'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
|
||||
placeholder: 'Select label',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleBigQueryIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
@@ -37,7 +36,7 @@ export const GoogleBigQueryBlock: BlockConfig = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-bigquery',
|
||||
requiredScopes: getScopesForService('google-bigquery'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/bigquery'],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
@@ -110,52 +109,20 @@ 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'] },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -44,7 +43,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-calendar',
|
||||
requiredScopes: getScopesForService('google-calendar'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
placeholder: 'Select Google Calendar account',
|
||||
},
|
||||
{
|
||||
@@ -65,7 +64,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
serviceId: 'google-calendar',
|
||||
selectorKey: 'google.calendar',
|
||||
selectorAllowSearch: false,
|
||||
requiredScopes: getScopesForService('google-calendar'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
placeholder: 'Select calendar',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
@@ -331,7 +330,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
serviceId: 'google-calendar',
|
||||
selectorKey: 'google.calendar',
|
||||
selectorAllowSearch: false,
|
||||
requiredScopes: getScopesForService('google-calendar'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
placeholder: 'Select destination calendar',
|
||||
dependsOn: ['credential'],
|
||||
condition: { field: 'operation', value: 'move' },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -38,7 +37,7 @@ export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-contacts',
|
||||
requiredScopes: getScopesForService('google-contacts'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/contacts'],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -37,7 +36,10 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-docs',
|
||||
requiredScopes: getScopesForService('google-docs'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -49,7 +48,10 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select Google Drive account',
|
||||
},
|
||||
{
|
||||
@@ -136,7 +138,10 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
|
||||
canonicalParamId: 'uploadFolderId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
mode: 'basic',
|
||||
@@ -206,7 +211,10 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
|
||||
canonicalParamId: 'createFolderParentId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
mode: 'basic',
|
||||
@@ -231,7 +239,10 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
|
||||
canonicalParamId: 'listFolderId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select a folder to list files from',
|
||||
mode: 'basic',
|
||||
@@ -288,7 +299,10 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
|
||||
canonicalParamId: 'downloadFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to download',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -347,7 +361,10 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
|
||||
canonicalParamId: 'getFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to get info for',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -372,7 +389,10 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
|
||||
canonicalParamId: 'copyFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to copy',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -403,7 +423,10 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
|
||||
canonicalParamId: 'copyDestFolderId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
placeholder: 'Select destination folder (optional)',
|
||||
mode: 'basic',
|
||||
@@ -427,7 +450,10 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
|
||||
canonicalParamId: 'updateFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to update',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -503,7 +529,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
canonicalParamId: 'trashFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to move to trash',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -528,7 +557,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
canonicalParamId: 'deleteFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to permanently delete',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -553,7 +585,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
canonicalParamId: 'shareFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to share',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -665,7 +700,10 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
|
||||
canonicalParamId: 'unshareFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to remove sharing from',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
@@ -698,7 +736,10 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
|
||||
canonicalParamId: 'listPermissionsFileId',
|
||||
serviceId: 'google-drive',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select a file to list permissions for',
|
||||
mode: 'basic',
|
||||
dependsOn: ['credential'],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleFormsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
@@ -39,7 +38,13 @@ export const GoogleFormsBlock: BlockConfig = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-forms',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleGroupsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
@@ -47,7 +46,10 @@ export const GoogleGroupsBlock: BlockConfig = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-groups',
|
||||
requiredScopes: getScopesForService('google-groups'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/admin.directory.group',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.member',
|
||||
],
|
||||
placeholder: 'Select Google Workspace account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -38,7 +37,10 @@ export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-meet',
|
||||
requiredScopes: getScopesForService('google-meet'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/meetings.space.created',
|
||||
'https://www.googleapis.com/auth/meetings.space.readonly',
|
||||
],
|
||||
placeholder: 'Select Google Meet account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -41,7 +40,10 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-sheets',
|
||||
requiredScopes: getScopesForService('google-sheets'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
@@ -61,7 +63,10 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
canonicalParamId: 'spreadsheetId',
|
||||
serviceId: 'google-sheets',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-sheets'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
@@ -334,7 +339,10 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-sheets',
|
||||
requiredScopes: getScopesForService('google-sheets'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
@@ -354,7 +362,10 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
|
||||
canonicalParamId: 'spreadsheetId',
|
||||
serviceId: 'google-sheets',
|
||||
selectorKey: 'google.drive',
|
||||
requiredScopes: getScopesForService('google-sheets'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
mimeType: 'application/vnd.google-apps.spreadsheet',
|
||||
placeholder: 'Select a spreadsheet',
|
||||
dependsOn: ['credential'],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -51,7 +50,10 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-drive',
|
||||
requiredScopes: getScopesForService('google-drive'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -39,7 +38,7 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-tasks',
|
||||
requiredScopes: getScopesForService('google-tasks'),
|
||||
requiredScopes: ['https://www.googleapis.com/auth/tasks'],
|
||||
placeholder: 'Select Google Tasks account',
|
||||
},
|
||||
{
|
||||
@@ -52,27 +51,12 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
|
||||
required: true,
|
||||
},
|
||||
|
||||
// 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 },
|
||||
},
|
||||
// Task List ID - shown for all task operations (not list_task_lists)
|
||||
{
|
||||
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 },
|
||||
},
|
||||
|
||||
@@ -226,9 +210,7 @@ Return ONLY the timestamp - no explanations, no extra text.`,
|
||||
params: (params) => {
|
||||
const { oauthCredential, operation, showCompleted, maxResults, ...rest } = params
|
||||
|
||||
const processedParams: Record<string, unknown> = {
|
||||
...rest,
|
||||
}
|
||||
const processedParams: Record<string, unknown> = { ...rest }
|
||||
|
||||
if (maxResults && typeof maxResults === 'string') {
|
||||
processedParams.maxResults = Number.parseInt(maxResults, 10)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleVaultIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
@@ -39,7 +38,10 @@ export const GoogleVaultBlock: BlockConfig = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-vault',
|
||||
requiredScopes: getScopesForService('google-vault'),
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/ediscovery',
|
||||
'https://www.googleapis.com/auth/devstorage.read_only',
|
||||
],
|
||||
placeholder: 'Select Google Vault account',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -43,7 +42,31 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'hubspot',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select HubSpot account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -47,7 +46,6 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
{ 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',
|
||||
},
|
||||
@@ -66,7 +64,38 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'jira',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select Jira account',
|
||||
},
|
||||
{
|
||||
@@ -674,31 +703,6 @@ 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,
|
||||
@@ -733,7 +737,6 @@ Return ONLY the comment text - no explanations.`,
|
||||
'jira_add_watcher',
|
||||
'jira_remove_watcher',
|
||||
'jira_get_users',
|
||||
'jira_search_users',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -794,8 +797,6 @@ 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'
|
||||
}
|
||||
@@ -1052,18 +1053,6 @@ 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
|
||||
}
|
||||
@@ -1143,13 +1132,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -60,7 +59,42 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'jira',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select Jira account',
|
||||
},
|
||||
{
|
||||
@@ -72,52 +106,11 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
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: [
|
||||
@@ -146,28 +139,12 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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'] },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -133,7 +132,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'linear',
|
||||
requiredScopes: getScopesForService('linear'),
|
||||
requiredScopes: ['read', 'write'],
|
||||
placeholder: 'Select Linear account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -36,7 +35,7 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
|
||||
serviceId: 'linkedin',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
requiredScopes: getScopesForService('linkedin'),
|
||||
requiredScopes: ['profile', 'openid', 'email', 'w_member_social'],
|
||||
placeholder: 'Select LinkedIn account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -47,7 +46,13 @@ export const MicrosoftDataverseBlock: BlockConfig<DataverseResponse> = {
|
||||
title: 'Microsoft Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'microsoft-dataverse',
|
||||
requiredScopes: getScopesForService('microsoft-dataverse'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'https://dynamics.microsoft.com/user_impersonation',
|
||||
'offline_access',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -40,7 +39,14 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'microsoft-excel',
|
||||
requiredScopes: getScopesForService('microsoft-excel'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
required: true,
|
||||
},
|
||||
@@ -360,7 +366,14 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'microsoft-excel',
|
||||
requiredScopes: getScopesForService('microsoft-excel'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -65,7 +64,15 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'microsoft-planner',
|
||||
requiredScopes: getScopesForService('microsoft-planner'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Group.ReadWrite.All',
|
||||
'Group.Read.All',
|
||||
'Tasks.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
},
|
||||
{
|
||||
@@ -77,36 +84,12 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
},
|
||||
|
||||
// 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
|
||||
// Plan ID - for various operations
|
||||
{
|
||||
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'],
|
||||
@@ -127,7 +110,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
|
||||
serviceId: 'microsoft-planner',
|
||||
selectorKey: 'microsoft.planner',
|
||||
condition: { field: 'operation', value: ['read_task'] },
|
||||
dependsOn: ['credential', 'planSelector'],
|
||||
dependsOn: ['credential', 'planId'],
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'readTaskId',
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -48,7 +47,28 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'microsoft-teams',
|
||||
requiredScopes: getScopesForService('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',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -53,49 +53,15 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
// Read/Write operation - Page ID
|
||||
{
|
||||
id: 'pageId',
|
||||
title: 'Page ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'pageId',
|
||||
placeholder: 'Enter Notion page ID',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['notion_read', 'notion_write'],
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
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', 'notion_query_database', 'notion_add_database_row'],
|
||||
value: 'notion_read',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
@@ -103,36 +69,31 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
id: 'databaseId',
|
||||
title: 'Database ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'databaseId',
|
||||
placeholder: 'Enter Notion database ID',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'],
|
||||
value: 'notion_read_database',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
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'] },
|
||||
id: 'pageId',
|
||||
title: 'Page ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Notion page ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'notion_write',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Create operation fields
|
||||
{
|
||||
id: 'parentId',
|
||||
title: 'Parent Page ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'parentId',
|
||||
placeholder: 'ID of parent page',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] },
|
||||
condition: { field: 'operation', value: 'notion_create_page' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -187,6 +148,14 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
},
|
||||
},
|
||||
// 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',
|
||||
@@ -249,6 +218,14 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
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',
|
||||
@@ -279,6 +256,14 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
},
|
||||
},
|
||||
// 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',
|
||||
@@ -419,7 +404,6 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
}
|
||||
|
||||
// V2 Block with API-aligned outputs
|
||||
|
||||
export const NotionV2Block: BlockConfig<any> = {
|
||||
type: 'notion_v2',
|
||||
name: 'Notion',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -43,7 +42,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'onedrive',
|
||||
requiredScopes: getScopesForService('onedrive'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
},
|
||||
{
|
||||
@@ -150,7 +156,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
canonicalParamId: 'uploadFolderId',
|
||||
serviceId: 'onedrive',
|
||||
selectorKey: 'onedrive.folders',
|
||||
requiredScopes: getScopesForService('onedrive'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
@@ -181,7 +194,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
canonicalParamId: 'createFolderParentId',
|
||||
serviceId: 'onedrive',
|
||||
selectorKey: 'onedrive.folders',
|
||||
requiredScopes: getScopesForService('onedrive'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a parent folder',
|
||||
dependsOn: ['credential'],
|
||||
@@ -207,7 +227,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
canonicalParamId: 'listFolderId',
|
||||
serviceId: 'onedrive',
|
||||
selectorKey: 'onedrive.folders',
|
||||
requiredScopes: getScopesForService('onedrive'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
mimeType: 'application/vnd.microsoft.graph.folder',
|
||||
placeholder: 'Select a folder to list files from',
|
||||
dependsOn: ['credential'],
|
||||
@@ -247,7 +274,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
canonicalParamId: 'downloadFileId',
|
||||
serviceId: 'onedrive',
|
||||
selectorKey: 'onedrive.files',
|
||||
requiredScopes: getScopesForService('onedrive'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
mimeType: 'file', // Exclude folders, show only files
|
||||
placeholder: 'Select a file to download',
|
||||
mode: 'basic',
|
||||
@@ -281,7 +315,14 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
|
||||
canonicalParamId: 'deleteFileId',
|
||||
serviceId: 'onedrive',
|
||||
selectorKey: 'onedrive.files',
|
||||
requiredScopes: getScopesForService('onedrive'),
|
||||
requiredScopes: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'Files.Read',
|
||||
'Files.ReadWrite',
|
||||
'offline_access',
|
||||
],
|
||||
mimeType: 'file', // Exclude folders, show only files
|
||||
placeholder: 'Select a file to delete',
|
||||
mode: 'basic',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -43,7 +42,16 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'outlook',
|
||||
requiredScopes: getScopesForService('outlook'),
|
||||
requiredScopes: [
|
||||
'Mail.ReadWrite',
|
||||
'Mail.ReadBasic',
|
||||
'Mail.Read',
|
||||
'Mail.Send',
|
||||
'offline_access',
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
],
|
||||
placeholder: 'Select Microsoft account',
|
||||
required: true,
|
||||
},
|
||||
@@ -180,7 +188,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
canonicalParamId: 'folder',
|
||||
serviceId: 'outlook',
|
||||
selectorKey: 'outlook.folders',
|
||||
requiredScopes: getScopesForService('outlook'),
|
||||
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
|
||||
placeholder: 'Select Outlook folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
@@ -226,7 +234,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
canonicalParamId: 'destinationId',
|
||||
serviceId: 'outlook',
|
||||
selectorKey: 'outlook.folders',
|
||||
requiredScopes: getScopesForService('outlook'),
|
||||
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
|
||||
placeholder: 'Select destination folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
@@ -273,7 +281,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
|
||||
canonicalParamId: 'copyDestinationId',
|
||||
serviceId: 'outlook',
|
||||
selectorKey: 'outlook.folders',
|
||||
requiredScopes: getScopesForService('outlook'),
|
||||
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
|
||||
placeholder: 'Select destination folder',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -49,7 +48,15 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'pipedrive',
|
||||
requiredScopes: getScopesForService('pipedrive'),
|
||||
requiredScopes: [
|
||||
'base',
|
||||
'deals:full',
|
||||
'contacts:full',
|
||||
'leads:full',
|
||||
'activities:full',
|
||||
'mail:full',
|
||||
'projects:full',
|
||||
],
|
||||
placeholder: 'Select Pipedrive account',
|
||||
required: true,
|
||||
},
|
||||
@@ -89,35 +96,12 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
|
||||
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',
|
||||
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' },
|
||||
placeholder: 'Filter by pipeline ID ',
|
||||
condition: { field: 'operation', value: ['get_all_deals'] },
|
||||
},
|
||||
{
|
||||
id: 'updated_since',
|
||||
@@ -190,6 +174,13 @@ 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',
|
||||
@@ -338,6 +329,14 @@ 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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user