Compare commits

..

5 Commits

Author SHA1 Message Date
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
240 changed files with 2998 additions and 37440 deletions

View File

@@ -144,6 +144,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
provenance: false
sbom: false
no-cache: true
# Build ARM64 images for GHCR (main branch only, runs in parallel)
build-ghcr-arm64:
@@ -204,6 +205,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
provenance: false
sbom: false
no-cache: true
# Create GHCR multi-arch manifests (only for main, after both builds)
create-ghcr-manifests:

View File

@@ -97,6 +97,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
provenance: false
sbom: false
no-cache: true
build-ghcr-arm64:
name: Build ARM64 (GHCR Only)
@@ -143,10 +144,11 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
provenance: false
sbom: false
no-cache: true
create-ghcr-manifests:
name: Create GHCR Manifests
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2404
needs: [build-amd64, build-ghcr-arm64]
if: github.ref == 'refs/heads/main'
strategy:

View File

@@ -110,7 +110,7 @@ jobs:
RESEND_API_KEY: 'dummy_key_for_ci_only'
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
run: bunx turbo run build --filter=sim
run: bun run build
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5

View File

@@ -5819,15 +5819,3 @@ export function RedisIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function HexIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1450.3 600'>
<path
fill='#5F509D'
fillRule='evenodd'
d='m250.11,0v199.49h-50V0H0v600h200.11v-300.69h50v300.69h200.18V0h-200.18Zm249.9,0v600h450.29v-250.23h-200.2v149h-50v-199.46h250.2V0h-450.29Zm200.09,199.49v-99.49h50v99.49h-50Zm550.02,0V0h200.18v150l-100,100.09,100,100.09v249.82h-200.18v-300.69h-50v300.69h-200.11v-249.82l100.11-100.09-100.11-100.09V0h200.11v199.49h50Z'
/>
</svg>
)
}

View File

@@ -54,7 +54,6 @@ import {
GrafanaIcon,
GrainIcon,
GreptileIcon,
HexIcon,
HubspotIcon,
HuggingFaceIcon,
HunterIOIcon,
@@ -197,7 +196,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
grafana: GrafanaIcon,
grain: GrainIcon,
greptile: GreptileIcon,
hex: HexIcon,
hubspot: HubspotIcon,
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,

View File

@@ -1,459 +0,0 @@
---
title: Hex
description: Run and manage Hex projects
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="hex"
color="#F5E6FF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Hex](https://hex.tech/) is a collaborative platform for analytics and data science that allows you to build, run, and share interactive data projects and notebooks. Hex lets teams work together on data exploration, transformation, and visualization, making it easy to turn analysis into shareable insights.
With Hex, you can:
- **Create and run powerful notebooks**: Blend SQL, Python, and visualizations in a single, interactive workspace.
- **Collaborate and share**: Work together with teammates in real time and publish interactive data apps for broader audiences.
- **Automate and orchestrate workflows**: Schedule notebook runs, parameterize runs with inputs, and automate data tasks.
- **Visualize and communicate results**: Turn analysis results into dashboards or interactive apps that anyone can use.
- **Integrate with your data stack**: Connect easily to data warehouses, APIs, and other sources.
The Sim Hex integration allows your AI agents or workflows to:
- List, get, and manage Hex projects directly from Sim.
- Trigger and monitor notebook runs, check their statuses, or cancel them as part of larger automation flows.
- Retrieve run results and use them within Sim-powered processes and decision-making.
- Leverage Hexs interactive analytics capabilities right inside your automated Sim workflows.
Whether youre empowering analysts, automating reporting, or embedding actionable data into your processes, Hex and Sim provide a seamless way to operationalize analytics and bring data-driven insights to your team.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.
## Tools
### `hex_cancel_run`
Cancel an active Hex project run.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project |
| `runId` | string | Yes | The UUID of the run to cancel |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the run was successfully cancelled |
| `projectId` | string | Project UUID |
| `runId` | string | Run UUID that was cancelled |
### `hex_create_collection`
Create a new collection in the Hex workspace to organize projects.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `name` | string | Yes | Name for the new collection |
| `description` | string | No | Optional description for the collection |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Newly created collection UUID |
| `name` | string | Collection name |
| `description` | string | Collection description |
| `creator` | object | Collection creator |
| ↳ `email` | string | Creator email |
| ↳ `id` | string | Creator UUID |
### `hex_get_collection`
Retrieve details for a specific Hex collection by its ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `collectionId` | string | Yes | The UUID of the collection |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Collection UUID |
| `name` | string | Collection name |
| `description` | string | Collection description |
| `creator` | object | Collection creator |
| ↳ `email` | string | Creator email |
| ↳ `id` | string | Creator UUID |
### `hex_get_data_connection`
Retrieve details for a specific data connection including type, description, and configuration flags.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `dataConnectionId` | string | Yes | The UUID of the data connection |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Connection UUID |
| `name` | string | Connection name |
| `type` | string | Connection type \(e.g., snowflake, postgres, bigquery\) |
| `description` | string | Connection description |
| `connectViaSsh` | boolean | Whether SSH tunneling is enabled |
| `includeMagic` | boolean | Whether Magic AI features are enabled |
| `allowWritebackCells` | boolean | Whether writeback cells are allowed |
### `hex_get_group`
Retrieve details for a specific Hex group.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `groupId` | string | Yes | The UUID of the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Group UUID |
| `name` | string | Group name |
| `createdAt` | string | Creation timestamp |
### `hex_get_project`
Get metadata and details for a specific Hex project by its ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Project UUID |
| `title` | string | Project title |
| `description` | string | Project description |
| `status` | object | Project status |
| ↳ `name` | string | Status name \(e.g., PUBLISHED, DRAFT\) |
| `type` | string | Project type \(PROJECT or COMPONENT\) |
| `creator` | object | Project creator |
| ↳ `email` | string | Creator email |
| `owner` | object | Project owner |
| ↳ `email` | string | Owner email |
| `categories` | array | Project categories |
| ↳ `name` | string | Category name |
| ↳ `description` | string | Category description |
| `lastEditedAt` | string | ISO 8601 last edited timestamp |
| `lastPublishedAt` | string | ISO 8601 last published timestamp |
| `createdAt` | string | ISO 8601 creation timestamp |
| `archivedAt` | string | ISO 8601 archived timestamp |
| `trashedAt` | string | ISO 8601 trashed timestamp |
### `hex_get_project_runs`
Retrieve API-triggered runs for a Hex project with optional filtering by status and pagination.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project |
| `limit` | number | No | Maximum number of runs to return \(1-100, default: 25\) |
| `offset` | number | No | Offset for paginated results \(default: 0\) |
| `statusFilter` | string | No | Filter by run status: PENDING, RUNNING, ERRORED, COMPLETED, KILLED, UNABLE_TO_ALLOCATE_KERNEL |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `runs` | array | List of project runs |
| ↳ `projectId` | string | Project UUID |
| ↳ `runId` | string | Run UUID |
| ↳ `runUrl` | string | URL to view the run |
| ↳ `status` | string | Run status \(PENDING, RUNNING, COMPLETED, ERRORED, KILLED, UNABLE_TO_ALLOCATE_KERNEL\) |
| ↳ `startTime` | string | Run start time |
| ↳ `endTime` | string | Run end time |
| ↳ `elapsedTime` | number | Elapsed time in seconds |
| ↳ `traceId` | string | Trace ID |
| ↳ `projectVersion` | number | Project version number |
| `total` | number | Total number of runs returned |
| `traceId` | string | Top-level trace ID |
### `hex_get_queried_tables`
Return the warehouse tables queried by a Hex project, including data connection and table names.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project |
| `limit` | number | No | Maximum number of tables to return \(1-100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tables` | array | List of warehouse tables queried by the project |
| ↳ `dataConnectionId` | string | Data connection UUID |
| ↳ `dataConnectionName` | string | Data connection name |
| ↳ `tableName` | string | Table name |
| `total` | number | Total number of tables returned |
### `hex_get_run_status`
Check the status of a Hex project run by its run ID.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project |
| `runId` | string | Yes | The UUID of the run to check |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `projectId` | string | Project UUID |
| `runId` | string | Run UUID |
| `runUrl` | string | URL to view the run |
| `status` | string | Run status \(PENDING, RUNNING, COMPLETED, ERRORED, KILLED, UNABLE_TO_ALLOCATE_KERNEL\) |
| `startTime` | string | ISO 8601 run start time |
| `endTime` | string | ISO 8601 run end time |
| `elapsedTime` | number | Elapsed time in seconds |
| `traceId` | string | Trace ID for debugging |
| `projectVersion` | number | Project version number |
### `hex_list_collections`
List all collections in the Hex workspace.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `limit` | number | No | Maximum number of collections to return \(1-500, default: 25\) |
| `sortBy` | string | No | Sort by field: NAME |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `collections` | array | List of collections |
| ↳ `id` | string | Collection UUID |
| ↳ `name` | string | Collection name |
| ↳ `description` | string | Collection description |
| ↳ `creator` | object | Collection creator |
| ↳ `email` | string | Creator email |
| ↳ `id` | string | Creator UUID |
| `total` | number | Total number of collections returned |
### `hex_list_data_connections`
List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, BigQuery).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `limit` | number | No | Maximum number of connections to return \(1-500, default: 25\) |
| `sortBy` | string | No | Sort by field: CREATED_AT or NAME |
| `sortDirection` | string | No | Sort direction: ASC or DESC |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `connections` | array | List of data connections |
| ↳ `id` | string | Connection UUID |
| ↳ `name` | string | Connection name |
| ↳ `type` | string | Connection type \(e.g., athena, bigquery, databricks, postgres, redshift, snowflake\) |
| ↳ `description` | string | Connection description |
| ↳ `connectViaSsh` | boolean | Whether SSH tunneling is enabled |
| ↳ `includeMagic` | boolean | Whether Magic AI features are enabled |
| ↳ `allowWritebackCells` | boolean | Whether writeback cells are allowed |
| `total` | number | Total number of connections returned |
### `hex_list_groups`
List all groups in the Hex workspace with optional sorting.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `limit` | number | No | Maximum number of groups to return \(1-500, default: 25\) |
| `sortBy` | string | No | Sort by field: CREATED_AT or NAME |
| `sortDirection` | string | No | Sort direction: ASC or DESC |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groups` | array | List of workspace groups |
| ↳ `id` | string | Group UUID |
| ↳ `name` | string | Group name |
| ↳ `createdAt` | string | Creation timestamp |
| `total` | number | Total number of groups returned |
### `hex_list_projects`
List all projects in your Hex workspace with optional filtering by status.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `limit` | number | No | Maximum number of projects to return \(1-100\) |
| `includeArchived` | boolean | No | Include archived projects in results |
| `statusFilter` | string | No | Filter by status: PUBLISHED, DRAFT, or ALL |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `projects` | array | List of Hex projects |
| ↳ `id` | string | Project UUID |
| ↳ `title` | string | Project title |
| ↳ `description` | string | Project description |
| ↳ `status` | object | Project status |
| ↳ `name` | string | Status name \(e.g., PUBLISHED, DRAFT\) |
| ↳ `type` | string | Project type \(PROJECT or COMPONENT\) |
| ↳ `creator` | object | Project creator |
| ↳ `email` | string | Creator email |
| ↳ `owner` | object | Project owner |
| ↳ `email` | string | Owner email |
| ↳ `lastEditedAt` | string | Last edited timestamp |
| ↳ `lastPublishedAt` | string | Last published timestamp |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `archivedAt` | string | Archived timestamp |
| `total` | number | Total number of projects returned |
### `hex_list_users`
List all users in the Hex workspace with optional filtering and sorting.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `limit` | number | No | Maximum number of users to return \(1-100, default: 25\) |
| `sortBy` | string | No | Sort by field: NAME or EMAIL |
| `sortDirection` | string | No | Sort direction: ASC or DESC |
| `groupId` | string | No | Filter users by group UUID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of workspace users |
| ↳ `id` | string | User UUID |
| ↳ `name` | string | User name |
| ↳ `email` | string | User email |
| ↳ `role` | string | User role \(ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS\) |
| `total` | number | Total number of users returned |
### `hex_run_project`
Execute a published Hex project. Optionally pass input parameters and control caching behavior.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project to run |
| `inputParams` | json | No | JSON object of input parameters for the project \(e.g., \{"date": "2024-01-01"\}\) |
| `dryRun` | boolean | No | If true, perform a dry run without executing the project |
| `updateCache` | boolean | No | \(Deprecated\) If true, update the cached results after execution |
| `updatePublishedResults` | boolean | No | If true, update the published app results after execution |
| `useCachedSqlResults` | boolean | No | If true, use cached SQL results instead of re-running queries |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `projectId` | string | Project UUID |
| `runId` | string | Run UUID |
| `runUrl` | string | URL to view the run |
| `runStatusUrl` | string | URL to check run status |
| `traceId` | string | Trace ID for debugging |
| `projectVersion` | number | Project version number |
### `hex_update_project`
Update a Hex project status label (e.g., endorsement or custom workspace statuses).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `projectId` | string | Yes | The UUID of the Hex project to update |
| `status` | string | Yes | New project status name \(custom workspace status label\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Project UUID |
| `title` | string | Project title |
| `description` | string | Project description |
| `status` | object | Updated project status |
| ↳ `name` | string | Status name \(e.g., PUBLISHED, DRAFT\) |
| `type` | string | Project type \(PROJECT or COMPONENT\) |
| `creator` | object | Project creator |
| ↳ `email` | string | Creator email |
| `owner` | object | Project owner |
| ↳ `email` | string | Owner email |
| `categories` | array | Project categories |
| ↳ `name` | string | Category name |
| ↳ `description` | string | Category description |
| `lastEditedAt` | string | Last edited timestamp |
| `lastPublishedAt` | string | Last published timestamp |
| `createdAt` | string | Creation timestamp |
| `archivedAt` | string | Archived timestamp |
| `trashedAt` | string | Trashed timestamp |

View File

@@ -116,7 +116,7 @@ Create a new service request in Jira Service Management
| `summary` | string | Yes | Summary/title for the service request |
| `description` | string | No | Description for the service request |
| `raiseOnBehalfOf` | string | No | Account ID of customer to raise request on behalf of |
| `requestFieldValues` | json | No | Request field values as key-value pairs \(overrides summary/description if provided\) |
| `requestFieldValues` | json | No | Custom field values as key-value pairs \(overrides summary/description if provided\) |
| `requestParticipants` | string | No | Comma-separated account IDs to add as request participants |
| `channel` | string | No | Channel the request originates from \(e.g., portal, email\) |

View File

@@ -49,7 +49,6 @@
"grafana",
"grain",
"greptile",
"hex",
"hubspot",
"huggingface",
"hunter",

View File

@@ -1,6 +1,6 @@
---
title: Slack
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
description: Send, update, delete messages, add reactions in Slack or trigger workflows from Slack events
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -59,7 +59,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, 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.
Integrate Slack into the workflow. Can send, update, and delete messages, 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.
@@ -80,7 +80,6 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| `dmUserId` | string | No | Slack user ID for direct messages \(e.g., U1234567890\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `threadTs` | string | No | Thread timestamp to reply to \(creates thread reply\) |
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
| `files` | file[] | No | Files to attach to the message |
#### Output
@@ -147,29 +146,6 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| `fileCount` | number | Number of files uploaded \(when files are attached\) |
| `files` | file[] | Files attached to the message |
### `slack_ephemeral_message`
Send an ephemeral message visible only to a specific user in a channel. Optionally reply in a thread. The message does not persist across sessions.
#### 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 | Slack channel ID \(e.g., C1234567890\) |
| `user` | string | Yes | User ID who will see the ephemeral message \(e.g., U1234567890\). Must be a member of the channel. |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `threadTs` | string | No | Thread timestamp to reply in. When provided, the ephemeral message appears as a thread reply. |
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `messageTs` | string | Timestamp of the ephemeral message \(cannot be used with chat.update\) |
| `channel` | string | Channel ID where the ephemeral message was sent |
### `slack_canvas`
Create and share Slack canvases in channels. Canvases are collaborative documents within Slack.
@@ -706,7 +682,6 @@ Update a message previously sent by the bot in Slack
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
| `timestamp` | string | Yes | Timestamp of the message to update \(e.g., 1405894322.002768\) |
| `text` | string | Yes | New message text \(supports Slack mrkdwn formatting\) |
| `blocks` | json | No | Block Kit layout blocks as a JSON array. When provided, text becomes the fallback notification text. |
#### Output

View File

@@ -1,274 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { ArrowLeftRight } from 'lucide-react'
import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
const SCOPE_DESCRIPTIONS: Record<string, string> = {
openid: 'Verify your identity',
profile: 'Access your basic profile information',
email: 'View your email address',
offline_access: 'Maintain access when you are not actively using the app',
'mcp:tools': 'Use Sim workflows and tools on your behalf',
} as const
interface ClientInfo {
clientId: string
name: string
icon: string
}
export default function OAuthConsentPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
const consentCode = searchParams.get('consent_code')
const clientId = searchParams.get('client_id')
const scope = searchParams.get('scope')
const [clientInfo, setClientInfo] = useState<ClientInfo | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const scopes = scope?.split(' ').filter(Boolean) ?? []
useEffect(() => {
if (!clientId) {
setLoading(false)
setError('The authorization request is missing a required client identifier.')
return
}
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
setClientInfo(data)
})
.catch(() => {})
.finally(() => {
setLoading(false)
})
}, [clientId])
const handleConsent = useCallback(
async (accept: boolean) => {
if (!consentCode) {
setError('The authorization request is missing a required consent code.')
return
}
setSubmitting(true)
try {
const res = await fetch('/api/auth/oauth2/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ accept, consent_code: consentCode }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(
(body as Record<string, string> | null)?.message ??
'The consent request could not be processed. Please try again.'
)
setSubmitting(false)
return
}
const data = (await res.json()) as { redirectURI?: string }
if (data.redirectURI) {
window.location.href = data.redirectURI
} else {
setError('The server did not return a redirect. Please try again.')
setSubmitting(false)
}
} catch {
setError('Something went wrong. Please try again.')
setSubmitting(false)
}
},
[consentCode]
)
const handleSwitchAccount = useCallback(async () => {
if (!consentCode) return
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
credentials: 'include',
})
if (!res.ok) {
setError('Unable to switch accounts. Please re-initiate the connection.')
return
}
const params = (await res.json()) as Record<string, string | null>
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
for (const [key, value] of Object.entries(params)) {
if (value) authorizeUrl.searchParams.set(key, value)
}
await signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = authorizeUrl.toString()
},
},
})
}, [consentCode])
if (loading) {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Loading application details...
</p>
</div>
</div>
)
}
if (error) {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorization Error
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{error}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
</div>
</div>
)
}
const clientName = clientInfo?.name ?? clientId
return (
<div className='flex flex-col items-center justify-center'>
<div className='mb-6 flex items-center gap-4'>
{clientInfo?.icon ? (
<img
src={clientInfo.icon}
alt={clientName ?? 'Application'}
width={48}
height={48}
className='rounded-[10px]'
/>
) : (
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
{(clientName ?? '?').charAt(0).toUpperCase()}
</div>
)}
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
<Image
src='/new/logo/colorized-bg.svg'
alt='Sim'
width={48}
height={48}
className='rounded-[10px]'
/>
</div>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
your account
</p>
</div>
{session?.user && (
<div
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
>
{session.user.image ? (
<Image
src={session.user.image}
alt={session.user.name ?? 'User'}
width={32}
height={32}
className='rounded-full'
unoptimized
/>
) : (
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
</div>
)}
<div className='min-w-0'>
{session.user.name && (
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
)}
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
</div>
<button
type='button'
onClick={handleSwitchAccount}
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
>
Switch
</button>
</div>
)}
{scopes.length > 0 && (
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
<div className='rounded-lg border p-4'>
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
<ul className='space-y-2'>
{scopes.map((s) => (
<li
key={s}
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
>
<span className='mt-0.5 text-green-500'>&#10003;</span>
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
</li>
))}
</ul>
</div>
</div>
)}
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
<Button
variant='outline'
size='md'
className='px-6 py-2'
disabled={submitting}
onClick={() => handleConsent(false)}
>
Deny
</Button>
<BrandedButton
fullWidth
showArrow={false}
loading={submitting}
loadingText='Authorizing'
onClick={() => handleConsent(true)}
>
Allow
</BrandedButton>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
}

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
}

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
}

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse(request)
}

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse(request)
}

View File

@@ -23,8 +23,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/chat') ||
pathname.startsWith('/studio') ||
pathname.startsWith('/resume') ||
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
pathname.startsWith('/form')
return (
<NextThemesProvider

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -31,13 +31,15 @@ export async function GET(request: NextRequest) {
})
.from(account)
.where(and(...whereConditions))
.orderBy(desc(account.updatedAt))
// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id,
accountId: acc.accountId,
providerId: acc.providerId,
displayName: acc.accountId || acc.providerId,
displayName: userEmail || acc.providerId,
}))
return NextResponse.json({ accounts: accountsWithDisplayName })

View File

@@ -57,6 +57,10 @@ describe('OAuth Credentials API Route', () => {
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
}))
vi.doMock('jwt-decode', () => ({
jwtDecode: vi.fn(),
}))
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
@@ -80,6 +84,64 @@ describe('OAuth Credentials API Route', () => {
vi.clearAllMocks()
})
it('should return credentials successfully', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
const mockAccounts = [
{
id: 'credential-1',
userId: 'user-123',
providerId: 'google-email',
accountId: 'test@example.com',
updatedAt: new Date('2024-01-01'),
idToken: null,
},
{
id: 'credential-2',
userId: 'user-123',
providerId: 'google-default',
accountId: 'user-id',
updatedAt: new Date('2024-01-02'),
idToken: null,
},
]
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockReturnValueOnce(mockDb)
mockDb.limit.mockResolvedValueOnce([{ email: 'user@example.com' }])
const req = createMockRequestWithQuery('GET', '?provider=google-email')
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(2)
expect(data.credentials[0]).toMatchObject({
id: 'credential-1',
provider: 'google-email',
isDefault: false,
})
expect(data.credentials[1]).toMatchObject({
id: 'credential-2',
provider: 'google-default',
isDefault: true,
})
})
it('should handle unauthenticated user', async () => {
mockGetSession.mockResolvedValueOnce(null)
@@ -136,12 +198,39 @@ describe('OAuth Credentials API Route', () => {
expect(data.credentials).toHaveLength(0)
})
it('should return empty credentials when no workspace context', async () => {
it('should decode ID token for display name', async () => {
const { jwtDecode } = await import('jwt-decode')
const mockJwtDecode = jwtDecode as any
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
const req = createMockRequestWithQuery('GET', '?provider=google-email')
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
const mockAccounts = [
{
id: 'credential-1',
userId: 'user-123',
providerId: 'google-default',
accountId: 'google-user-id',
updatedAt: new Date('2024-01-01'),
idToken: 'mock-jwt-token',
},
]
mockJwtDecode.mockReturnValueOnce({
email: 'decoded@example.com',
name: 'Decoded User',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockResolvedValueOnce(mockAccounts)
const req = createMockRequestWithQuery('GET', '?provider=google')
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
@@ -149,6 +238,31 @@ describe('OAuth Credentials API Route', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
expect(data.credentials[0].name).toBe('decoded@example.com')
})
it('should handle database error', async () => {
mockGetSession.mockResolvedValueOnce({
user: { id: 'user-123' },
})
mockParseProvider.mockReturnValueOnce({
baseProvider: 'google',
})
mockDb.select.mockReturnValueOnce(mockDb)
mockDb.from.mockReturnValueOnce(mockDb)
mockDb.where.mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequestWithQuery('GET', '?provider=google')
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Internal server error')
expect(mockLogger.error).toHaveBeenCalled()
})
})

View File

@@ -1,15 +1,14 @@
import { db } from '@sim/db'
import { account, credential, credentialMember } from '@sim/db/schema'
import { account, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
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 { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
@@ -19,7 +18,6 @@ const credentialsQuerySchema = z
.object({
provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z
.string()
.min(1, 'Credential ID must not be empty')
@@ -31,30 +29,10 @@ const credentialsQuerySchema = z
path: ['provider'],
})
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
interface GoogleIdToken {
email?: string
sub?: string
name?: string
}
/**
@@ -68,7 +46,6 @@ export async function GET(request: NextRequest) {
const rawQuery = {
provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'),
}
@@ -101,7 +78,7 @@ export async function GET(request: NextRequest) {
)
}
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
const { provider: providerParam, workflowId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request)
@@ -111,7 +88,7 @@ export async function GET(request: NextRequest) {
}
const requesterUserId = authResult.userId
let effectiveWorkspaceId = workspaceId ?? undefined
const effectiveUserId = requesterUserId
if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
@@ -129,125 +106,105 @@ export async function GET(request: NextRequest) {
{ status: workflowAuthorization.status }
)
}
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
}
if (effectiveWorkspaceId) {
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Parse the provider to get base provider and feature type (if provider is present)
const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider)
let accountsData
if (credentialId && workflowId) {
// When both workflowId and credentialId are provided, fetch by ID only.
// Workspace authorization above already proves access; the credential
// may belong to another workspace member (e.g. for display name resolution).
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else if (credentialId) {
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
} else {
// Fetch all credentials for provider and effective user
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
}
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
// Transform accounts into credentials
const credentials = await Promise.all(
accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
const [_, featureType = 'default'] = acc.providerId.split('-')
if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
// Try multiple methods to get a user-friendly display name
let displayName = ''
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: acc.userId,
})
}
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
const storedScope = acc.scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
})
)
const credentialsData = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam)
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
)
}
return NextResponse.json({ credentials: [] }, { status: 200 })
return NextResponse.json({ credentials }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View File

@@ -16,7 +16,6 @@ const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(),
accountId: z.string().optional(),
})
/**
@@ -52,20 +51,15 @@ export async function POST(request: NextRequest) {
)
}
const { provider, providerId, accountId } = parseResult.data
const { provider, providerId } = parseResult.data
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider,
hasProviderId: !!providerId,
})
// If a specific account row ID is provided, delete that exact account
if (accountId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
// If a specific providerId is provided, delete accounts for that provider ID
// If a specific providerId is provided, delete only that account
if (providerId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))

View File

@@ -38,18 +38,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
credentialId,
authz.credentialOwnerUserId,
requestId
)

View File

@@ -37,19 +37,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(
resolvedCredentialId,
credentialId,
authz.credentialOwnerUserId,
requestId
)

View File

@@ -344,11 +344,10 @@ describe('OAuth Token API Routes', () => {
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
@@ -374,8 +373,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
@@ -393,8 +392,8 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
@@ -407,16 +406,15 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any)
const data = await response.json()
expect(response.status).toBe(403)
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
@@ -434,11 +432,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle missing access token', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
@@ -461,11 +458,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle token refresh failure', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',

View File

@@ -110,35 +110,23 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
@@ -198,20 +186,13 @@ export async function GET(request: NextRequest) {
const { credentialId } = parseResult.data
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
// For GET requests, we only support session-based authentication
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -223,11 +204,7 @@ export async function GET(request: NextRequest) {
}
try {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined

View File

@@ -62,23 +62,21 @@ describe('OAuth Utils', () => {
describe('getCredential', () => {
it('should return credential when found', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDb.select).toHaveBeenCalledTimes(2)
expect(mockDb.select).toHaveBeenCalled()
expect(mockFrom).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(mockLimit).toHaveBeenCalledWith(1)
expect(credential).toMatchObject(mockAccountRow)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
expect(credential).toEqual(mockCredential)
})
it('should return undefined when credential is not found', async () => {
mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
@@ -160,17 +158,15 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
const mockCredential = {
id: 'credential-id',
accessToken: 'valid-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockSelectChain([mockCredential])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -179,17 +175,15 @@ describe('OAuth Utils', () => {
})
it('should refresh token when expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
const mockCredential = {
id: 'credential-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockSelectChain([mockCredential])
mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({
@@ -207,7 +201,6 @@ describe('OAuth Utils', () => {
it('should return null if credential not found', async () => {
mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -215,17 +208,15 @@ describe('OAuth Utils', () => {
})
it('should return null if refresh fails', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockAccountRow = {
id: 'account-id',
const mockCredential = {
id: 'credential-id',
accessToken: 'expired-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockSelectChain([mockCredential])
mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { account, credential, credentialSetMember } from '@sim/db/schema'
import { account, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { refreshOAuthToken } from '@/lib/oauth'
@@ -25,38 +25,6 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
const [credentialRow] = await db
.select({
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return {
accountId: credentialRow.accountId,
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -84,16 +52,10 @@ export async function safeAccountInsert(
* Get a credential by ID and verify it belongs to the user
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
.where(and(eq(account.id, credentialId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
@@ -101,10 +63,7 @@ export async function getCredential(requestId: string, credentialId: string, use
return undefined
}
return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
}
return credentials[0]
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
@@ -279,9 +238,7 @@ export async function refreshAccessTokenIfNeeded(
}
// Update the token in the database
const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token for credential`)
return refreshedToken.accessToken
@@ -317,8 +274,6 @@ export async function refreshTokenIfNeeded(
credential: any,
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
@@ -379,7 +334,7 @@ export async function refreshTokenIfNeeded(
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
return { accessToken: refreshedToken, refreshed: true }
@@ -388,7 +343,7 @@ export async function refreshTokenIfNeeded(
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
)
const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId)
const freshCredential = await getCredential(requestId, credentialId, credential.userId)
if (freshCredential?.accessToken) {
const freshExpiresAt = freshCredential.accessTokenExpiresAt
const stillValid = !freshExpiresAt || freshExpiresAt > new Date()

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -57,41 +57,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: itemIdValidation.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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).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 credential = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -47,41 +47,27 @@ export async function GET(request: NextRequest) {
)
}
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)
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).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 credential = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -1,59 +0,0 @@
import { db } from '@sim/db'
import { verification } from '@sim/db/schema'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
/**
* Returns the original OAuth authorize parameters stored in the verification record
* for a given consent code. Used by the consent page to reconstruct the authorize URL
* when switching accounts.
*/
export async function GET(request: NextRequest) {
const session = await getSession()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const consentCode = request.nextUrl.searchParams.get('consent_code')
if (!consentCode) {
return NextResponse.json({ error: 'consent_code is required' }, { status: 400 })
}
const [record] = await db
.select({ value: verification.value })
.from(verification)
.where(and(eq(verification.identifier, consentCode), gt(verification.expiresAt, new Date())))
.limit(1)
if (!record) {
return NextResponse.json({ error: 'Invalid or expired consent code' }, { status: 404 })
}
const data = JSON.parse(record.value) as {
clientId: string
redirectURI: string
scope: string[]
userId: string
codeChallenge: string
codeChallengeMethod: string
state: string | null
nonce: string | null
}
if (data.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return NextResponse.json({
client_id: data.clientId,
redirect_uri: data.redirectURI,
scope: data.scope.join(' '),
code_challenge: data.codeChallenge,
code_challenge_method: data.codeChallengeMethod,
state: data.state,
nonce: data.nonce,
response_type: 'code',
})
}

View File

@@ -48,21 +48,16 @@ export async function GET(request: NextRequest) {
const shopData = await shopResponse.json()
const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')),
})
const now = new Date()
const accountData = {
accessToken: accessToken,
accountId: stableAccountId,
accountId: shopInfo.id?.toString() || shopDomain,
scope: scope || '',
updatedAt: now,
idToken: shopDomain,

View File

@@ -52,11 +52,7 @@ export async function POST(request: NextRequest) {
const trelloUser = await userResponse.json()
const existing = await db.query.account.findFirst({
where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')),
})
const now = new Date()

View File

@@ -1,226 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireWorkspaceAdminMembership(credentialId: string, userId: string) {
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) return null
const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId)
if (perm === null) return null
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
return membership
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ members: [] }, { status: 200 })
}
const callerPerm = await getUserEntityPermissions(
session.user.id,
'workspace',
cred.workspaceId
)
if (callerPerm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const members = await db
.select({
id: credentialMember.id,
userId: credentialMember.userId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
.where(eq(credentialMember.credentialId, credentialId))
return NextResponse.json({ members })
} catch (error) {
logger.error('Failed to fetch credential members', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const addMemberSchema = z.object({
userId: z.string().min(1),
role: z.enum(['admin', 'member']).default('member'),
})
export async function POST(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await request.json()
const parsed = addMemberSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, role } = parsed.data
const now = new Date()
const [existing] = await db
.select({ id: credentialMember.id, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (existing) {
await db
.update(credentialMember)
.set({ role, status: 'active', updatedAt: now })
.where(eq(credentialMember.id, existing.id))
return NextResponse.json({ success: true })
}
await db.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId,
role,
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const targetUserId = new URL(request.url).searchParams.get('userId')
if (!targetUserId) {
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
}
const admin = await requireWorkspaceAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const [target] = await db
.select({
id: credentialMember.id,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
const revoked = await db.transaction(async (tx) => {
if (target.role === 'admin') {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return false
}
}
await tx
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return true
})
if (!revoked) {
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,251 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
const logger = createLogger('CredentialByIdAPI')
const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
})
.strict()
.refine((data) => data.displayName !== undefined || data.description !== undefined, {
message: 'At least one field must be provided',
path: ['displayName'],
})
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credential)
.innerJoin(
credentialMember,
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
)
.where(eq(credential.id, credentialId))
.limit(1)
return row ?? null
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.member) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to fetch credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const parseResult = updateCredentialSchema.safeParse(await request.json())
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (parseResult.data.description !== undefined) {
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
updates.displayName = parseResult.data.displayName
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ status: 400 }
)
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, id))
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(environment)
.values({
id: ownerUserId,
userId: ownerUserId,
variables: current,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
return NextResponse.json({ success: true }, { status: 200 })
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || crypto.randomUUID(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
envKeys: Object.keys(current),
actingUserId: session.user.id,
})
return NextResponse.json({ success: true }, { status: 200 })
}
await db.delete(credential).where(eq(credential.id, id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,116 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember, pendingCredentialDraft } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CredentialDraftAPI')
const DRAFT_TTL_MS = 15 * 60 * 1000
const createDraftSchema = z.object({
workspaceId: z.string().min(1),
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
credentialId: z.string().min(1).optional(),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = createDraftSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { workspaceId, providerId, displayName, description, credentialId } = parsed.data
const userId = session.user.id
const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
if (credentialId) {
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.innerJoin(credential, eq(credential.id, credentialMember.credentialId))
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, userId),
eq(credentialMember.status, 'active'),
eq(credentialMember.role, 'admin'),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
if (!membership) {
return NextResponse.json(
{ error: 'Admin access required on the target credential' },
{ status: 403 }
)
}
}
const now = new Date()
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: crypto.randomUUID(),
userId,
workspaceId,
providerId,
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: {
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
logger.info('Credential draft saved', {
userId,
workspaceId,
providerId,
displayName,
credentialId: credentialId || null,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to save credential draft', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,120 +0,0 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembershipsAPI')
const leaveCredentialSchema = z.object({
credentialId: z.string().min(1),
})
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const memberships = await db
.select({
membershipId: credentialMember.id,
credentialId: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
})
.from(credentialMember)
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
.where(eq(credentialMember.userId, session.user.id))
return NextResponse.json({ memberships }, { status: 200 })
} catch (error) {
logger.error('Failed to list credential memberships', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parseResult = leaveCredentialSchema.safeParse({
credentialId: new URL(request.url).searchParams.get('credentialId'),
})
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { credentialId } = parseResult.data
const [membership] = await db
.select()
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
}
if (membership.status !== 'active') {
return NextResponse.json({ success: true }, { status: 200 })
}
const revoked = await db.transaction(async (tx) => {
if (membership.role === 'admin') {
const activeAdmins = await tx
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return false
}
}
await tx
.update(credentialMember)
.set({
status: 'revoked',
updatedAt: new Date(),
})
.where(eq(credentialMember.id, membership.id))
return true
})
if (!revoked) {
return NextResponse.json(
{ error: 'Cannot leave credential as the last active admin' },
{ status: 400 }
)
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to leave credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,520 +0,0 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
return wrappedMatch ? wrappedMatch[1] : trimmed
}
const listCredentialsSchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema.optional(),
providerId: z.string().optional(),
credentialId: z.string().optional(),
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'accountId is required for oauth credentials',
path: ['accountId'],
})
}
if (!data.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'providerId is required for oauth credentials',
path: ['providerId'],
})
}
if (!data.displayName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'displayName is required for oauth credentials',
path: ['displayName'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey is required for env credentials',
path: ['envKey'],
})
return
}
if (!isValidEnvVarName(normalizedEnvKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey must contain only letters, numbers, and underscores',
path: ['envKey'],
})
}
})
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth'),
eq(credential.accountId, accountId)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_workspace' && envKey) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_workspace'),
eq(credential.envKey, envKey)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_personal' && envKey && envOwnerUserId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envKey, envKey),
eq(credential.envOwnerUserId, envOwnerUserId)
)
)
.limit(1)
return row ?? null
}
return null
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const rawWorkspaceId = searchParams.get('workspaceId')
const rawType = searchParams.get('type')
const rawProviderId = searchParams.get('providerId')
const rawCredentialId = searchParams.get('credentialId')
const parseResult = listCredentialsSchema.safeParse({
workspaceId: rawWorkspaceId?.trim(),
type: rawType?.trim() || undefined,
providerId: rawProviderId?.trim() || undefined,
credentialId: rawCredentialId?.trim() || undefined,
})
if (!parseResult.success) {
logger.warn(`[${requestId}] Invalid credential list request`, {
workspaceId: rawWorkspaceId,
type: rawType,
providerId: rawProviderId,
errors: parseResult.error.errors,
})
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (lookupCredentialId) {
let [row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
if (!row) {
;[row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(
and(
eq(credential.accountId, lookupCredentialId),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
}
return NextResponse.json({ credential: row ?? null })
}
if (!type || type === 'oauth') {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
const whereClauses = [eq(credential.workspaceId, workspaceId)]
if (type) {
whereClauses.push(eq(credential.type, type))
}
if (providerId) {
whereClauses.push(eq(credential.providerId, providerId))
}
const credentials = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active')
)
)
.where(and(...whereClauses))
return NextResponse.json({ credentials })
} catch (error) {
logger.error(`[${requestId}] Failed to list credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const parseResult = createCredentialSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const {
workspaceId,
type,
displayName,
description,
providerId,
accountId,
envKey,
envOwnerUserId,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
let resolvedDisplayName = displayName?.trim() ?? ''
const resolvedDescription = description?.trim() || null
let resolvedProviderId: string | null = providerId ?? null
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
})
.from(account)
.where(eq(account.id, accountId!))
.limit(1)
if (!accountRow) {
return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
}
if (accountRow.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Only account owners can create oauth credentials for an account' },
{ status: 403 }
)
}
if (providerId !== accountRow.providerId) {
return NextResponse.json(
{ error: 'providerId does not match the selected OAuth account' },
{ status: 400 }
)
}
if (!resolvedDisplayName) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
return NextResponse.json(
{ error: 'Only the current user can create personal env credentials for themselves' },
{ status: 403 }
)
}
resolvedProviderId = null
resolvedAccountId = null
resolvedDisplayName = resolvedEnvKey || ''
} else {
resolvedProviderId = null
resolvedAccountId = null
resolvedEnvOwnerUserId = null
resolvedDisplayName = resolvedEnvKey || ''
}
if (!resolvedDisplayName) {
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
}
const existingCredential = await findExistingCredentialBySource({
workspaceId,
type,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
})
if (existingCredential) {
const [membership] = await db
.select({
id: credentialMember.id,
status: credentialMember.status,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, existingCredential.id),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership || membership.status !== 'active') {
return NextResponse.json(
{ error: 'A credential with this source already exists in this workspace' },
{ status: 409 }
)
}
const canUpdateExistingCredential = membership.role === 'admin'
const shouldUpdateDisplayName =
type === 'oauth' &&
resolvedDisplayName &&
resolvedDisplayName !== existingCredential.displayName
const shouldUpdateDescription =
typeof description !== 'undefined' &&
(existingCredential.description ?? null) !== resolvedDescription
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
await db
.update(credential)
.set({
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
updatedAt: new Date(),
})
.where(eq(credential.id, existingCredential.id))
const [updatedCredential] = await db
.select()
.from(credential)
.where(eq(credential.id, existingCredential.id))
.limit(1)
return NextResponse.json(
{ credential: updatedCredential ?? existingCredential },
{ status: 200 }
)
}
return NextResponse.json({ credential: existingCredential }, { status: 200 })
}
const now = new Date()
const credentialId = crypto.randomUUID()
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
await db.transaction(async (tx) => {
await tx.insert(credential).values({
id: credentialId,
workspaceId,
type,
displayName: resolvedDisplayName,
description: resolvedDescription,
providerId: resolvedProviderId,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: memberUserId,
role:
memberUserId === workspaceRow.ownerId || memberUserId === session.user.id
? 'admin'
: 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
}
} else {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: session.user.id,
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
})
const [created] = await db
.select()
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
{ status: 409 }
)
}
if (error?.code === '23503') {
return NextResponse.json(
{ error: 'Invalid credential reference or membership target' },
{ status: 400 }
)
}
if (error?.code === '23514') {
return NextResponse.json(
{ error: 'Credential source data failed validation checks' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Credential create failure details`, {
code: error?.code,
detail: error?.detail,
constraint: error?.constraint,
table: error?.table,
message: error?.message,
})
logger.error(`[${requestId}] Failed to create credential`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -4,27 +4,18 @@ import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('TeamsSubscriptionRenewal')
async function getCredentialOwner(
credentialId: string
): Promise<{ userId: string; accountId: string } | null> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.error(`Failed to resolve OAuth account for credential ${credentialId}`)
return null
}
async function getCredentialOwnerUserId(credentialId: string): Promise<string | null> {
const [credentialRecord] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolved.accountId))
.where(eq(account.id, credentialId))
.limit(1)
return credentialRecord
? { userId: credentialRecord.userId, accountId: resolved.accountId }
: null
return credentialRecord?.userId ?? null
}
/**
@@ -97,8 +88,8 @@ export async function GET(request: NextRequest) {
continue
}
const credentialOwner = await getCredentialOwner(credentialId)
if (!credentialOwner) {
const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId)
if (!credentialOwnerUserId) {
logger.error(`Credential owner not found for credential ${credentialId}`)
totalFailed++
continue
@@ -106,8 +97,8 @@ export async function GET(request: NextRequest) {
// Get fresh access token
const accessToken = await refreshAccessTokenIfNeeded(
credentialOwner.accountId,
credentialOwner.userId,
credentialId,
credentialOwnerUserId,
`renewal-${webhook.id}`
)

View File

@@ -8,7 +8,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment'
const logger = createLogger('EnvironmentAPI')
@@ -55,11 +54,6 @@ export async function POST(req: NextRequest) {
},
})
await syncPersonalEnvCredentialsForUser({
userId: session.user.id,
envKeys: Object.keys(variables),
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,

View File

@@ -211,7 +211,7 @@ describe('Function Execute API Route', () => {
it.concurrent('should block SSRF attacks through secure fetch wrapper', async () => {
expect(validateProxyUrl('http://169.254.169.254/latest/meta-data/').isValid).toBe(false)
expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(true)
expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(false)
expect(validateProxyUrl('http://192.168.1.1/config').isValid).toBe(false)
expect(validateProxyUrl('http://10.0.0.1/internal').isValid).toBe(false)
})

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpAuthorizationServerMetadataResponse(request)
}

View File

@@ -1,6 +1,6 @@
import type { NextResponse } from 'next/server'
import type { NextRequest, NextResponse } from 'next/server'
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
export async function GET(): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse()
export async function GET(request: NextRequest): Promise<NextResponse> {
return createMcpProtectedResourceMetadataResponse(request)
}

View File

@@ -16,7 +16,6 @@ import { userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
ORCHESTRATION_TIMEOUT_MS,
@@ -32,7 +31,6 @@ import {
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
import { env } from '@/lib/core/config/env'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
authorizeWorkflowByWorkspacePermission,
resolveWorkflowIdForUser,
@@ -386,14 +384,12 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
...(tool.annotations && { annotations: tool.annotations }),
}))
const subagentTools = SUBAGENT_TOOL_DEFS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
...(tool.annotations && { annotations: tool.annotations }),
}))
const result: ListToolsResult = {
@@ -406,51 +402,27 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const headers = (extra.requestInfo?.headers || {}) as HeaderMap
const apiKeyHeader = readHeader(headers, 'x-api-key')
const authorizationHeader = readHeader(headers, 'authorization')
let authResult: CopilotKeyAuthResult = { success: false }
if (authorizationHeader?.startsWith('Bearer ')) {
const token = authorizationHeader.slice(7)
const oauthResult = await validateOAuthAccessToken(token)
if (oauthResult.success && oauthResult.userId) {
if (!oauthResult.scopes?.includes('mcp:tools')) {
return {
content: [
{
type: 'text' as const,
text: 'AUTHENTICATION ERROR: OAuth token is missing the required "mcp:tools" scope. Re-authorize with the correct scopes.',
},
],
isError: true,
}
}
authResult = { success: true, userId: oauthResult.userId }
} else {
return {
content: [
{
type: 'text' as const,
text: `AUTHENTICATION ERROR: ${oauthResult.error ?? 'Invalid OAuth access token'} Do NOT retry — re-authorize via OAuth.`,
},
],
isError: true,
}
}
} else if (apiKeyHeader) {
authResult = await authenticateCopilotApiKey(apiKeyHeader)
}
if (!authResult.success || !authResult.userId) {
const errorMsg = apiKeyHeader
? `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`
: 'AUTHENTICATION ERROR: No authentication provided. Provide a Bearer token (OAuth 2.1) or an x-api-key header. Generate a Copilot API key in Settings → Copilot.'
logger.warn('MCP copilot auth failed', { method: request.method })
if (!apiKeyHeader) {
return {
content: [
{
type: 'text' as const,
text: errorMsg,
text: 'AUTHENTICATION ERROR: No Copilot API key provided. The user must set their Copilot API key in the x-api-key header. They can generate one in the Sim app under Settings → Copilot. Do NOT retry — this will fail until the key is configured.',
},
],
isError: true,
}
}
const authResult = await authenticateCopilotApiKey(apiKeyHeader)
if (!authResult.success || !authResult.userId) {
logger.warn('MCP copilot key auth failed', { method: request.method })
return {
content: [
{
type: 'text' as const,
text: `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`,
},
],
isError: true,
@@ -540,20 +512,6 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key')
if (!hasAuth) {
const origin = getBaseUrl().replace(/\/$/, '')
const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource/api/mcp/copilot`
return new NextResponse(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: {
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`,
'Content-Type': 'application/json',
},
})
}
try {
let parsedBody: unknown
@@ -574,19 +532,6 @@ export async function POST(request: NextRequest) {
}
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
'Access-Control-Allow-Headers':
'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
'Access-Control-Max-Age': '86400',
},
})
}
export async function DELETE(request: NextRequest) {
void request
return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 })

View File

@@ -11,7 +11,6 @@ import {
user,
userStats,
type WorkspaceInvitationStatus,
workspaceEnvironment,
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -25,7 +24,6 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation')
@@ -498,34 +496,6 @@ export async function PUT(
}
})
if (status === 'accepted') {
const acceptedWsInvitations = await db
.select({ workspaceId: workspaceInvitation.workspaceId })
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.orgInvitationId, invitationId),
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
)
)
for (const wsInv of acceptedWsInvitations) {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: wsInv.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
}
}
// Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) {
try {

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -360,20 +360,15 @@ function sanitizeObject(obj: any): any {
async function resolveVertexCredential(requestId: string, credentialId: string): Promise<string> {
logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
where: eq(account.id, credentialId),
})
if (!credential) {
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
if (!accessToken) {
throw new Error('Failed to get Vertex AI access token')

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, 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 { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -41,27 +41,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: labelIdValidation.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))
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
@@ -69,17 +52,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const credential = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,12 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, 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 { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GmailLabelsAPI')
@@ -45,45 +45,27 @@ export async function GET(request: NextRequest) {
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
let credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
}
const accountRow = credentials[0]
const credential = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -6,7 +6,7 @@ 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 { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { PlannerTask } from '@/tools/microsoft_planner/types'
const logger = createLogger('MicrosoftPlannerTasksAPI')
@@ -42,41 +42,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: planIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).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 credential = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -6,7 +6,7 @@ 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 { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -45,40 +45,22 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Fetching credential`, { credentialId })
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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).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 credential = credentials[0]
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -6,7 +6,7 @@ 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 { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -34,39 +34,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: fileIdValidation.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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ 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 { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -40,39 +40,17 @@ export async function GET(request: NextRequest) {
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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -44,28 +44,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
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 creds = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!creds.length) {
logger.warn('Credential not found', { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -73,7 +52,7 @@ export async function GET(request: Request) {
const credentialOwnerUserId = creds[0].userId
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
credentialId,
credentialOwnerUserId,
generateRequestId()
)

View File

@@ -6,7 +6,7 @@ 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 { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -34,39 +34,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: siteIdValidation.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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -6,7 +6,7 @@ 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 { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { SharepointSite } from '@/tools/sharepoint/types'
export const dynamic = 'force-dynamic'
@@ -39,39 +39,17 @@ export async function GET(request: NextRequest) {
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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const credential = credentials[0]
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}

View File

@@ -1,96 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
const logger = createLogger('SlackSendEphemeralAPI')
const SlackSendEphemeralSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
channel: z.string().min(1, 'Channel ID is required'),
user: z.string().min(1, 'User ID is required'),
text: z.string().min(1, 'Message text is required'),
thread_ts: z.string().optional().nullable(),
blocks: z.array(z.record(z.unknown())).optional().nullable(),
})
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Slack ephemeral send attempt: ${authResult.error}`)
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
logger.info(
`[${requestId}] Authenticated Slack ephemeral send request via ${authResult.authType}`,
{ userId: authResult.userId }
)
const body = await request.json()
const validatedData = SlackSendEphemeralSchema.parse(body)
logger.info(`[${requestId}] Sending ephemeral message`, {
channel: validatedData.channel,
user: validatedData.user,
threadTs: validatedData.thread_ts ?? undefined,
})
const response = await fetch('https://slack.com/api/chat.postEphemeral', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${validatedData.accessToken}`,
},
body: JSON.stringify({
channel: validatedData.channel,
user: validatedData.user,
text: validatedData.text,
...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }),
...(validatedData.blocks &&
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
}),
})
const data = await response.json()
if (!data.ok) {
logger.error(`[${requestId}] Slack API error:`, data.error)
return NextResponse.json(
{ success: false, error: data.error || 'Failed to send ephemeral message' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Ephemeral message sent successfully`)
return NextResponse.json({
success: true,
output: {
messageTs: data.message_ts,
channel: validatedData.channel,
},
})
} catch (error) {
logger.error(`[${requestId}] Error sending ephemeral message:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
{ status: 500 }
)
}
}

View File

@@ -17,7 +17,6 @@ const SlackSendMessageSchema = z
userId: z.string().optional().nullable(),
text: z.string().min(1, 'Message text is required'),
thread_ts: z.string().optional().nullable(),
blocks: z.array(z.record(z.unknown())).optional().nullable(),
files: RawFileInputArraySchema.optional().nullable(),
})
.refine((data) => data.channel || data.userId, {
@@ -64,7 +63,6 @@ export async function POST(request: NextRequest) {
userId: validatedData.userId ?? undefined,
text: validatedData.text,
threadTs: validatedData.thread_ts ?? undefined,
blocks: validatedData.blocks ?? undefined,
files: validatedData.files ?? undefined,
},
requestId,

View File

@@ -13,7 +13,6 @@ const SlackUpdateMessageSchema = z.object({
channel: z.string().min(1, 'Channel is required'),
timestamp: z.string().min(1, 'Message timestamp is required'),
text: z.string().min(1, 'Message text is required'),
blocks: z.array(z.record(z.unknown())).optional().nullable(),
})
export async function POST(request: NextRequest) {
@@ -58,8 +57,6 @@ export async function POST(request: NextRequest) {
channel: validatedData.channel,
ts: validatedData.timestamp,
text: validatedData.text,
...(validatedData.blocks &&
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
}),
})

View File

@@ -11,8 +11,7 @@ export async function postSlackMessage(
accessToken: string,
channel: string,
text: string,
threadTs?: string | null,
blocks?: unknown[] | null
threadTs?: string | null
): Promise<{ ok: boolean; ts?: string; channel?: string; message?: any; error?: string }> {
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
@@ -24,7 +23,6 @@ export async function postSlackMessage(
channel,
text,
...(threadTs && { thread_ts: threadTs }),
...(blocks && blocks.length > 0 && { blocks }),
}),
})
@@ -222,7 +220,6 @@ export interface SlackMessageParams {
userId?: string
text: string
threadTs?: string | null
blocks?: unknown[] | null
files?: any[] | null
}
@@ -245,7 +242,7 @@ export async function sendSlackMessage(
}
error?: string
}> {
const { accessToken, text, threadTs, blocks, files } = params
const { accessToken, text, threadTs, files } = params
let { channel } = params
if (!channel && params.userId) {
@@ -261,7 +258,7 @@ export async function sendSlackMessage(
if (!files || files.length === 0) {
logger.info(`[${requestId}] No files, using chat.postMessage`)
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
const data = await postSlackMessage(accessToken, channel, text, threadTs)
if (!data.ok) {
logger.error(`[${requestId}] Slack API error:`, data.error)
@@ -285,7 +282,7 @@ export async function sendSlackMessage(
if (fileIds.length === 0) {
logger.warn(`[${requestId}] No valid files to upload, sending text-only message`)
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
const data = await postSlackMessage(accessToken, channel, text, threadTs)
if (!data.ok) {
return { success: false, error: data.error || 'Failed to send message' }

View File

@@ -165,7 +165,7 @@ export async function POST(request: NextRequest) {
}
const modelName =
provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5'
provider === 'anthropic' ? 'anthropic/claude-3-7-sonnet-latest' : 'openai/gpt-4.1'
try {
logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName })

View File

@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
try {
const modelName =
provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5'
provider === 'anthropic' ? 'anthropic/claude-3-7-sonnet-latest' : 'openai/gpt-4.1'
logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName })

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -64,41 +64,24 @@ export async function GET(request: NextRequest) {
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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).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 credential = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -64,41 +64,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: typeValidation.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)
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).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 credential = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)

View File

@@ -25,7 +25,6 @@ import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -216,8 +215,6 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (_, context) => {
await db.delete(permissions).where(eq(permissions.id, memberId))
await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId)
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId,
})

View File

@@ -32,10 +32,9 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -233,20 +232,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
permissionId,
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: body.userId,
})
}
return singleResponse({
id: permissionId,
workspaceId,

View File

@@ -1,8 +1,9 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { verifyInternalToken } from '@/lib/auth/internal'
import { generateRequestId } from '@/lib/core/utils/request'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -42,21 +43,21 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Internal API call for deployed workflow: ${id}`)
}
let deployedState = null
try {
const data = await loadDeployedWorkflowState(id)
deployedState = {
blocks: data.blocks,
edges: data.edges,
loops: data.loops,
parallels: data.parallels,
variables: data.variables,
}
} catch {
deployedState = null
}
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.orderBy(desc(workflowDeploymentVersion.createdAt))
.limit(1)
const response = createSuccessResponse({ deployedState })
const response = createSuccessResponse({
deployedState: active?.state || null,
})
return addNoCacheHeaders(response)
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)

View File

@@ -38,7 +38,6 @@ import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/
import { normalizeName } from '@/executor/constants'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type {
ChildWorkflowContext,
ExecutionMetadata,
IterationContext,
SerializableExecutionState,
@@ -537,7 +536,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
}
@@ -744,8 +742,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
iterationContext?: IterationContext
) => {
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
sendEvent({
@@ -764,10 +761,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
}),
...(childWorkflowContext && {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}),
},
})
}
@@ -777,20 +770,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockName: string,
blockType: string,
callbackData: any,
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
iterationContext?: IterationContext
) => {
const hasError = callbackData.output?.error
const childWorkflowData = childWorkflowContext
? {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}
: {}
const instanceData = callbackData.childWorkflowInstanceId
? { childWorkflowInstanceId: callbackData.childWorkflowInstanceId }
: {}
if (hasError) {
logger.info(`[${requestId}] ✗ onBlockComplete (error) called:`, {
@@ -820,8 +802,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
}),
...childWorkflowData,
...instanceData,
},
})
} else {
@@ -851,8 +831,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
}),
...childWorkflowData,
...instanceData,
},
})
}
@@ -907,7 +885,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
}
@@ -921,34 +898,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
selectedOutputs
)
const onChildWorkflowInstanceReady = (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext
) => {
sendEvent({
type: 'block:childWorkflowStarted',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
blockId,
childWorkflowInstanceId,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationContainerId: iterationContext.iterationContainerId,
}),
},
})
}
const result = await executeWorkflowCore({
snapshot,
callbacks: {
onBlockStart,
onBlockComplete,
onStream,
onChildWorkflowInstanceReady,
},
loggingSession,
abortSignal: timeoutController.signal,

View File

@@ -1,15 +1,13 @@
import { db } from '@sim/db'
import { workspaceEnvironment } from '@sim/db/schema'
import { environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -47,10 +45,44 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
userId,
workspaceId
)
// Workspace env (encrypted)
const wsEnvRow = await db
.select()
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
// Personal env (encrypted)
const personalRow = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
// Decrypt both for UI
const decryptAll = async (src: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(src)) {
try {
const { decrypted } = await decryptSecret(v)
out[k] = decrypted
} catch {
out[k] = ''
}
}
return out
}
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
decryptAll(wsEncrypted),
decryptAll(personalEncrypted),
])
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
return NextResponse.json(
{
@@ -125,12 +157,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
set: { variables: merged, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(merged),
actingUserId: userId,
})
recordAudit({
workspaceId,
actorId: userId,
@@ -210,12 +236,6 @@ export async function DELETE(
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(current),
actingUserId: userId,
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Workspace env DELETE error`, error)

View File

@@ -1,13 +1,12 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema'
import { permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import {
getUsersWithPermissions,
hasWorkspaceAdminAccess,
@@ -156,20 +155,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
const updatedUsers = await getUsersWithPermissions(workspaceId)
for (const update of body.updates) {

View File

@@ -8,27 +8,15 @@ const mockHasWorkspaceAdminAccess = vi.fn()
let dbSelectResults: any[] = []
let dbSelectCallIndex = 0
const mockDbSelect = vi.fn().mockImplementation(() => {
const makeThen = () =>
vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(callback ? callback(result) : result)
})
const makeLimit = () =>
vi.fn().mockImplementation(() => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(result)
})
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = makeLimit()
chain.then = makeThen()
return chain
})
const mockDbSelect = vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(callback ? callback(result) : result)
}),
}))
const mockDbInsert = vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue(undefined),
@@ -65,10 +53,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
mockHasWorkspaceAdminAccess(userId, workspaceId),
}))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/audit/log', () => auditMock)
@@ -113,10 +97,6 @@ vi.mock('@sim/db/schema', () => ({
userId: 'userId',
permissionType: 'permissionType',
},
workspaceEnvironment: {
workspaceId: 'workspaceId',
variables: 'variables',
},
}))
vi.mock('drizzle-orm', () => ({
@@ -229,7 +209,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }],
[],
[],
]
const request = new NextRequest(
@@ -483,7 +462,6 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }],
[],
[],
]
const request2 = new NextRequest(

View File

@@ -6,7 +6,6 @@ import {
user,
type WorkspaceInvitationStatus,
workspace,
workspaceEnvironment,
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -16,7 +15,6 @@ import { WorkspaceInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -165,20 +163,6 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id))
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: invitation.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
recordAudit({
workspaceId: invitation.workspaceId,
actorId: session.user.id,

View File

@@ -6,7 +6,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceMemberAPI')
@@ -103,8 +102,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
)
)
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
recordAudit({
workspaceId,
actorId: session.user.id,

View File

@@ -208,10 +208,9 @@ export default function Logs() {
const selectedLog = useMemo(() => {
if (!selectedLogFromList) return null
if (!activeLogQuery.data || isPreviewOpen || activeLogQuery.isPlaceholderData)
return selectedLogFromList
if (!activeLogQuery.data || isPreviewOpen) return selectedLogFromList
return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, activeLogQuery.isPlaceholderData, isPreviewOpen])
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
const handleLogHover = useCallback(
(log: WorkflowLog) => {
@@ -651,7 +650,7 @@ export default function Logs() {
hasActiveFilters={filtersActive}
/>
{isPreviewOpen && !activeLogQuery.isPlaceholderData && activeLogQuery.data?.executionId && (
{isPreviewOpen && activeLogQuery.data?.executionId && (
<ExecutionSnapshot
executionId={activeLogQuery.data.executionId}
traceSpans={activeLogQuery.data.executionData?.traceSpans}

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -43,10 +42,44 @@ export function useChangeDetection({
const currentState = useMemo((): WorkflowState | null => {
if (!workflowId) return null
const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {})
const blocksWithSubBlocks: WorkflowState['blocks'] = {}
for (const [blockId, block] of Object.entries(blocks)) {
const blockSubValues = subBlockValues?.[blockId] || {}
const subBlocks: Record<string, any> = {}
if (block.subBlocks) {
for (const [subId, subBlock] of Object.entries(block.subBlocks)) {
const storedValue = blockSubValues[subId]
subBlocks[subId] = {
...subBlock,
value: storedValue !== undefined ? storedValue : subBlock.value,
}
}
}
if (block.triggerMode) {
const triggerConfigValue = blockSubValues?.triggerConfig
if (
triggerConfigValue &&
typeof triggerConfigValue === 'object' &&
!subBlocks.triggerConfig
) {
subBlocks.triggerConfig = {
id: 'triggerConfig',
type: 'short-input',
value: triggerConfigValue,
}
}
}
blocksWithSubBlocks[blockId] = {
...block,
subBlocks,
}
}
return {
blocks: mergedBlocks,
blocks: blocksWithSubBlocks,
edges,
loops,
parallels,

View File

@@ -30,7 +30,6 @@ export interface OAuthRequiredModalProps {
requiredScopes?: string[]
serviceId: string
newScopes?: string[]
onConnect?: () => Promise<void> | void
}
const SCOPE_DESCRIPTIONS: Record<string, string> = {
@@ -315,7 +314,6 @@ export function OAuthRequiredModal({
requiredScopes = [],
serviceId,
newScopes = [],
onConnect,
}: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider)
@@ -361,12 +359,6 @@ export function OAuthRequiredModal({
setError(null)
try {
if (onConnect) {
await onConnect()
onClose()
return
}
const providerId = getProviderIdFromServiceId(serviceId)
logger.info('Linking OAuth2:', {

View File

@@ -1,13 +1,12 @@
'use client'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -19,15 +18,15 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET } from '@/executor/constants'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOAuthCredentialDetail, 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 logger = createLogger('CredentialSelector')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
interface CredentialSelectorProps {
@@ -47,8 +46,6 @@ export function CredentialSelector({
previewValue,
previewContextValues,
}: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
@@ -99,63 +96,64 @@ export function CredentialSelector({
data: credentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
)
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
setInaccessibleCredentialName(null)
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName])
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const invalidSelection =
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -197,18 +195,8 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
setShowOAuthModal(true)
}, [])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -263,18 +251,23 @@ export function CredentialSelector({
label: cred.name,
value: cred.id,
}))
credentialItems.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
groups.push({
section: 'Personal Credential',
items: credentialItems,
})
if (credentialItems.length > 0) {
groups.push({
section: 'Personal Credential',
items: credentialItems,
})
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
return { comboboxOptions: [], comboboxGroups: groups }
}
@@ -284,13 +277,12 @@ export function CredentialSelector({
value: cred.id,
}))
options.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
if (credentials.length === 0) {
options.push({
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
}
return { comboboxOptions: options, comboboxGroups: undefined }
}, [
@@ -376,7 +368,7 @@ export function CredentialSelector({
}
disabled={effectiveDisabled}
editable={true}
filterOptions={true}
filterOptions={!isForeign && !isForeignCredentialSet}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
@@ -388,13 +380,15 @@ export function CredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required
</div>
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
{!isForeign && (
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
)}
</div>
)}
@@ -412,3 +406,31 @@ export function CredentialSelector({
</div>
)
}
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
useEffect(() => {
const refresh = () => {
void refetchCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refresh()
}
}
const handlePageShow = (event: Event) => {
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [refetchCredentials])
}

View File

@@ -9,7 +9,6 @@ import {
PopoverSection,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
usePersonalEnvironment,
useWorkspaceEnvironment,
@@ -169,15 +168,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}, [searchTerm])
const openEnvironmentSettings = () => {
if (workspaceId) {
writePendingCredentialCreateRequest({
workspaceId,
type: 'env_personal',
envKey: searchTerm.trim(),
requestedAt: Date.now(),
})
}
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } }))
onClose?.()
}
@@ -311,7 +302,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}}
>
<Plus className='h-3 w-3' />
<span>Create Secret</span>
<span>Create environment variable</span>
</PopoverItem>
</PopoverScrollArea>
) : (

View File

@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry'
@@ -124,6 +125,8 @@ export function FileSelectorInput({
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl,
@@ -165,6 +168,7 @@ export function FileSelectorInput({
const disabledReason =
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingDomain ||
missingProject ||

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types'
@@ -46,6 +47,10 @@ export function FolderSelectorInput({
subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
@@ -114,7 +119,9 @@ export function FolderSelectorInput({
selectorContext={
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
}
disabled={finalDisabled || missingCredential || !selectorResolution?.key}
disabled={
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select folder'}

View File

@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry'
@@ -72,6 +73,11 @@ export function ProjectSelectorInput({
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
@@ -117,7 +123,7 @@ export function ProjectSelectorInput({
subBlock={subBlock}
selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context}
disabled={finalDisabled || missingCredential}
disabled={finalDisabled || isForeignCredential || missingCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select project'}

View File

@@ -7,6 +7,7 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
@@ -86,6 +87,8 @@ export function SheetSelectorInput({
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl,
@@ -98,7 +101,11 @@ export function SheetSelectorInput({
const missingSpreadsheet = !normalizedSpreadsheetId
const disabledReason =
finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key
finalDisabled ||
isForeignCredential ||
missingCredential ||
missingSpreadsheet ||
!selectorResolution?.key
if (!selectorResolution?.key) {
return (

View File

@@ -6,6 +6,7 @@ import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types'
@@ -84,6 +85,11 @@ export function SlackSelectorInput({
? (effectiveBotToken as string) || ''
: (effectiveCredential as string) || ''
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || ''
)
useEffect(() => {
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (typeof val === 'string') {
@@ -93,7 +99,7 @@ export function SlackSelectorInput({
const requiresCredential = dependsOn.includes('credential')
const missingCredential = !credential || credential.trim().length === 0
const shouldForceDisable = requiresCredential && missingCredential
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
const context: SelectorContext = useMemo(
() => ({
@@ -130,7 +136,7 @@ export function SlackSelectorInput({
subBlock={subBlock}
selectorKey={config.selectorKey}
selectorContext={context}
disabled={finalDisabled || shouldForceDisable}
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || config.placeholder}

View File

@@ -1,8 +1,6 @@
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -13,8 +11,8 @@ import {
parseProvider,
} from '@/lib/oauth'
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 { CREDENTIAL } from '@/executor/constants'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -66,10 +64,6 @@ export function ToolCredentialSelector({
serviceId,
disabled = false,
}: ToolCredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
@@ -84,57 +78,50 @@ export function ToolCredentialSelector({
data: credentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
setInaccessibleCredentialName(null)
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, inaccessibleCredentialName])
}, [selectedCredential, isForeign])
const inputValue = isEditing ? editingInputValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const invalidSelection =
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
onChange('')
}, [invalidSelection, onChange])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -162,18 +149,8 @@ export function ToolCredentialSelector({
)
const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
setShowOAuthModal(true)
}, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({
@@ -181,13 +158,12 @@ export function ToolCredentialSelector({
value: cred.id,
}))
options.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
if (credentials.length === 0) {
options.push({
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
}
return options
}, [credentials, provider])
@@ -237,7 +213,7 @@ export function ToolCredentialSelector({
placeholder={effectiveLabel}
disabled={disabled}
editable={true}
filterOptions={true}
filterOptions={!isForeign}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
@@ -249,13 +225,15 @@ export function ToolCredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required
</div>
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
{!isForeign && (
<Button
variant='active'
onClick={() => setShowOAuthModal(true)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
>
Update access
</Button>
)}
</div>
)}
@@ -273,3 +251,31 @@ export function ToolCredentialSelector({
</div>
)
}
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) {
useEffect(() => {
const refresh = () => {
void refetchCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refresh()
}
}
const handlePageShow = (event: Event) => {
if ('persisted' in event && (event as PageTransitionEvent).persisted) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [refetchCredentials])
}

View File

@@ -24,7 +24,12 @@ import {
getMcpToolIssue as validateMcpTool,
} from '@/lib/mcp/tool-validation'
import type { McpToolSchema } from '@/lib/mcp/types'
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
type OAuthProvider,
type OAuthService,
} from '@/lib/oauth'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -1409,6 +1414,16 @@ export const ToolInput = memo(function ToolInput({
isToolAlreadySelected,
])
const toolRequiresOAuth = (toolId: string): boolean => {
const toolParams = getToolParametersConfig(toolId)
return toolParams?.toolConfig?.oauth?.required || false
}
const getToolOAuthConfig = (toolId: string) => {
const toolParams = getToolParametersConfig(toolId)
return toolParams?.toolConfig?.oauth
}
return (
<div className='w-full space-y-[8px]'>
<Combobox
@@ -1522,11 +1537,16 @@ export const ToolInput = memo(function ToolInput({
? subBlocksResult!.subBlocks
: []
const requiresOAuth =
!isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId)
const oauthConfig =
!isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
const hasParams = useSubBlocks
? displaySubBlocks.length > 0
: displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0
const hasToolBody = hasOperations || hasParams
const hasToolBody = hasOperations || (requiresOAuth && oauthConfig) || hasParams
const isExpandedForDisplay = hasToolBody
? isPreview
@@ -1726,6 +1746,39 @@ export const ToolInput = memo(function ToolInput({
{(() => {
const renderedElements: React.ReactNode[] = []
const showOAuth =
requiresOAuth && oauthConfig && tool.params?.authMethod !== 'bot_token'
const renderOAuthAccount = (): React.ReactNode => {
if (!showOAuth || !oauthConfig) return null
const credentialSubBlock = toolBlock?.subBlocks?.find(
(s) => s.type === 'oauth-input'
)
return (
<div key='oauth-account' className='relative min-w-0 space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
{credentialSubBlock?.title || 'Account'}{' '}
<span className='ml-0.5'>*</span>
</div>
<div className='w-full min-w-0'>
<ToolCredentialSelector
value={tool.params?.credential || ''}
onChange={(value: string) =>
handleParamChange(toolIndex, 'credential', value)
}
provider={oauthConfig.provider as OAuthProvider}
requiredScopes={
credentialSubBlock?.requiredScopes ||
getCanonicalScopesForProvider(oauthConfig.provider)
}
serviceId={oauthConfig.provider}
disabled={disabled}
/>
</div>
</div>
)
}
const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => {
const effectiveParamId = sb.id
const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
@@ -1795,8 +1848,44 @@ export const ToolInput = memo(function ToolInput({
})
)
for (const sb of displaySubBlocks) {
renderedElements.push(renderSubBlock(sb))
type RenderItem =
| { kind: 'subblock'; sb: BlockSubBlockConfig }
| { kind: 'oauth' }
const renderOrder: RenderItem[] = displaySubBlocks.map((sb) => ({
kind: 'subblock' as const,
sb,
}))
if (showOAuth) {
const credentialIdx = allBlockSubBlocks.findIndex(
(sb) => sb.type === 'oauth-input'
)
if (credentialIdx >= 0) {
const sbPositions = new Map(allBlockSubBlocks.map((sb, i) => [sb.id, i]))
const insertAt = renderOrder.findIndex(
(item) =>
item.kind === 'subblock' &&
(sbPositions.get(item.sb.id) ?? Number.POSITIVE_INFINITY) >
credentialIdx
)
if (insertAt === -1) {
renderOrder.push({ kind: 'oauth' })
} else {
renderOrder.splice(insertAt, 0, { kind: 'oauth' })
}
} else {
renderOrder.unshift({ kind: 'oauth' })
}
}
for (const item of renderOrder) {
if (item.kind === 'oauth') {
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
} else {
renderedElements.push(renderSubBlock(item.sb))
}
}
const uncoveredParams = displayParams.filter(
@@ -1835,6 +1924,11 @@ export const ToolInput = memo(function ToolInput({
)
}
{
const el = renderOAuthAccount()
if (el) renderedElements.push(el)
}
const filteredParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool)
)

View File

@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react'
export function useForeignCredential(
provider: string | undefined,
credentialId: string | undefined
) {
const [isForeign, setIsForeign] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
useEffect(() => {
let cancelled = false
async function check() {
setLoading(true)
setError(null)
try {
if (!normalizedProvider || !normalizedCredentialId) {
if (!cancelled) setIsForeign(false)
return
}
const res = await fetch(
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
)
if (!res.ok) {
if (!cancelled) setIsForeign(true)
return
}
const data = await res.json()
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
if (!cancelled) setIsForeign(!isOwn)
} catch (e) {
if (!cancelled) {
setIsForeign(true)
setError((e as Error).message)
}
} finally {
if (!cancelled) setLoading(false)
}
}
void check()
return () => {
cancelled = true
}
}, [normalizedProvider, normalizedCredentialId])
return { isForeignCredential: isForeign, loading, error }
}

View File

@@ -41,7 +41,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import { ROW_STYLES } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import {
collectExpandableNodeIds,
type EntryNode,
type ExecutionGroup,
flattenBlockEntriesOnly,
@@ -68,21 +67,6 @@ const MIN_HEIGHT = TERMINAL_HEIGHT.MIN
const DEFAULT_EXPANDED_HEIGHT = TERMINAL_HEIGHT.DEFAULT
const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH.MIN
/** Returns true if any node in the subtree has an error */
function hasErrorInTree(nodes: EntryNode[]): boolean {
return nodes.some((n) => Boolean(n.entry.error) || hasErrorInTree(n.children))
}
/** Returns true if any node in the subtree is currently running */
function hasRunningInTree(nodes: EntryNode[]): boolean {
return nodes.some((n) => Boolean(n.entry.isRunning) || hasRunningInTree(n.children))
}
/** Returns true if any node in the subtree was canceled */
function hasCanceledInTree(nodes: EntryNode[]): boolean {
return nodes.some((n) => Boolean(n.entry.isCanceled) || hasCanceledInTree(n.children))
}
/**
* Block row component for displaying actual block entries
*/
@@ -354,122 +338,6 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
)
})
/**
* Workflow node component - shows workflow block header with nested child blocks
*/
const WorkflowNodeRow = memo(function WorkflowNodeRow({
node,
selectedEntryId,
onSelectEntry,
expandedNodes,
onToggleNode,
}: {
node: EntryNode
selectedEntryId: string | null
onSelectEntry: (entry: ConsoleEntry) => void
expandedNodes: Set<string>
onToggleNode: (nodeId: string) => void
}) {
const { entry, children } = node
const BlockIcon = getBlockIcon(entry.blockType)
const bgColor = getBlockColor(entry.blockType)
const nodeId = entry.id
const isExpanded = expandedNodes.has(nodeId)
const hasChildren = children.length > 0
const isSelected = selectedEntryId === entry.id
const hasError = useMemo(
() => Boolean(entry.error) || hasErrorInTree(children),
[entry.error, children]
)
const hasRunningDescendant = useMemo(
() => Boolean(entry.isRunning) || hasRunningInTree(children),
[entry.isRunning, children]
)
const hasCanceledDescendant = useMemo(
() => (Boolean(entry.isCanceled) || hasCanceledInTree(children)) && !hasRunningDescendant,
[entry.isCanceled, children, hasRunningDescendant]
)
return (
<div className='flex min-w-0 flex-col'>
{/* Workflow Block Header */}
<div
className={clsx(
ROW_STYLES.base,
'h-[26px]',
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
)}
onClick={(e) => {
e.stopPropagation()
if (!isSelected) onSelectEntry(entry)
if (hasChildren) onToggleNode(nodeId)
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
</div>
<span
className={clsx(
'min-w-0 truncate font-medium text-[13px]',
hasError
? 'text-[var(--text-error)]'
: isSelected || isExpanded
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{entry.blockName}
</span>
{hasChildren && (
<ChevronDown
className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
!isExpanded && '-rotate-90'
)}
/>
)}
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!hasRunningDescendant &&
(hasCanceledDescendant
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-tertiary)]')
)}
>
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
/>
</span>
</div>
{/* Nested Child Blocks — rendered through EntryNodeRow for full loop/parallel support */}
{isExpanded && hasChildren && (
<div className={ROW_STYLES.nested}>
{children.map((child) => (
<EntryNodeRow
key={child.entry.id}
node={child}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
))}
</div>
)}
</div>
)
})
/**
* Entry node component - dispatches to appropriate component based on node type
*/
@@ -500,18 +368,6 @@ const EntryNodeRow = memo(function EntryNodeRow({
)
}
if (nodeType === 'workflow') {
return (
<WorkflowNodeRow
node={node}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
expandedNodes={expandedNodes}
onToggleNode={onToggleNode}
/>
)
}
if (nodeType === 'iteration') {
return (
<IterationNodeRow
@@ -803,15 +659,27 @@ export const Terminal = memo(function Terminal() {
])
/**
* Auto-expand subflows, iterations, and workflow nodes when new entries arrive.
* Recursively walks the full tree so nested nodes (e.g. a workflow block inside
* a loop iteration) are also expanded automatically.
* Auto-expand subflows and iterations when new entries arrive.
* This always runs regardless of autoSelectEnabled - new runs should always be visible.
*/
useEffect(() => {
if (executionGroups.length === 0) return
const nodeIdsToExpand = collectExpandableNodeIds(executionGroups[0].entryTree)
const newestExec = executionGroups[0]
// Collect all node IDs that should be expanded (subflows and their iterations)
const nodeIdsToExpand: string[] = []
for (const node of newestExec.entryTree) {
if (node.nodeType === 'subflow' && node.children.length > 0) {
nodeIdsToExpand.push(node.entry.id)
// Also expand all iteration children
for (const iterNode of node.children) {
if (iterNode.nodeType === 'iteration') {
nodeIdsToExpand.push(iterNode.entry.id)
}
}
}
}
if (nodeIdsToExpand.length > 0) {
setExpandedNodes((prev) => {

View File

@@ -1,14 +1,6 @@
import type React from 'react'
import {
AlertTriangleIcon,
BanIcon,
NetworkIcon,
RepeatIcon,
SplitIcon,
XCircleIcon,
} from 'lucide-react'
import { AlertTriangleIcon, BanIcon, RepeatIcon, SplitIcon, XCircleIcon } from 'lucide-react'
import { getBlock } from '@/blocks'
import { isWorkflowBlockType } from '@/executor/constants'
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import type { ConsoleEntry } from '@/stores/terminal'
@@ -20,8 +12,6 @@ const SUBFLOW_COLORS = {
parallel: '#FEE12B',
} as const
const WORKFLOW_COLOR = '#8b5cf6'
/**
* Special block type colors for errors and system messages
*/
@@ -51,10 +41,6 @@ export function getBlockIcon(
return SplitIcon
}
if (blockType === 'workflow') {
return NetworkIcon
}
if (blockType === 'error') {
return XCircleIcon
}
@@ -85,9 +71,6 @@ export function getBlockColor(blockType: string): string {
if (blockType === 'parallel') {
return SUBFLOW_COLORS.parallel
}
if (blockType === 'workflow') {
return WORKFLOW_COLOR
}
// Special block types for errors and system messages
if (blockType === 'error') {
return SPECIAL_BLOCK_COLORS.error
@@ -137,7 +120,7 @@ export function isSubflowBlockType(blockType: string): boolean {
/**
* Node type for the tree structure
*/
export type EntryNodeType = 'block' | 'subflow' | 'iteration' | 'workflow'
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
/**
* Entry node for tree structure - represents a block, subflow, or iteration
@@ -185,36 +168,6 @@ interface IterationGroup {
startTimeMs: number
}
/**
* Recursively collects all descendant entries owned by a workflow block.
* This includes direct children and the children of any nested workflow blocks,
* enabling correct tree construction for deeply-nested child workflows.
*/
function collectWorkflowDescendants(
instanceKey: string,
workflowChildGroups: Map<string, ConsoleEntry[]>,
visited: Set<string> = new Set()
): ConsoleEntry[] {
if (visited.has(instanceKey)) return []
visited.add(instanceKey)
const direct = workflowChildGroups.get(instanceKey) ?? []
const result = [...direct]
for (const entry of direct) {
if (isWorkflowBlockType(entry.blockType)) {
// Use childWorkflowInstanceId when available (unique per-invocation) to correctly
// separate children across loop iterations of the same workflow block.
result.push(
...collectWorkflowDescendants(
entry.childWorkflowInstanceId ?? entry.blockId,
workflowChildGroups,
visited
)
)
}
}
return result
}
/**
* Builds a tree structure from flat entries.
* Groups iteration entries by (iterationType, iterationContainerId, iterationCurrent), showing all blocks
@@ -222,37 +175,18 @@ function collectWorkflowDescendants(
* Sorts by start time to ensure chronological order.
*/
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
// Separate entries into three buckets:
// 1. Iteration entries (loop/parallel children)
// 2. Workflow child entries (blocks inside a child workflow)
// 3. Regular blocks
// Separate regular blocks from iteration entries
const regularBlocks: ConsoleEntry[] = []
const iterationEntries: ConsoleEntry[] = []
const workflowChildEntries: ConsoleEntry[] = []
for (const entry of entries) {
if (entry.childWorkflowBlockId) {
// Child workflow entries take priority over iteration classification
workflowChildEntries.push(entry)
} else if (entry.iterationType && entry.iterationCurrent !== undefined) {
if (entry.iterationType && entry.iterationCurrent !== undefined) {
iterationEntries.push(entry)
} else {
regularBlocks.push(entry)
}
}
// Group workflow child entries by the parent workflow block ID
const workflowChildGroups = new Map<string, ConsoleEntry[]>()
for (const entry of workflowChildEntries) {
const parentId = entry.childWorkflowBlockId!
const group = workflowChildGroups.get(parentId)
if (group) {
group.push(entry)
} else {
workflowChildGroups.set(parentId, [entry])
}
}
// Group iteration entries by (iterationType, iterationContainerId, iterationCurrent)
const iterationGroupsMap = new Map<string, IterationGroup>()
for (const entry of iterationEntries) {
@@ -327,9 +261,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
)
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Parallel branches run concurrently — use wall-clock time. Loop iterations run serially — use sum.
const subflowDuration =
iterationType === 'parallel' ? subflowEndMs - subflowStartMs : totalDuration
// Create synthetic subflow parent entry
// Use the minimum executionOrder from all child blocks for proper ordering
@@ -345,7 +276,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
startedAt: new Date(subflowStartMs).toISOString(),
executionOrder: subflowExecutionOrder,
endedAt: new Date(subflowEndMs).toISOString(),
durationMs: subflowDuration,
durationMs: totalDuration,
success: !allBlocks.some((b) => b.error),
}
@@ -360,9 +291,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
)
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Parallel branches run concurrently — use wall-clock time. Loop iterations run serially — use sum.
const iterDisplayDuration =
iterationType === 'parallel' ? iterEndMs - iterStartMs : iterDuration
// Use the minimum executionOrder from blocks in this iteration
const iterExecutionOrder = Math.min(...iterBlocks.map((b) => b.executionOrder))
@@ -377,7 +305,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
startedAt: new Date(iterStartMs).toISOString(),
executionOrder: iterExecutionOrder,
endedAt: new Date(iterEndMs).toISOString(),
durationMs: iterDisplayDuration,
durationMs: iterDuration,
success: !iterBlocks.some((b) => b.error),
iterationCurrent: iterGroup.iterationCurrent,
iterationTotal: iterGroup.iterationTotal,
@@ -385,24 +313,12 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
iterationContainerId: iterGroup.iterationContainerId,
}
// Block nodes within this iteration — workflow blocks get their full subtree
const blockNodes: EntryNode[] = iterBlocks.map((block) => {
if (isWorkflowBlockType(block.blockType)) {
const instanceKey = block.childWorkflowInstanceId ?? block.blockId
const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups)
const rawChildren = allDescendants.map((c) => ({
...c,
childWorkflowBlockId:
c.childWorkflowBlockId === instanceKey ? undefined : c.childWorkflowBlockId,
}))
return {
entry: block,
children: buildEntryTree(rawChildren),
nodeType: 'workflow' as const,
}
}
return { entry: block, children: [], nodeType: 'block' as const }
})
// Block nodes within this iteration
const blockNodes: EntryNode[] = iterBlocks.map((block) => ({
entry: block,
children: [],
nodeType: 'block' as const,
}))
return {
entry: syntheticIteration,
@@ -422,61 +338,19 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
})
}
// Build workflow nodes for regular blocks that are workflow block types
const workflowNodes: EntryNode[] = []
const remainingRegularBlocks: ConsoleEntry[] = []
for (const block of regularBlocks) {
if (isWorkflowBlockType(block.blockType)) {
const instanceKey = block.childWorkflowInstanceId ?? block.blockId
const allDescendants = collectWorkflowDescendants(instanceKey, workflowChildGroups)
const rawChildren = allDescendants.map((c) => ({
...c,
childWorkflowBlockId:
c.childWorkflowBlockId === instanceKey ? undefined : c.childWorkflowBlockId,
}))
const children = buildEntryTree(rawChildren)
workflowNodes.push({ entry: block, children, nodeType: 'workflow' as const })
} else {
remainingRegularBlocks.push(block)
}
}
// Build nodes for remaining regular blocks
const regularNodes: EntryNode[] = remainingRegularBlocks.map((entry) => ({
// Build nodes for regular blocks
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
entry,
children: [],
nodeType: 'block' as const,
}))
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
const allNodes = [...subflowNodes, ...workflowNodes, ...regularNodes]
const allNodes = [...subflowNodes, ...regularNodes]
allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder)
return allNodes
}
/**
* Recursively collects IDs of all nodes that should be auto-expanded.
* Includes subflow, iteration, and workflow nodes that have children.
*/
export function collectExpandableNodeIds(nodes: EntryNode[]): string[] {
const ids: string[] = []
for (const node of nodes) {
if (
(node.nodeType === 'subflow' ||
node.nodeType === 'iteration' ||
node.nodeType === 'workflow') &&
node.children.length > 0
) {
ids.push(node.entry.id)
}
if (node.children.length > 0) {
ids.push(...collectExpandableNodeIds(node.children))
}
}
return ids
}
/**
* Groups console entries by execution ID and builds a tree structure.
* Pre-computes timestamps for efficient sorting.
@@ -584,7 +458,7 @@ export function flattenBlockEntriesOnly(
): NavigableBlockEntry[] {
const result: NavigableBlockEntry[] = []
for (const node of nodes) {
if (node.nodeType === 'block' || node.nodeType === 'workflow') {
if (node.nodeType === 'block') {
result.push({
entry: node.entry,
executionId,

View File

@@ -20,7 +20,6 @@ import {
TriggerUtils,
} from '@/lib/workflows/triggers/triggers'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
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 {
@@ -64,7 +63,6 @@ interface BlockEventHandlerConfig {
executionIdRef: { current: string }
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
activeBlocksSet: Set<string>
activeBlockRefCounts: Map<string, number>
accumulatedBlockLogs: BlockLog[]
accumulatedBlockStates: Map<string, BlockState>
executedBlockIds: Set<string>
@@ -311,7 +309,6 @@ export function useWorkflowExecution() {
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs,
accumulatedBlockStates,
executedBlockIds,
@@ -330,7 +327,11 @@ export function useWorkflowExecution() {
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
if (!workflowId) return
updateActiveBlockRefCount(activeBlockRefCounts, activeBlocksSet, blockId, isActive)
if (isActive) {
activeBlocksSet.add(blockId)
} else {
activeBlocksSet.delete(blockId)
}
setActiveBlocks(workflowId, new Set(activeBlocksSet))
}
@@ -383,9 +384,6 @@ export function useWorkflowExecution() {
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
childWorkflowInstanceId: data.childWorkflowInstanceId,
})
}
@@ -409,9 +407,6 @@ export function useWorkflowExecution() {
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
childWorkflowInstanceId: data.childWorkflowInstanceId,
})
}
@@ -431,9 +426,6 @@ export function useWorkflowExecution() {
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
childWorkflowInstanceId: data.childWorkflowInstanceId,
},
executionIdRef.current
)
@@ -456,9 +448,6 @@ export function useWorkflowExecution() {
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
childWorkflowInstanceId: data.childWorkflowInstanceId,
},
executionIdRef.current
)
@@ -490,8 +479,6 @@ export function useWorkflowExecution() {
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
})
}
@@ -549,27 +536,7 @@ export function useWorkflowExecution() {
}
}
const onBlockChildWorkflowStarted = (data: {
blockId: string
childWorkflowInstanceId: string
iterationCurrent?: number
iterationContainerId?: string
}) => {
if (isStaleExecution()) return
updateConsole(
data.blockId,
{
childWorkflowInstanceId: data.childWorkflowInstanceId,
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
...(data.iterationContainerId !== undefined && {
iterationContainerId: data.iterationContainerId,
}),
},
executionIdRef.current
)
}
return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted }
return { onBlockStarted, onBlockCompleted, onBlockError }
},
[addConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, updateConsole]
)
@@ -1313,7 +1280,6 @@ export function useWorkflowExecution() {
}
const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const streamedContent = new Map<string, string>()
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
@@ -1326,7 +1292,6 @@ export function useWorkflowExecution() {
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs,
accumulatedBlockStates,
executedBlockIds,
@@ -1369,7 +1334,6 @@ export function useWorkflowExecution() {
onBlockStarted: blockHandlers.onBlockStarted,
onBlockCompleted: blockHandlers.onBlockCompleted,
onBlockError: blockHandlers.onBlockError,
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
onStreamChunk: (data) => {
const existing = streamedContent.get(data.blockId) || ''
@@ -1938,7 +1902,6 @@ export function useWorkflowExecution() {
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
try {
const blockHandlers = buildBlockEventHandlers({
@@ -1946,7 +1909,6 @@ export function useWorkflowExecution() {
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs,
accumulatedBlockStates,
executedBlockIds,
@@ -1967,7 +1929,6 @@ export function useWorkflowExecution() {
onBlockStarted: blockHandlers.onBlockStarted,
onBlockCompleted: blockHandlers.onBlockCompleted,
onBlockError: blockHandlers.onBlockError,
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
onExecutionCompleted: (data) => {
if (data.success) {
@@ -2143,7 +2104,6 @@ export function useWorkflowExecution() {
const workflowEdges = useWorkflowStore.getState().edges
const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
@@ -2155,7 +2115,6 @@ export function useWorkflowExecution() {
executionIdRef,
workflowEdges,
activeBlocksSet,
activeBlockRefCounts,
accumulatedBlockLogs,
accumulatedBlockStates,
executedBlockIds,
@@ -2196,10 +2155,6 @@ export function useWorkflowExecution() {
clearOnce()
handlers.onBlockError(data)
},
onBlockChildWorkflowStarted: (data) => {
clearOnce()
handlers.onBlockChildWorkflowStarted(data)
},
onExecutionCompleted: () => {
const currentId = useExecutionStore
.getState()

View File

@@ -5,30 +5,6 @@ import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Updates the active blocks set and ref counts for a single block.
* Ref counting ensures a block stays active until all parallel branches for it complete.
*/
export function updateActiveBlockRefCount(
refCounts: Map<string, number>,
activeSet: Set<string>,
blockId: string,
isActive: boolean
): void {
if (isActive) {
refCounts.set(blockId, (refCounts.get(blockId) ?? 0) + 1)
activeSet.add(blockId)
} else {
const next = (refCounts.get(blockId) ?? 1) - 1
if (next <= 0) {
refCounts.delete(blockId)
activeSet.delete(blockId)
} else {
refCounts.set(blockId, next)
}
}
}
export interface WorkflowExecutionOptions {
workflowInput?: any
onStream?: (se: StreamingExecution) => Promise<void>
@@ -63,7 +39,6 @@ export async function executeWorkflowWithFullLogging(
const workflowEdges = useWorkflowStore.getState().edges
const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const payload: any = {
input: options.workflowInput,
@@ -128,12 +103,7 @@ export async function executeWorkflowWithFullLogging(
switch (event.type) {
case 'block:started': {
updateActiveBlockRefCount(
activeBlockRefCounts,
activeBlocksSet,
event.data.blockId,
true
)
activeBlocksSet.add(event.data.blockId)
setActiveBlocks(wfId, new Set(activeBlocksSet))
const incomingEdges = workflowEdges.filter(
@@ -145,13 +115,8 @@ export async function executeWorkflowWithFullLogging(
break
}
case 'block:completed': {
updateActiveBlockRefCount(
activeBlockRefCounts,
activeBlocksSet,
event.data.blockId,
false
)
case 'block:completed':
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'success')
@@ -173,24 +138,15 @@ export async function executeWorkflowWithFullLogging(
iterationTotal: event.data.iterationTotal,
iterationType: event.data.iterationType,
iterationContainerId: event.data.iterationContainerId,
childWorkflowBlockId: event.data.childWorkflowBlockId,
childWorkflowName: event.data.childWorkflowName,
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
})
if (options.onBlockComplete) {
options.onBlockComplete(event.data.blockId, event.data.output).catch(() => {})
}
break
}
case 'block:error': {
updateActiveBlockRefCount(
activeBlockRefCounts,
activeBlocksSet,
event.data.blockId,
false
)
case 'block:error':
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'error')
@@ -213,30 +169,8 @@ export async function executeWorkflowWithFullLogging(
iterationTotal: event.data.iterationTotal,
iterationType: event.data.iterationType,
iterationContainerId: event.data.iterationContainerId,
childWorkflowBlockId: event.data.childWorkflowBlockId,
childWorkflowName: event.data.childWorkflowName,
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
})
break
}
case 'block:childWorkflowStarted': {
const { updateConsole } = useTerminalConsoleStore.getState()
updateConsole(
event.data.blockId,
{
childWorkflowInstanceId: event.data.childWorkflowInstanceId,
...(event.data.iterationCurrent !== undefined && {
iterationCurrent: event.data.iterationCurrent,
}),
...(event.data.iterationContainerId !== undefined && {
iterationContainerId: event.data.iterationContainerId,
}),
},
executionId
)
break
}
case 'execution:completed':
executionResult = {

View File

@@ -255,69 +255,6 @@ const WorkflowContent = React.memo(() => {
const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
const pending = window.sessionStorage.getItem(OAUTH_CONNECT_PENDING_KEY)
if (!pending) return
window.sessionStorage.removeItem(OAUTH_CONNECT_PENDING_KEY)
;(async () => {
try {
const {
displayName,
providerId,
preCount,
workspaceId: wsId,
reconnect,
} = JSON.parse(pending) as {
displayName: string
providerId: string
preCount: number
workspaceId: string
reconnect?: boolean
}
if (reconnect) {
addNotification({
level: 'info',
message: `"${displayName}" reconnected successfully.`,
})
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId, workspaceId: wsId },
})
)
return
}
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(wsId)}&type=oauth`
)
const data = response.ok ? await response.json() : { credentials: [] }
const oauthCredentials = (data.credentials ?? []) as Array<{
displayName: string
providerId: string | null
}>
if (oauthCredentials.length > preCount) {
addNotification({
level: 'info',
message: `"${displayName}" credential connected successfully.`,
})
} else {
const existing = oauthCredentials.find((c) => c.providerId === providerId)
const existingName = existing?.displayName || displayName
addNotification({
level: 'info',
message: `This account is already connected as "${existingName}".`,
})
}
} catch {
// Ignore malformed sessionStorage data
}
})()
}, [])
const {
workflows,
activeWorkflowId,

View File

@@ -473,7 +473,7 @@ function ConnectionsSection({
</div>
)}
{/* Secrets */}
{/* Environment Variables */}
{envVars.length > 0 && (
<div className='mb-[2px] last:mb-0'>
<div
@@ -489,7 +489,7 @@ function ConnectionsSection({
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)}
>
Secrets
Environment Variables
</span>
<ChevronDownIcon
className={cn(

View File

@@ -31,9 +31,10 @@ const logger = createLogger('ApiKeys')
interface ApiKeysProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
export function ApiKeys({ onOpenChange }: ApiKeysProps) {
export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const { data: session } = useSession()
const userId = session?.user?.id
const params = useParams()
@@ -117,6 +118,12 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
onOpenChange?.(open)
}
useEffect(() => {
if (registerCloseHandler) {
registerCloseHandler(handleModalClose)
}
}, [registerCloseHandler])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({

View File

@@ -1,15 +0,0 @@
'use client'
import { CredentialsManager } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager'
interface CredentialsProps {
onOpenChange?: (open: boolean) => void
}
export function Credentials(_props: CredentialsProps) {
return (
<div className='h-full min-h-0'>
<CredentialsManager />
</div>
)
}

View File

@@ -0,0 +1,864 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search, Share2, Undo2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Input as EmcnInput,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Input, Skeleton } from '@/components/ui'
import { isValidEnvVarName } from '@/executor/constants'
import {
usePersonalEnvironment,
useRemoveWorkspaceEnvironment,
useSavePersonalEnvironment,
useUpsertWorkspaceEnvironment,
useWorkspaceEnvironment,
type WorkspaceEnvironmentData,
} from '@/hooks/queries/environment'
const logger = createLogger('EnvironmentVariables')
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center'
const generateRowId = (() => {
let counter = 0
return () => {
counter += 1
return Date.now() + counter
}
})()
const createEmptyEnvVar = (): UIEnvironmentVariable => ({
key: '',
value: '',
id: generateRowId(),
})
interface UIEnvironmentVariable {
key: string
value: string
id?: number
}
/**
* Validates an environment variable key.
* Returns an error message if invalid, undefined if valid.
*/
function validateEnvVarKey(key: string): string | undefined {
if (!key) return undefined
if (key.includes(' ')) return 'Spaces are not allowed'
if (!isValidEnvVarName(key)) return 'Only letters, numbers, and underscores allowed'
return undefined
}
interface EnvironmentVariablesProps {
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
}
interface WorkspaceVariableRowProps {
envKey: string
value: string
renamingKey: string | null
pendingKeyValue: string
isNewlyPromoted: boolean
onRenameStart: (key: string) => void
onPendingKeyChange: (value: string) => void
onRenameEnd: (key: string, value: string) => void
onDelete: (key: string) => void
onDemote: (key: string, value: string) => void
}
function WorkspaceVariableRow({
envKey,
value,
renamingKey,
pendingKeyValue,
isNewlyPromoted,
onRenameStart,
onPendingKeyChange,
onRenameEnd,
onDelete,
onDemote,
}: WorkspaceVariableRowProps) {
return (
<div className={GRID_COLS}>
<EmcnInput
value={renamingKey === envKey ? pendingKeyValue : envKey}
onChange={(e) => {
if (renamingKey !== envKey) onRenameStart(envKey)
onPendingKeyChange(e.target.value)
}}
onBlur={() => onRenameEnd(envKey, value)}
name={`workspace_env_key_${envKey}_${Math.random()}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className='h-9'
/>
<div />
<EmcnInput
value={value ? '•'.repeat(value.length) : ''}
readOnly
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
className='h-9'
/>
<div className='ml-[8px] flex'>
{isNewlyPromoted && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => onDemote(envKey, value)} className='h-9 w-9'>
<Undo2 className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Change to personal scope</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' onClick={() => onDelete(envKey)} className='h-9 w-9'>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)
}
export function EnvironmentVariables({ registerBeforeLeaveHandler }: EnvironmentVariablesProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data: personalEnvData, isLoading: isPersonalLoading } = usePersonalEnvironment()
const { data: workspaceEnvData, isLoading: isWorkspaceLoading } = useWorkspaceEnvironment(
workspaceId,
{
select: useCallback(
(data: WorkspaceEnvironmentData): WorkspaceEnvironmentData => ({
workspace: data.workspace || {},
personal: data.personal || {},
conflicts: data.conflicts || [],
}),
[]
),
}
)
const savePersonalMutation = useSavePersonalEnvironment()
const upsertWorkspaceMutation = useUpsertWorkspaceEnvironment()
const removeWorkspaceMutation = useRemoveWorkspaceEnvironment()
const isLoading = isPersonalLoading || isWorkspaceLoading
const variables = useMemo(() => personalEnvData || {}, [personalEnvData])
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const [workspaceVars, setWorkspaceVars] = useState<Record<string, string>>({})
const [conflicts, setConflicts] = useState<string[]>([])
const [renamingKey, setRenamingKey] = useState<string | null>(null)
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
const [changeToken, setChangeToken] = useState(0)
const initialWorkspaceVarsRef = useRef<Record<string, string>>({})
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingProceedCallback = useRef<(() => void) | null>(null)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
const hasChangesRef = useRef(false)
const hasSavedRef = useRef(false)
const filteredEnvVars = useMemo(() => {
const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index }))
if (!searchTerm.trim()) return mapped
const term = searchTerm.toLowerCase()
return mapped.filter(({ envVar }) => envVar.key.toLowerCase().includes(term))
}, [envVars, searchTerm])
const filteredWorkspaceEntries = useMemo(() => {
const entries = Object.entries(workspaceVars)
if (!searchTerm.trim()) return entries
const term = searchTerm.toLowerCase()
return entries.filter(([key]) => key.toLowerCase().includes(term))
}, [workspaceVars, searchTerm])
const hasChanges = useMemo(() => {
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
const currentVars = envVars.filter((v) => v.key || v.value)
const initialMap = new Map(initialVars.map((v) => [v.key, v.value]))
const currentMap = new Map(currentVars.map((v) => [v.key, v.value]))
if (initialMap.size !== currentMap.size) return true
for (const [key, value] of currentMap) {
if (initialMap.get(key) !== value) return true
}
for (const key of initialMap.keys()) {
if (!currentMap.has(key)) return true
}
const before = initialWorkspaceVarsRef.current
const after = workspaceVars
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
if (Object.keys(before).length !== Object.keys(after).length) return true
for (const key of allKeys) {
if (before[key] !== after[key]) return true
}
return false
}, [envVars, workspaceVars, changeToken])
const hasConflicts = useMemo(() => {
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
}, [envVars, workspaceVars])
const hasInvalidKeys = useMemo(() => {
return envVars.some((envVar) => !!envVar.key && validateEnvVarKey(envVar.key))
}, [envVars])
useEffect(() => {
hasChangesRef.current = hasChanges
}, [hasChanges])
const handleBeforeLeave = useCallback((onProceed: () => void) => {
if (hasChangesRef.current) {
setShowUnsavedChanges(true)
pendingProceedCallback.current = onProceed
} else {
onProceed()
}
}, [])
useEffect(() => {
if (hasSavedRef.current) return
const existingVars = Object.values(variables)
const initialVars = existingVars.length
? existingVars.map((envVar) => ({
...envVar,
id: generateRowId(),
}))
: [createEmptyEnvVar()]
initialVarsRef.current = JSON.parse(JSON.stringify(initialVars))
setEnvVars(JSON.parse(JSON.stringify(initialVars)))
pendingProceedCallback.current = null
}, [variables])
useEffect(() => {
if (workspaceEnvData) {
if (hasSavedRef.current) {
setConflicts(workspaceEnvData?.conflicts || [])
hasSavedRef.current = false
} else {
setWorkspaceVars(workspaceEnvData?.workspace || {})
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
setConflicts(workspaceEnvData?.conflicts || [])
}
}
}, [workspaceEnvData])
useEffect(() => {
if (registerBeforeLeaveHandler) {
registerBeforeLeaveHandler(handleBeforeLeave)
}
}, [registerBeforeLeaveHandler, handleBeforeLeave])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
setShouldScrollToBottom(false)
}
}, [shouldScrollToBottom])
useEffect(() => {
const personalKeys = envVars.map((envVar) => envVar.key.trim()).filter((key) => key.length > 0)
const uniquePersonalKeys = Array.from(new Set(personalKeys))
const computedConflicts = uniquePersonalKeys.filter((key) => Object.hasOwn(workspaceVars, key))
setConflicts((prev) => {
if (prev.length === computedConflicts.length) {
const sameKeys = prev.every((key) => computedConflicts.includes(key))
if (sameKeys) return prev
}
return computedConflicts
})
}, [envVars, workspaceVars])
const handleWorkspaceKeyRename = useCallback(
(currentKey: string, currentValue: string) => {
const newKey = pendingKeyValue.trim()
if (!renamingKey || renamingKey !== currentKey) return
setRenamingKey(null)
if (!newKey || newKey === currentKey) return
setWorkspaceVars((prev) => {
const next = { ...prev }
delete next[currentKey]
next[newKey] = currentValue
return next
})
},
[pendingKeyValue, renamingKey]
)
const handleDeleteWorkspaceVar = useCallback((key: string) => {
setWorkspaceVars((prev) => {
const next = { ...prev }
delete next[key]
return next
})
}, [])
const addEnvVar = useCallback(() => {
setEnvVars((prev) => [...prev, createEmptyEnvVar()])
setSearchTerm('')
setShouldScrollToBottom(true)
}, [])
const updateEnvVar = useCallback((index: number, field: 'key' | 'value', value: string) => {
setEnvVars((prev) => {
const newEnvVars = [...prev]
newEnvVars[index][field] = value
return newEnvVars
})
}, [])
const removeEnvVar = useCallback((index: number) => {
setEnvVars((prev) => {
const newEnvVars = prev.filter((_, i) => i !== index)
return newEnvVars.length ? newEnvVars : [createEmptyEnvVar()]
})
}, [])
const handleValueFocus = useCallback((index: number, e: React.FocusEvent<HTMLInputElement>) => {
setFocusedValueIndex(index)
e.target.scrollLeft = 0
}, [])
const handleValueClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
e.currentTarget.scrollLeft = 0
}, [])
const parseEnvVarLine = useCallback((line: string): UIEnvironmentVariable | null => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return null
const withoutExport = trimmed.replace(/^export\s+/, '')
const equalIndex = withoutExport.indexOf('=')
if (equalIndex === -1 || equalIndex === 0) return null
const potentialKey = withoutExport.substring(0, equalIndex).trim()
if (!isValidEnvVarName(potentialKey)) return null
let value = withoutExport.substring(equalIndex + 1)
const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_')
const valueIsJustPadding = /^=+$/.test(value.trim())
if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) {
return null
}
const trimmedValue = value.trim()
if (
!trimmedValue.startsWith('"') &&
!trimmedValue.startsWith("'") &&
!trimmedValue.startsWith('`')
) {
const commentIndex = value.search(/\s#/)
if (commentIndex !== -1) {
value = value.substring(0, commentIndex)
}
}
value = value.trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('`') && value.endsWith('`'))
) {
value = value.slice(1, -1)
}
return { key: potentialKey, value, id: generateRowId() }
}, [])
const handleSingleValuePaste = useCallback(
(text: string, index: number, inputType: 'key' | 'value') => {
setEnvVars((prev) => {
const newEnvVars = [...prev]
newEnvVars[index][inputType] = text
return newEnvVars
})
},
[]
)
const handleKeyValuePaste = useCallback(
(lines: string[]) => {
const parsedVars = lines
.map(parseEnvVarLine)
.filter((parsed): parsed is UIEnvironmentVariable => parsed !== null)
.filter(({ key, value }) => key && value)
if (parsedVars.length > 0) {
setEnvVars((prev) => {
const existingVars = prev.filter((v) => v.key || v.value)
return [...existingVars, ...parsedVars]
})
setShouldScrollToBottom(true)
}
},
[parseEnvVarLine]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
const text = e.clipboardData.getData('text').trim()
if (!text) return
const lines = text.split('\n').filter((line) => line.trim())
if (lines.length === 0) return
e.preventDefault()
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
| 'key'
| 'value'
if (inputType) {
const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null)
if (!hasValidEnvVarPattern) {
handleSingleValuePaste(text, index, inputType)
return
}
}
handleKeyValuePaste(lines)
},
[parseEnvVarLine, handleSingleValuePaste, handleKeyValuePaste]
)
const handleCancel = useCallback(() => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setWorkspaceVars({ ...initialWorkspaceVarsRef.current })
setShowUnsavedChanges(false)
pendingProceedCallback.current?.()
pendingProceedCallback.current = null
}, [])
const handleSave = useCallback(async () => {
const onProceed = pendingProceedCallback.current
const prevInitialVars = [...initialVarsRef.current]
const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current }
try {
setShowUnsavedChanges(false)
hasSavedRef.current = true
initialWorkspaceVarsRef.current = { ...workspaceVars }
initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value)))
setChangeToken((prev) => prev + 1)
const validVariables = envVars
.filter((v) => v.key && v.value)
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
await savePersonalMutation.mutateAsync({ variables: validVariables })
const before = prevInitialWorkspaceVars
const after = workspaceVars
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
for (const [k, v] of Object.entries(after)) {
if (!(k in before) || before[k] !== v) {
toUpsert[k] = v
}
}
for (const k of Object.keys(before)) {
if (!(k in after)) toDelete.push(k)
}
if (workspaceId) {
if (Object.keys(toUpsert).length) {
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
}
if (toDelete.length) {
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
}
}
onProceed?.()
pendingProceedCallback.current = null
} catch (error) {
hasSavedRef.current = false
initialVarsRef.current = prevInitialVars
initialWorkspaceVarsRef.current = prevInitialWorkspaceVars
logger.error('Failed to save environment variables:', error)
}
}, [
envVars,
workspaceVars,
workspaceId,
savePersonalMutation,
upsertWorkspaceMutation,
removeWorkspaceMutation,
])
const promoteToWorkspace = useCallback(
(envVar: UIEnvironmentVariable) => {
if (!envVar.key || !envVar.value || !workspaceId) return
setWorkspaceVars((prev) => ({ ...prev, [envVar.key]: envVar.value }))
setEnvVars((prev) => {
const filtered = prev.filter((entry) => entry !== envVar)
return filtered.length ? filtered : [createEmptyEnvVar()]
})
},
[workspaceId]
)
const demoteToPersonal = useCallback((key: string, value: string) => {
if (!key) return
setWorkspaceVars((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setEnvVars((prev) => [...prev, { key, value, id: generateRowId() }])
}, [])
const conflictClassName = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]'
const renderEnvVarRow = useCallback(
(envVar: UIEnvironmentVariable, originalIndex: number) => {
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
const keyError = validateEnvVarKey(envVar.key)
const maskedValueStyle =
focusedValueIndex !== originalIndex && !isConflict
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
: undefined
return (
<>
<div className={GRID_COLS}>
<EmcnInput
data-input-type='key'
value={envVar.key}
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder='API_KEY'
name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className={`h-9 ${isConflict ? conflictClassName : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`}
/>
<div />
<EmcnInput
data-input-type='value'
value={envVar.value}
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
type='text'
onFocus={(e) => {
if (!isConflict) {
e.target.removeAttribute('readOnly')
handleValueFocus(originalIndex, e)
}
}}
onClick={handleValueClick}
onBlur={() => setFocusedValueIndex(null)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder={isConflict ? 'Workspace override active' : 'Enter value'}
disabled={isConflict}
aria-disabled={isConflict}
name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly={isConflict}
style={maskedValueStyle}
className={`h-9 ${isConflict ? `cursor-not-allowed ${conflictClassName}` : ''}`}
/>
<div className='ml-[8px] flex items-center'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
disabled={!envVar.key || !envVar.value || isConflict || !workspaceId}
onClick={() => promoteToWorkspace(envVar)}
className='h-9 w-9'
>
<Share2 className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Change to workspace scope</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeEnvVar(originalIndex)}
className='h-9 w-9'
>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{keyError && (
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
{keyError}
</div>
)}
{isConflict && !keyError && (
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
Workspace variable with the same name overrides this. Rename your personal key to use
it.
</div>
)}
</>
)
},
[
workspaceVars,
workspaceId,
focusedValueIndex,
updateEnvVar,
handlePaste,
handleValueFocus,
handleValueClick,
promoteToWorkspace,
removeEnvVar,
]
)
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='hidden'>
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
tabIndex={-1}
readOnly
/>
</div>
<div className='flex items-center gap-[8px]'>
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search variables...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
name='env_search_field'
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<Button onClick={addEnvVar} variant='tertiary' disabled={isLoading}>
<Plus className='mr-[6px] h-[13px] w-[13px]' />
Add
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleSave}
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
variant='tertiary'
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
>
Save
</Button>
</Tooltip.Trigger>
{hasConflicts && <Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>}
{hasInvalidKeys && !hasConflicts && (
<Tooltip.Content>Fix invalid variable names before saving</Tooltip.Content>
)}
</Tooltip.Root>
</div>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{isLoading ? (
<>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-5 w-[70px]' />
<div className='text-[13px] text-[var(--text-muted)]'>
<Skeleton className='h-5 w-[160px]' />
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Skeleton className='h-5 w-[55px]' />
{Array.from({ length: 2 }, (_, i) => (
<div key={`personal-${i}`} className={GRID_COLS}>
<Skeleton className='h-9 rounded-[6px]' />
<div />
<Skeleton className='h-9 rounded-[6px]' />
<div className='ml-[8px] flex items-center gap-0'>
<Skeleton className='h-9 w-9 rounded-[6px]' />
<Skeleton className='h-9 w-9 rounded-[6px]' />
</div>
</div>
))}
</div>
</>
) : (
<>
{(!searchTerm.trim() || filteredWorkspaceEntries.length > 0) && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Workspace
</div>
{!searchTerm.trim() && Object.keys(workspaceVars).length === 0 ? (
<div className='text-[13px] text-[var(--text-muted)]'>
No workspace variables yet
</div>
) : (
(searchTerm.trim()
? filteredWorkspaceEntries
: Object.entries(workspaceVars)
).map(([key, value]) => (
<WorkspaceVariableRow
key={key}
envKey={key}
value={value}
renamingKey={renamingKey}
pendingKeyValue={pendingKeyValue}
isNewlyPromoted={!Object.hasOwn(initialWorkspaceVarsRef.current, key)}
onRenameStart={setRenamingKey}
onPendingKeyChange={setPendingKeyValue}
onRenameEnd={handleWorkspaceKeyRename}
onDelete={handleDeleteWorkspaceVar}
onDemote={demoteToPersonal}
/>
))
)}
</div>
)}
{(!searchTerm.trim() || filteredEnvVars.length > 0) && (
<div className='flex flex-col gap-[8px]'>
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
Personal
</div>
{filteredEnvVars.map(({ envVar, originalIndex }) => (
<div key={envVar.id || originalIndex}>
{renderEnvVarRow(envVar, originalIndex)}
</div>
))}
</div>
)}
{searchTerm.trim() &&
filteredEnvVars.length === 0 &&
filteredWorkspaceEntries.length === 0 &&
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No environment variables found matching "{searchTerm}"
</div>
)}
</>
)}
</div>
</div>
</div>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
{hasConflicts || hasInvalidKeys
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
: 'You have unsaved changes. Do you want to save them before closing?'}
</p>
</ModalBody>
<ModalFooter>
<Button variant='destructive' onClick={handleCancel}>
Discard Changes
</Button>
{hasConflicts || hasInvalidKeys ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
disabled={true}
variant='tertiary'
className='cursor-not-allowed opacity-50'
>
Save Changes
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{hasConflicts
? 'Resolve all conflicts before saving'
: 'Fix invalid variable names before saving'}
</Tooltip.Content>
</Tooltip.Root>
) : (
<Button onClick={handleSave} variant='tertiary'>
Save Changes
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -2,11 +2,12 @@ export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp'
export { Skills } from './skills/skills'
export { Subscription } from './subscription/subscription'

View File

@@ -0,0 +1,415 @@
'use client'
import { createElement, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { OAUTH_PROVIDERS } from '@/lib/oauth'
import {
type ServiceInfo,
useConnectOAuthService,
useDisconnectOAuthService,
useOAuthConnections,
} from '@/hooks/queries/oauth-connections'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Integrations')
/**
* Static skeleton structure matching OAUTH_PROVIDERS layout
* Each entry: [providerName, serviceCount]
*/
const SKELETON_STRUCTURE: [string, number][] = [
['Google', 7],
['Microsoft', 6],
['GitHub', 1],
['X', 1],
['Confluence', 1],
['Jira', 1],
['Airtable', 1],
['Notion', 1],
['Linear', 1],
['Slack', 1],
['Reddit', 1],
['Wealthbox', 1],
['Webflow', 1],
['Trello', 1],
['Asana', 1],
['Pipedrive', 1],
['HubSpot', 1],
['Salesforce', 1],
]
function IntegrationsSkeleton() {
return (
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex w-full items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<Input
placeholder='Search integrations...'
disabled
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{SKELETON_STRUCTURE.map(([providerName, serviceCount]) => (
<div key={providerName} className='flex flex-col gap-[8px]'>
<Skeleton className='h-[14px] w-[60px]' />
{Array.from({ length: serviceCount }).map((_, index) => (
<div key={index} className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
<div className='flex flex-col justify-center gap-[1px]'>
<Skeleton className='h-[14px] w-[100px]' />
<Skeleton className='h-[13px] w-[200px]' />
</div>
</div>
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
</div>
))}
</div>
))}
</div>
</div>
</div>
)
}
interface IntegrationsProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
export function Integrations({ onOpenChange, registerCloseHandler }: IntegrationsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const pendingServiceRef = useRef<HTMLDivElement>(null)
const { data: services = [], isPending } = useOAuthConnections()
const connectService = useConnectOAuthService()
const disconnectService = useDisconnectOAuthService()
const { config: permissionConfig } = usePermissionConfig()
const [searchTerm, setSearchTerm] = useState('')
const [pendingService, setPendingService] = useState<string | null>(null)
const [authSuccess, setAuthSuccess] = useState(false)
const [showActionRequired, setShowActionRequired] = useState(false)
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
const connectionAddedRef = useRef<boolean>(false)
// Disconnect confirmation dialog state
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
const [serviceToDisconnect, setServiceToDisconnect] = useState<{
service: ServiceInfo
accountId: string
} | null>(null)
// Check for OAuth callback - just show success message
useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (code && state) {
logger.info('OAuth callback successful')
setAuthSuccess(true)
// Clear URL parameters without changing the page
const url = new URL(window.location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
router.replace(url.pathname + url.search)
} else if (error) {
logger.error('OAuth error:', { error })
}
}, [searchParams, router])
// Track when a new connection is added compared to previous render
useEffect(() => {
try {
const currentConnected = new Set<string>()
services.forEach((svc) => {
if (svc.isConnected) currentConnected.add(svc.id)
})
// Detect new connections by comparing to previous connected set
for (const id of currentConnected) {
if (!prevConnectedIdsRef.current.has(id)) {
connectionAddedRef.current = true
break
}
}
prevConnectedIdsRef.current = currentConnected
} catch {}
}, [services])
// On mount, register a close handler so the parent modal can delegate close events here
useEffect(() => {
if (!registerCloseHandler) return
const handle = (open: boolean) => {
if (open) return
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('oauth-integration-closed', {
detail: { success: connectionAddedRef.current === true },
})
)
}
} catch {}
onOpenChange?.(open)
}
registerCloseHandler(handle)
}, [registerCloseHandler, onOpenChange])
// Handle connect button click
const handleConnect = async (service: ServiceInfo) => {
try {
logger.info('Connecting service:', {
serviceId: service.id,
providerId: service.providerId,
scopes: service.scopes,
})
// better-auth will automatically redirect back to this URL after OAuth
await connectService.mutateAsync({
providerId: service.providerId,
callbackURL: window.location.href,
})
} catch (error) {
logger.error('OAuth connection error:', { error })
}
}
/**
* Opens the disconnect confirmation dialog for a service.
*/
const handleDisconnect = (service: ServiceInfo, accountId: string) => {
setServiceToDisconnect({ service, accountId })
setShowDisconnectDialog(true)
}
/**
* Confirms and executes the service disconnection.
*/
const confirmDisconnect = async () => {
if (!serviceToDisconnect) return
setShowDisconnectDialog(false)
const { service, accountId } = serviceToDisconnect
setServiceToDisconnect(null)
try {
await disconnectService.mutateAsync({
provider: service.providerId.split('-')[0],
providerId: service.providerId,
serviceId: service.id,
accountId,
})
} catch (error) {
logger.error('Error disconnecting service:', { error })
}
}
const groupedServices = services.reduce(
(acc, service) => {
if (
permissionConfig.allowedIntegrations !== null &&
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_').toLowerCase())
) {
return acc
}
// Find the provider for this service
const providerKey =
Object.keys(OAUTH_PROVIDERS).find((key) =>
Object.keys(OAUTH_PROVIDERS[key].services).includes(service.id)
) || 'other'
if (!acc[providerKey]) {
acc[providerKey] = []
}
acc[providerKey].push(service)
return acc
},
{} as Record<string, ServiceInfo[]>
)
// Filter services based on search term
const filteredGroupedServices = Object.entries(groupedServices).reduce(
(acc, [providerKey, providerServices]) => {
const filteredServices = providerServices.filter(
(service) =>
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
service.description.toLowerCase().includes(searchTerm.toLowerCase())
)
if (filteredServices.length > 0) {
acc[providerKey] = filteredServices
}
return acc
},
{} as Record<string, ServiceInfo[]>
)
const scrollToHighlightedService = () => {
if (pendingServiceRef.current) {
pendingServiceRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
if (isPending) {
return <IntegrationsSkeleton />
}
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='flex w-full items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<Input
placeholder='Search services...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{authSuccess && (
<div className='flex items-center gap-[12px] rounded-[8px] border border-green-200 bg-green-50 p-[12px]'>
<Check className='h-4 w-4 flex-shrink-0 text-green-500' />
<p className='font-medium text-[13px] text-green-800'>
Account connected successfully!
</p>
</div>
)}
{pendingService && showActionRequired && (
<div className='flex items-start gap-[12px] rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[12px]'>
<ExternalLink className='mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--text-muted)]' />
<div className='flex flex-1 flex-col gap-[8px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
<span className='font-medium text-[var(--text-primary)]'>Action Required:</span>{' '}
Please connect your account to enable the requested features.
</p>
<Button variant='outline' onClick={scrollToHighlightedService}>
<span>Go to service</span>
<ChevronDown className='h-3 w-3' />
</Button>
</div>
</div>
)}
<div className='flex flex-col gap-[16px]'>
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='flex flex-col gap-[8px]'>
<Label className='text-[12px] text-[var(--text-tertiary)]'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</Label>
{providerServices.map((service) => (
<div
key={service.id}
className={cn(
'flex items-center justify-between',
pendingService === service.id &&
'-m-[8px] rounded-[8px] bg-[var(--bg)] p-[8px]'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex items-center gap-[12px]'>
<div className='flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'>
{createElement(service.icon, { className: 'h-4 w-4' })}
</div>
<div className='flex flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{service.name}</span>
{service.accounts && service.accounts.length > 0 ? (
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{service.accounts.map((a) => a.name).join(', ')}
</p>
) : (
<p className='truncate text-[13px] text-[var(--text-muted)]'>
{service.description}
</p>
)}
</div>
</div>
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={disconnectService.isPending}
>
Disconnect
</Button>
) : (
<Button
variant='tertiary'
onClick={() => handleConnect(service)}
disabled={connectService.isPending}
>
Connect
</Button>
)}
</div>
))}
</div>
))}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No services found matching "{searchTerm}"
</div>
)}
</div>
</div>
</div>
</div>
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
<ModalContent size='sm'>
<ModalHeader>Disconnect Service</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to disconnect{' '}
<span className='font-medium text-[var(--text-primary)]'>
{serviceToDisconnect?.service.name}
</span>
?{' '}
<span className='text-[var(--text-error)]'>
This will revoke access and you will need to reconnect to use this service.
</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDisconnectDialog(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={confirmDisconnect}>
Disconnect
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
@@ -20,6 +20,7 @@ import {
import {
Card,
Connections,
FolderCode,
HexSimple,
Key,
SModal,
@@ -44,11 +45,12 @@ import {
BYOK,
Copilot,
CredentialSets,
Credentials,
CustomTools,
Debug,
EnvironmentVariables,
FileUploads,
General,
Integrations,
MCP,
Skills,
Subscription,
@@ -78,8 +80,9 @@ interface SettingsModalProps {
type SettingsSection =
| 'general'
| 'credentials'
| 'environment'
| 'template-profile'
| 'integrations'
| 'credential-sets'
| 'access-control'
| 'apikeys'
@@ -153,10 +156,11 @@ const allNavigationItems: NavigationItem[] = [
requiresHosted: true,
requiresTeam: true,
},
{ id: 'credentials', label: 'Credentials', icon: Connections, section: 'account' },
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{
@@ -214,6 +218,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
const environmentBeforeLeaveHandler = useRef<((onProceed: () => void) => void) | null>(null)
const integrationsCloseHandler = useRef<((open: boolean) => void) | null>(null)
const userEmail = session?.user?.email
const userId = session?.user?.id
@@ -250,6 +256,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false
}
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
return false
}
if (item.id === 'files' && permissionConfig.hideFilesTab) {
return false
}
@@ -318,9 +327,26 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return activeSection
}, [activeSection])
const registerEnvironmentBeforeLeaveHandler = useCallback(
(handler: (onProceed: () => void) => void) => {
environmentBeforeLeaveHandler.current = handler
},
[]
)
const registerIntegrationsCloseHandler = useCallback((handler: (open: boolean) => void) => {
integrationsCloseHandler.current = handler
}, [])
const handleSectionChange = useCallback(
(sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return
}
setActiveSection(sectionId)
},
[effectiveActiveSection]
@@ -449,8 +475,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}
// Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => {
onOpenChange(newOpen)
if (
!newOpen &&
effectiveActiveSection === 'environment' &&
environmentBeforeLeaveHandler.current
) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
} else if (
!newOpen &&
effectiveActiveSection === 'integrations' &&
integrationsCloseHandler.current
) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
}
}
return (
@@ -461,7 +502,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</VisuallyHidden.Root>
<VisuallyHidden.Root>
<DialogPrimitive.Description>
Configure your workspace settings, credentials, and preferences
Configure your workspace settings, environment variables, integrations, and preferences
</DialogPrimitive.Description>
</VisuallyHidden.Root>
@@ -498,10 +539,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</SModalMainHeader>
<SModalMainBody>
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'credentials' && (
<Credentials onOpenChange={onOpenChange} />
{effectiveActiveSection === 'environment' && (
<EnvironmentVariables
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
/>
)}
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
{effectiveActiveSection === 'integrations' && (
<Integrations
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
{effectiveActiveSection === 'access-control' && <AccessControl />}
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}

View File

@@ -126,8 +126,6 @@ Return ONLY the JSON array.`,
title: 'Google Cloud Account',
type: 'oauth-input',
serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account',
required: true,
@@ -136,19 +134,6 @@ Return ONLY the JSON array.`,
value: providers.vertex.models,
},
},
{
id: 'manualCredential',
title: 'Google Cloud Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{
id: 'reasoningEffort',
title: 'Reasoning Effort',
@@ -747,7 +732,6 @@ Example 3 (Array Input):
apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' },
oauthCredential: { type: 'string', description: 'OAuth credential for Vertex AI' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },

View File

@@ -32,8 +32,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
id: 'credential',
title: 'Airtable Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable',
requiredScopes: [
'data.records:read',
@@ -44,15 +42,6 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
placeholder: 'Select Airtable account',
required: true,
},
{
id: 'manualCredential',
title: 'Airtable Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'baseId',
title: 'Base ID',
@@ -230,7 +219,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
}
},
params: (params) => {
const { oauthCredential, records, fields, ...rest } = params
const { credential, records, fields, ...rest } = params
let parsedRecords: any | undefined
let parsedFields: any | undefined
@@ -248,7 +237,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
// Construct parameters based on operation
const baseParams = {
credential: oauthCredential,
credential,
...rest,
}
@@ -266,7 +255,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Airtable access token' },
credential: { type: 'string', description: 'Airtable access token' },
baseId: { type: 'string', description: 'Airtable base identifier' },
tableId: { type: 'string', description: 'Airtable table identifier' },
// Conditional inputs

View File

@@ -32,22 +32,12 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
id: 'credential',
title: 'Asana Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true,
serviceId: 'asana',
requiredScopes: ['default'],
placeholder: 'Select Asana account',
},
{
id: 'manualCredential',
title: 'Asana Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'workspace',
title: 'Workspace GID',
@@ -225,7 +215,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}
},
params: (params) => {
const { oauthCredential, operation } = params
const { credential, operation } = params
const projectsArray = params.projects
? params.projects
@@ -235,7 +225,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
: undefined
const baseParams = {
accessToken: oauthCredential?.accessToken,
accessToken: credential?.accessToken,
}
switch (operation) {
@@ -294,7 +284,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Asana OAuth credential' },
workspace: { type: 'string', description: 'Workspace GID' },
taskGid: { type: 'string', description: 'Task GID' },
getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' },

View File

@@ -33,7 +33,6 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
type: 'dropdown',
options: [
{ label: 'Browser Use LLM', id: 'browser-use-llm' },
{ label: 'Browser Use 2.0', id: 'browser-use-2.0' },
{ label: 'GPT-4o', id: 'gpt-4o' },
{ label: 'GPT-4o Mini', id: 'gpt-4o-mini' },
{ label: 'GPT-4.1', id: 'gpt-4.1' },
@@ -43,7 +42,6 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
{ label: 'Gemini 2.5 Flash', id: 'gemini-2.5-flash' },
{ label: 'Gemini 2.5 Pro', id: 'gemini-2.5-pro' },
{ label: 'Gemini 3 Pro Preview', id: 'gemini-3-pro-preview' },
{ label: 'Gemini 3 Flash Preview', id: 'gemini-3-flash-preview' },
{ label: 'Gemini Flash Latest', id: 'gemini-flash-latest' },
{ label: 'Gemini Flash Lite Latest', id: 'gemini-flash-lite-latest' },
{ label: 'Claude 3.7 Sonnet', id: 'claude-3-7-sonnet-20250219' },

Some files were not shown because too many files have changed in this diff Show More