mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
13 Commits
v0.5.96
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae814549a | ||
|
|
e55d41f2ef | ||
|
|
364bb196ea | ||
|
|
69ec70af13 | ||
|
|
687c12528b | ||
|
|
996dc96d6e | ||
|
|
04286fc16b | ||
|
|
c52f78c840 | ||
|
|
e318bf2e65 | ||
|
|
4913799a27 | ||
|
|
ccb4f5956d | ||
|
|
2a6d4fcb96 | ||
|
|
42020c3ae2 |
@@ -5819,3 +5819,15 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
GrafanaIcon,
|
||||
GrainIcon,
|
||||
GreptileIcon,
|
||||
HexIcon,
|
||||
HubspotIcon,
|
||||
HuggingFaceIcon,
|
||||
HunterIOIcon,
|
||||
@@ -196,6 +197,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
grafana: GrafanaIcon,
|
||||
grain: GrainIcon,
|
||||
greptile: GreptileIcon,
|
||||
hex: HexIcon,
|
||||
hubspot: HubspotIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hunter: HunterIOIcon,
|
||||
|
||||
459
apps/docs/content/docs/en/tools/hex.mdx
Normal file
459
apps/docs/content/docs/en/tools/hex.mdx
Normal file
@@ -0,0 +1,459 @@
|
||||
---
|
||||
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 Hex’s interactive analytics capabilities right inside your automated Sim workflows.
|
||||
|
||||
Whether you’re 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 |
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"grafana",
|
||||
"grain",
|
||||
"greptile",
|
||||
"hex",
|
||||
"hubspot",
|
||||
"huggingface",
|
||||
"hunter",
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function OAuthConsentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' })
|
||||
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
@@ -164,13 +164,12 @@ export default function OAuthConsentPage() {
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='mb-6 flex items-center gap-4'>
|
||||
{clientInfo?.icon ? (
|
||||
<Image
|
||||
<img
|
||||
src={clientInfo.icon}
|
||||
alt={clientName ?? 'Application'}
|
||||
width={48}
|
||||
height={48}
|
||||
className='rounded-[10px]'
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
@@ -31,15 +31,13 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(account)
|
||||
.where(and(...whereConditions))
|
||||
|
||||
// Use the user's email as the display name (consistent with credential selector)
|
||||
const userEmail = session.user.email
|
||||
.orderBy(desc(account.updatedAt))
|
||||
|
||||
const accountsWithDisplayName = accounts.map((acc) => ({
|
||||
id: acc.id,
|
||||
accountId: acc.accountId,
|
||||
providerId: acc.providerId,
|
||||
displayName: userEmail || acc.providerId,
|
||||
displayName: acc.accountId || acc.providerId,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ accounts: accountsWithDisplayName })
|
||||
|
||||
@@ -57,10 +57,6 @@ 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),
|
||||
}))
|
||||
@@ -84,64 +80,6 @@ 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)
|
||||
|
||||
@@ -198,39 +136,12 @@ describe('OAuth Credentials API Route', () => {
|
||||
expect(data.credentials).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should decode ID token for display name', async () => {
|
||||
const { jwtDecode } = await import('jwt-decode')
|
||||
const mockJwtDecode = jwtDecode as any
|
||||
|
||||
it('should return empty credentials when no workspace context', async () => {
|
||||
mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-123' },
|
||||
})
|
||||
|
||||
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 req = createMockRequestWithQuery('GET', '?provider=google-email')
|
||||
|
||||
const { GET } = await import('@/app/api/auth/oauth/credentials/route')
|
||||
|
||||
@@ -238,31 +149,6 @@ describe('OAuth Credentials API Route', () => {
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
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()
|
||||
expect(data.credentials).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, user } from '@sim/db/schema'
|
||||
import { account, credential, credentialMember } 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 { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { evaluateScopeCoverage } from '@/lib/oauth'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,6 +19,7 @@ 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')
|
||||
@@ -29,10 +31,30 @@ const credentialsQuerySchema = z
|
||||
path: ['provider'],
|
||||
})
|
||||
|
||||
interface GoogleIdToken {
|
||||
email?: string
|
||||
sub?: string
|
||||
name?: string
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +68,7 @@ export async function GET(request: NextRequest) {
|
||||
const rawQuery = {
|
||||
provider: searchParams.get('provider'),
|
||||
workflowId: searchParams.get('workflowId'),
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
credentialId: searchParams.get('credentialId'),
|
||||
}
|
||||
|
||||
@@ -78,7 +101,7 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { provider: providerParam, workflowId, credentialId } = parseResult.data
|
||||
const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
|
||||
|
||||
// Authenticate requester (supports session and internal JWT)
|
||||
const authResult = await checkSessionOrInternalAuth(request)
|
||||
@@ -88,7 +111,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
const requesterUserId = authResult.userId
|
||||
|
||||
const effectiveUserId = requesterUserId
|
||||
let effectiveWorkspaceId = workspaceId ?? undefined
|
||||
if (workflowId) {
|
||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
@@ -106,105 +129,125 @@ export async function GET(request: NextRequest) {
|
||||
{ status: workflowAuthorization.status }
|
||||
)
|
||||
}
|
||||
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
|
||||
}
|
||||
|
||||
// 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 (effectiveWorkspaceId) {
|
||||
const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
|
||||
if (!workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (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)
|
||||
|
||||
// Try multiple methods to get a user-friendly display name
|
||||
let displayName = ''
|
||||
if (platformCredential) {
|
||||
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
|
||||
return NextResponse.json({ credentials: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
// 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 (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 2: For GitHub, the accountId might be the username
|
||||
if (!displayName && baseProvider === 'github') {
|
||||
displayName = `${acc.accountId} (GitHub)`
|
||||
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
|
||||
return NextResponse.json({ credentials: [] }, { 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)
|
||||
return NextResponse.json(
|
||||
{
|
||||
credentials: [
|
||||
toCredentialResponse(
|
||||
platformCredential.id,
|
||||
platformCredential.displayName,
|
||||
platformCredential.accountProviderId,
|
||||
platformCredential.accountUpdatedAt,
|
||||
platformCredential.accountScope
|
||||
),
|
||||
],
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if (effectiveWorkspaceId && providerParam) {
|
||||
await syncWorkspaceOAuthCredentialsForUser({
|
||||
workspaceId: effectiveWorkspaceId,
|
||||
userId: requesterUserId,
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ credentials }, { status: 200 })
|
||||
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 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching OAuth credentials`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -16,6 +16,7 @@ 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(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -51,15 +52,20 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { provider, providerId } = parseResult.data
|
||||
const { provider, providerId, accountId } = parseResult.data
|
||||
|
||||
logger.info(`[${requestId}] Processing OAuth disconnect request`, {
|
||||
provider,
|
||||
hasProviderId: !!providerId,
|
||||
})
|
||||
|
||||
// If a specific providerId is provided, delete only that account
|
||||
if (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
|
||||
await db
|
||||
.delete(account)
|
||||
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))
|
||||
|
||||
@@ -38,13 +38,18 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
||||
const credential = await getCredential(
|
||||
requestId,
|
||||
resolvedCredentialId,
|
||||
authz.credentialOwnerUserId
|
||||
)
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
resolvedCredentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
@@ -37,14 +37,19 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
||||
const credential = await getCredential(
|
||||
requestId,
|
||||
resolvedCredentialId,
|
||||
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(
|
||||
credentialId,
|
||||
resolvedCredentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
@@ -344,10 +344,11 @@ describe('OAuth Token API Routes', () => {
|
||||
*/
|
||||
describe('GET handler', () => {
|
||||
it('should return access token successfully', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
@@ -373,8 +374,8 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('accessToken', 'fresh-token')
|
||||
|
||||
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
|
||||
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalled()
|
||||
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -392,8 +393,8 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle authentication failure', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
@@ -406,15 +407,16 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle credential not found', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce(undefined)
|
||||
|
||||
@@ -432,10 +434,11 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle missing access token', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
@@ -458,10 +461,11 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
|
||||
@@ -110,23 +110,35 @@ 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 credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
||||
const credential = await getCredential(
|
||||
requestId,
|
||||
resolvedCredentialId,
|
||||
authz.credentialOwnerUserId
|
||||
)
|
||||
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||
const { accessToken } = await refreshTokenIfNeeded(
|
||||
requestId,
|
||||
credential,
|
||||
resolvedCredentialId
|
||||
)
|
||||
|
||||
let instanceUrl: string | undefined
|
||||
if (credential.providerId === 'salesforce' && credential.scope) {
|
||||
@@ -186,13 +198,20 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { credentialId } = parseResult.data
|
||||
|
||||
// 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 authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const credential = await getCredential(requestId, credentialId, auth.userId)
|
||||
const resolvedCredentialId = authz.resolvedCredentialId || credentialId
|
||||
const credential = await getCredential(
|
||||
requestId,
|
||||
resolvedCredentialId,
|
||||
authz.credentialOwnerUserId
|
||||
)
|
||||
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
@@ -204,7 +223,11 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||
const { accessToken } = await refreshTokenIfNeeded(
|
||||
requestId,
|
||||
credential,
|
||||
resolvedCredentialId
|
||||
)
|
||||
|
||||
// For Salesforce, extract instanceUrl from the scope field
|
||||
let instanceUrl: string | undefined
|
||||
|
||||
@@ -62,21 +62,23 @@ describe('OAuth Utils', () => {
|
||||
|
||||
describe('getCredential', () => {
|
||||
it('should return credential when found', async () => {
|
||||
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
||||
const { mockFrom, mockWhere, mockLimit } = mockSelectChain([mockCredential])
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
|
||||
const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
|
||||
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
expect(mockFrom).toHaveBeenCalled()
|
||||
expect(mockWhere).toHaveBeenCalled()
|
||||
expect(mockLimit).toHaveBeenCalledWith(1)
|
||||
expect(mockDb.select).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(credential).toEqual(mockCredential)
|
||||
expect(credential).toMatchObject(mockAccountRow)
|
||||
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
|
||||
})
|
||||
|
||||
it('should return undefined when credential is not found', async () => {
|
||||
mockSelectChain([])
|
||||
mockSelectChain([])
|
||||
|
||||
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
|
||||
|
||||
@@ -158,15 +160,17 @@ describe('OAuth Utils', () => {
|
||||
|
||||
describe('refreshAccessTokenIfNeeded', () => {
|
||||
it('should return valid access token without refresh if not expired', async () => {
|
||||
const mockCredential = {
|
||||
id: 'credential-id',
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredential])
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
|
||||
@@ -175,15 +179,17 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should refresh token when expired', async () => {
|
||||
const mockCredential = {
|
||||
id: 'credential-id',
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredential])
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockAccountRow])
|
||||
mockUpdateChain()
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce({
|
||||
@@ -201,6 +207,7 @@ 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')
|
||||
|
||||
@@ -208,15 +215,17 @@ describe('OAuth Utils', () => {
|
||||
})
|
||||
|
||||
it('should return null if refresh fails', async () => {
|
||||
const mockCredential = {
|
||||
id: 'credential-id',
|
||||
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
|
||||
const mockAccountRow = {
|
||||
id: 'account-id',
|
||||
accessToken: 'expired-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
|
||||
providerId: 'google',
|
||||
userId: 'test-user-id',
|
||||
}
|
||||
mockSelectChain([mockCredential])
|
||||
mockSelectChain([mockCredentialRow])
|
||||
mockSelectChain([mockAccountRow])
|
||||
|
||||
mockRefreshOAuthToken.mockResolvedValueOnce(null)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, credentialSetMember } from '@sim/db/schema'
|
||||
import { account, credential, credentialSetMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
@@ -25,6 +25,38 @@ 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.
|
||||
@@ -52,10 +84,16 @@ 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, credentialId), eq(account.userId, userId)))
|
||||
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
@@ -63,7 +101,10 @@ export async function getCredential(requestId: string, credentialId: string, use
|
||||
return undefined
|
||||
}
|
||||
|
||||
return credentials[0]
|
||||
return {
|
||||
...credentials[0],
|
||||
resolvedCredentialId: resolved.accountId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
|
||||
@@ -238,7 +279,9 @@ export async function refreshAccessTokenIfNeeded(
|
||||
}
|
||||
|
||||
// Update the token in the database
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
const resolvedCredentialId =
|
||||
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
|
||||
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
|
||||
|
||||
logger.info(`[${requestId}] Successfully refreshed access token for credential`)
|
||||
return refreshedToken.accessToken
|
||||
@@ -274,6 +317,8 @@ 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
|
||||
@@ -334,7 +379,7 @@ export async function refreshTokenIfNeeded(
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
|
||||
|
||||
logger.info(`[${requestId}] Successfully refreshed access token`)
|
||||
return { accessToken: refreshedToken, refreshed: true }
|
||||
@@ -343,7 +388,7 @@ export async function refreshTokenIfNeeded(
|
||||
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
|
||||
)
|
||||
|
||||
const freshCredential = await getCredential(requestId, credentialId, credential.userId)
|
||||
const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId)
|
||||
if (freshCredential?.accessToken) {
|
||||
const freshExpiresAt = freshCredential.accessTokenExpiresAt
|
||||
const stillValid = !freshExpiresAt || freshExpiresAt > new Date()
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -57,24 +57,41 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: itemIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
const accountRow = 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(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -47,27 +47,41 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
const accountRow = credentials[0]
|
||||
|
||||
// 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)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
|
||||
@@ -48,16 +48,21 @@ 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')),
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'shopify'),
|
||||
eq(account.accountId, stableAccountId)
|
||||
),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const accountData = {
|
||||
accessToken: accessToken,
|
||||
accountId: shopInfo.id?.toString() || shopDomain,
|
||||
accountId: stableAccountId,
|
||||
scope: scope || '',
|
||||
updatedAt: now,
|
||||
idToken: shopDomain,
|
||||
|
||||
@@ -52,7 +52,11 @@ 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')),
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'trello'),
|
||||
eq(account.accountId, trelloUser.id)
|
||||
),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
226
apps/sim/app/api/credentials/[id]/members/route.ts
Normal file
226
apps/sim/app/api/credentials/[id]/members/route.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
251
apps/sim/app/api/credentials/[id]/route.ts
Normal file
251
apps/sim/app/api/credentials/[id]/route.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
116
apps/sim/app/api/credentials/draft/route.ts
Normal file
116
apps/sim/app/api/credentials/draft/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
120
apps/sim/app/api/credentials/memberships/route.ts
Normal file
120
apps/sim/app/api/credentials/memberships/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
520
apps/sim/app/api/credentials/route.ts
Normal file
520
apps/sim/app/api/credentials/route.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,27 @@ 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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('TeamsSubscriptionRenewal')
|
||||
|
||||
async function getCredentialOwnerUserId(credentialId: string): Promise<string | null> {
|
||||
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
|
||||
}
|
||||
const [credentialRecord] = await db
|
||||
.select({ userId: account.userId })
|
||||
.from(account)
|
||||
.where(eq(account.id, credentialId))
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
return credentialRecord?.userId ?? null
|
||||
return credentialRecord
|
||||
? { userId: credentialRecord.userId, accountId: resolved.accountId }
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +97,8 @@ export async function GET(request: NextRequest) {
|
||||
continue
|
||||
}
|
||||
|
||||
const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId)
|
||||
if (!credentialOwnerUserId) {
|
||||
const credentialOwner = await getCredentialOwner(credentialId)
|
||||
if (!credentialOwner) {
|
||||
logger.error(`Credential owner not found for credential ${credentialId}`)
|
||||
totalFailed++
|
||||
continue
|
||||
@@ -97,8 +106,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Get fresh access token
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
credentialOwnerUserId,
|
||||
credentialOwner.accountId,
|
||||
credentialOwner.userId,
|
||||
`renewal-${webhook.id}`
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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')
|
||||
@@ -54,6 +55,11 @@ 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,
|
||||
|
||||
@@ -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(false)
|
||||
expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(true)
|
||||
expect(validateProxyUrl('http://192.168.1.1/config').isValid).toBe(false)
|
||||
expect(validateProxyUrl('http://10.0.0.1/internal').isValid).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpAuthorizationServerMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpAuthorizationServerMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { createMcpProtectedResourceMetadataResponse } from '@/lib/mcp/oauth-discovery'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse(request)
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return createMcpProtectedResourceMetadataResponse()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ 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,
|
||||
@@ -542,7 +543,8 @@ export async function POST(request: NextRequest) {
|
||||
const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key')
|
||||
|
||||
if (!hasAuth) {
|
||||
const resourceMetadataUrl = `${request.nextUrl.origin}/.well-known/oauth-protected-resource/api/mcp/copilot`
|
||||
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: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
user,
|
||||
userStats,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspaceEnvironment,
|
||||
workspaceInvitation,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
@@ -24,6 +25,7 @@ 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')
|
||||
@@ -496,6 +498,34 @@ 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 {
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
|
||||
@@ -360,15 +360,20 @@ 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, credentialId),
|
||||
where: eq(account.id, resolved.accountId),
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
throw new Error(`Vertex AI credential not found: ${credentialId}`)
|
||||
}
|
||||
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, resolved.accountId)
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to get Vertex AI access token')
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
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 { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -41,10 +41,27 @@ 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(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
@@ -52,13 +69,17 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
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 { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('GmailLabelsAPI')
|
||||
@@ -45,27 +45,45 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
let credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
|
||||
.limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!credentials.length) {
|
||||
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 })
|
||||
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 credential = credentials[0]
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
|
||||
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
|
||||
)
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import type { PlannerTask } from '@/tools/microsoft_planner/types'
|
||||
|
||||
const logger = createLogger('MicrosoftPlannerTasksAPI')
|
||||
@@ -42,24 +42,41 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: planIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
const accountRow = 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(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -45,22 +45,40 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Fetching credential`, { credentialId })
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const 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 accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -34,17 +34,39 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: fileIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -40,17 +40,39 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -44,7 +44,28 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
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)
|
||||
if (!creds.length) {
|
||||
logger.warn('Credential not found', { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
@@ -52,7 +73,7 @@ export async function GET(request: Request) {
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
resolved.accountId,
|
||||
credentialOwnerUserId,
|
||||
generateRequestId()
|
||||
)
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -34,17 +34,39 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import type { SharepointSite } from '@/tools/sharepoint/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -39,17 +39,39 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
if (credential.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const modelName =
|
||||
provider === 'anthropic' ? 'anthropic/claude-3-7-sonnet-latest' : 'openai/gpt-4.1'
|
||||
provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5'
|
||||
|
||||
try {
|
||||
logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName })
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const modelName =
|
||||
provider === 'anthropic' ? 'anthropic/claude-3-7-sonnet-latest' : 'openai/gpt-4.1'
|
||||
provider === 'anthropic' ? 'anthropic/claude-sonnet-4-5-20250929' : 'openai/gpt-5'
|
||||
|
||||
logger.info('Initializing Stagehand with Browserbase (v3)', { provider, modelName })
|
||||
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -64,24 +64,41 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
const accountRow = 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(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
|
||||
@@ -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 } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -64,24 +64,41 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
const accountRow = 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(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
|
||||
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -215,6 +216,8 @@ 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,
|
||||
})
|
||||
|
||||
@@ -32,9 +32,10 @@
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, user, workspace } from '@sim/db/schema'
|
||||
import { permissions, user, workspace, workspaceEnvironment } 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,
|
||||
@@ -232,6 +233,20 @@ 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,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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'
|
||||
|
||||
@@ -43,21 +42,21 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
logger.debug(`[${requestId}] Internal API call for deployed workflow: ${id}`)
|
||||
}
|
||||
|
||||
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)
|
||||
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 response = createSuccessResponse({
|
||||
deployedState: active?.state || null,
|
||||
})
|
||||
const response = createSuccessResponse({ deployedState })
|
||||
return addNoCacheHeaders(response)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)
|
||||
|
||||
@@ -38,6 +38,7 @@ import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type {
|
||||
ChildWorkflowContext,
|
||||
ExecutionMetadata,
|
||||
IterationContext,
|
||||
SerializableExecutionState,
|
||||
@@ -536,6 +537,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
}
|
||||
|
||||
@@ -742,7 +744,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
executionOrder: number,
|
||||
iterationContext?: IterationContext
|
||||
iterationContext?: IterationContext,
|
||||
childWorkflowContext?: ChildWorkflowContext
|
||||
) => {
|
||||
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
|
||||
sendEvent({
|
||||
@@ -761,6 +764,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
iterationType: iterationContext.iterationType,
|
||||
iterationContainerId: iterationContext.iterationContainerId,
|
||||
}),
|
||||
...(childWorkflowContext && {
|
||||
childWorkflowBlockId: childWorkflowContext.parentBlockId,
|
||||
childWorkflowName: childWorkflowContext.workflowName,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -770,9 +777,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
blockName: string,
|
||||
blockType: string,
|
||||
callbackData: any,
|
||||
iterationContext?: IterationContext
|
||||
iterationContext?: IterationContext,
|
||||
childWorkflowContext?: ChildWorkflowContext
|
||||
) => {
|
||||
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:`, {
|
||||
@@ -802,6 +820,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
iterationType: iterationContext.iterationType,
|
||||
iterationContainerId: iterationContext.iterationContainerId,
|
||||
}),
|
||||
...childWorkflowData,
|
||||
...instanceData,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -831,6 +851,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
iterationType: iterationContext.iterationType,
|
||||
iterationContainerId: iterationContext.iterationContainerId,
|
||||
}),
|
||||
...childWorkflowData,
|
||||
...instanceData,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -885,6 +907,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
}
|
||||
|
||||
@@ -898,12 +921,34 @@ 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,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { db } from '@sim/db'
|
||||
import { environment, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { 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 { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { 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')
|
||||
@@ -45,44 +47,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// 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)
|
||||
const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -157,6 +125,12 @@ 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,
|
||||
@@ -236,6 +210,12 @@ 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)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import crypto from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { permissions, workspace, 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 { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import {
|
||||
getUsersWithPermissions,
|
||||
hasWorkspaceAdminAccess,
|
||||
@@ -155,6 +156,20 @@ 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) {
|
||||
|
||||
@@ -8,15 +8,27 @@ const mockHasWorkspaceAdminAccess = vi.fn()
|
||||
let dbSelectResults: any[] = []
|
||||
let dbSelectCallIndex = 0
|
||||
|
||||
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 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 mockDbInsert = vi.fn().mockImplementation(() => ({
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -53,6 +65,10 @@ 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)
|
||||
@@ -97,6 +113,10 @@ vi.mock('@sim/db/schema', () => ({
|
||||
userId: 'userId',
|
||||
permissionType: 'permissionType',
|
||||
},
|
||||
workspaceEnvironment: {
|
||||
workspaceId: 'workspaceId',
|
||||
variables: 'variables',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
@@ -209,6 +229,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
[mockWorkspace],
|
||||
[{ ...mockUser, email: 'invited@example.com' }],
|
||||
[],
|
||||
[],
|
||||
]
|
||||
|
||||
const request = new NextRequest(
|
||||
@@ -462,6 +483,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
[mockWorkspace],
|
||||
[{ ...mockUser, email: 'invited@example.com' }],
|
||||
[],
|
||||
[],
|
||||
]
|
||||
|
||||
const request2 = new NextRequest(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceEnvironment,
|
||||
workspaceInvitation,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
@@ -15,6 +16,7 @@ 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'
|
||||
@@ -163,6 +165,20 @@ 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,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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')
|
||||
@@ -102,6 +103,8 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
)
|
||||
)
|
||||
|
||||
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -42,44 +43,10 @@ export function useChangeDetection({
|
||||
const currentState = useMemo((): WorkflowState | null => {
|
||||
if (!workflowId) return null
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {})
|
||||
|
||||
return {
|
||||
blocks: blocksWithSubBlocks,
|
||||
blocks: mergedBlocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface OAuthRequiredModalProps {
|
||||
requiredScopes?: string[]
|
||||
serviceId: string
|
||||
newScopes?: string[]
|
||||
onConnect?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -314,6 +315,7 @@ export function OAuthRequiredModal({
|
||||
requiredScopes = [],
|
||||
serviceId,
|
||||
newScopes = [],
|
||||
onConnect,
|
||||
}: OAuthRequiredModalProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
@@ -359,6 +361,12 @@ export function OAuthRequiredModal({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (onConnect) {
|
||||
await onConnect()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const providerId = getProviderIdFromServiceId(serviceId)
|
||||
|
||||
logger.info('Linking OAuth2:', {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'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,
|
||||
@@ -18,15 +19,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, CREDENTIAL_SET } from '@/executor/constants'
|
||||
import { CREDENTIAL_SET } from '@/executor/constants'
|
||||
import { useCredentialSets } from '@/hooks/queries/credential-sets'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
@@ -46,6 +47,8 @@ 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)
|
||||
@@ -96,64 +99,63 @@ export function CredentialSelector({
|
||||
data: credentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
|
||||
} = useOAuthCredentials(effectiveProviderId, {
|
||||
enabled: Boolean(effectiveProviderId),
|
||||
workspaceId,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
|
||||
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 isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
|
||||
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 resolvedLabel = useMemo(() => {
|
||||
if (selectedCredentialSet) return selectedCredentialSet.name
|
||||
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
|
||||
if (inaccessibleCredentialName) return inaccessibleCredentialName
|
||||
return ''
|
||||
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
|
||||
}, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName])
|
||||
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
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)
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
@@ -195,8 +197,18 @@ export function CredentialSelector({
|
||||
)
|
||||
|
||||
const handleAddCredential = useCallback(() => {
|
||||
setShowOAuthModal(true)
|
||||
}, [])
|
||||
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])
|
||||
|
||||
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
|
||||
const { baseProvider } = parseProvider(providerName)
|
||||
@@ -251,23 +263,18 @@ 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__',
|
||||
})
|
||||
|
||||
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__',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
groups.push({
|
||||
section: 'Personal Credential',
|
||||
items: credentialItems,
|
||||
})
|
||||
|
||||
return { comboboxOptions: [], comboboxGroups: groups }
|
||||
}
|
||||
@@ -277,12 +284,13 @@ export function CredentialSelector({
|
||||
value: cred.id,
|
||||
}))
|
||||
|
||||
if (credentials.length === 0) {
|
||||
options.push({
|
||||
label: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label:
|
||||
credentials.length > 0
|
||||
? `Connect another ${getProviderName(provider)} account`
|
||||
: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
})
|
||||
|
||||
return { comboboxOptions: options, comboboxGroups: undefined }
|
||||
}, [
|
||||
@@ -368,7 +376,7 @@ export function CredentialSelector({
|
||||
}
|
||||
disabled={effectiveDisabled}
|
||||
editable={true}
|
||||
filterOptions={!isForeign && !isForeignCredentialSet}
|
||||
filterOptions={true}
|
||||
isLoading={credentialsLoading}
|
||||
overlayContent={overlayContent}
|
||||
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
|
||||
@@ -380,15 +388,13 @@ export function CredentialSelector({
|
||||
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
|
||||
Additional permissions required
|
||||
</div>
|
||||
{!isForeign && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => setShowOAuthModal(true)}
|
||||
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
Update access
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => setShowOAuthModal(true)}
|
||||
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
Update access
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -406,31 +412,3 @@ 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])
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PopoverSection,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
|
||||
import {
|
||||
usePersonalEnvironment,
|
||||
useWorkspaceEnvironment,
|
||||
@@ -168,7 +169,15 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
}, [searchTerm])
|
||||
|
||||
const openEnvironmentSettings = () => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } }))
|
||||
if (workspaceId) {
|
||||
writePendingCredentialCreateRequest({
|
||||
workspaceId,
|
||||
type: 'env_personal',
|
||||
envKey: searchTerm.trim(),
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
@@ -302,7 +311,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
}}
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
<span>Create environment variable</span>
|
||||
<span>Create Secret</span>
|
||||
</PopoverItem>
|
||||
</PopoverScrollArea>
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,6 @@ 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'
|
||||
@@ -125,8 +124,6 @@ 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,
|
||||
@@ -168,7 +165,6 @@ export function FileSelectorInput({
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
isForeignCredential ||
|
||||
missingCredential ||
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -47,10 +46,6 @@ 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, {
|
||||
@@ -119,9 +114,7 @@ export function FolderSelectorInput({
|
||||
selectorContext={
|
||||
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
|
||||
}
|
||||
disabled={
|
||||
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
|
||||
}
|
||||
disabled={finalDisabled || missingCredential || !selectorResolution?.key}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select folder'}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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'
|
||||
@@ -73,11 +72,6 @@ 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,
|
||||
@@ -123,7 +117,7 @@ export function ProjectSelectorInput({
|
||||
subBlock={subBlock}
|
||||
selectorKey={selectorResolution.key}
|
||||
selectorContext={selectorResolution.context}
|
||||
disabled={finalDisabled || isForeignCredential || missingCredential}
|
||||
disabled={finalDisabled || missingCredential}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || 'Select project'}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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'
|
||||
@@ -87,8 +86,6 @@ 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,
|
||||
@@ -101,11 +98,7 @@ export function SheetSelectorInput({
|
||||
const missingSpreadsheet = !normalizedSpreadsheetId
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
isForeignCredential ||
|
||||
missingCredential ||
|
||||
missingSpreadsheet ||
|
||||
!selectorResolution?.key
|
||||
finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,6 @@ 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'
|
||||
@@ -85,11 +84,6 @@ 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') {
|
||||
@@ -99,7 +93,7 @@ export function SlackSelectorInput({
|
||||
|
||||
const requiresCredential = dependsOn.includes('credential')
|
||||
const missingCredential = !credential || credential.trim().length === 0
|
||||
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential)
|
||||
const shouldForceDisable = requiresCredential && missingCredential
|
||||
|
||||
const context: SelectorContext = useMemo(
|
||||
() => ({
|
||||
@@ -136,7 +130,7 @@ export function SlackSelectorInput({
|
||||
subBlock={subBlock}
|
||||
selectorKey={config.selectorKey}
|
||||
selectorContext={context}
|
||||
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
|
||||
disabled={finalDisabled || shouldForceDisable}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue ?? null}
|
||||
placeholder={subBlock.placeholder || config.placeholder}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, 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,
|
||||
@@ -11,8 +13,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 { CREDENTIAL } from '@/executor/constants'
|
||||
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -64,6 +66,10 @@ 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)
|
||||
@@ -78,50 +84,57 @@ export function ToolCredentialSelector({
|
||||
data: credentials = [],
|
||||
isFetching: credentialsLoading,
|
||||
refetch: refetchCredentials,
|
||||
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId))
|
||||
} = useOAuthCredentials(effectiveProviderId, {
|
||||
enabled: Boolean(effectiveProviderId),
|
||||
workspaceId,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
|
||||
const selectedCredential = useMemo(
|
||||
() => credentials.find((cred) => cred.id === selectedId),
|
||||
[credentials, selectedId]
|
||||
)
|
||||
|
||||
const shouldFetchForeignMeta =
|
||||
Boolean(selectedId) &&
|
||||
!selectedCredential &&
|
||||
Boolean(activeWorkflowId) &&
|
||||
Boolean(effectiveProviderId)
|
||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
||||
|
||||
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
|
||||
useOAuthCredentialDetail(
|
||||
shouldFetchForeignMeta ? selectedId : undefined,
|
||||
activeWorkflowId || undefined,
|
||||
shouldFetchForeignMeta
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
||||
setInaccessibleCredentialName(null)
|
||||
return
|
||||
}
|
||||
|
||||
const hasForeignMeta = foreignCredentials.length > 0
|
||||
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
|
||||
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 resolvedLabel = useMemo(() => {
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
|
||||
if (inaccessibleCredentialName) return inaccessibleCredentialName
|
||||
return ''
|
||||
}, [selectedCredential, isForeign])
|
||||
}, [selectedCredential, inaccessibleCredentialName])
|
||||
|
||||
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
Boolean(selectedId) &&
|
||||
!selectedCredential &&
|
||||
!hasForeignMeta &&
|
||||
!credentialsLoading &&
|
||||
!foreignMetaLoading
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidSelection) return
|
||||
onChange('')
|
||||
}, [invalidSelection, onChange])
|
||||
|
||||
useCredentialRefreshTriggers(refetchCredentials)
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
@@ -149,8 +162,18 @@ export function ToolCredentialSelector({
|
||||
)
|
||||
|
||||
const handleAddCredential = useCallback(() => {
|
||||
setShowOAuthModal(true)
|
||||
}, [])
|
||||
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])
|
||||
|
||||
const comboboxOptions = useMemo(() => {
|
||||
const options = credentials.map((cred) => ({
|
||||
@@ -158,12 +181,13 @@ export function ToolCredentialSelector({
|
||||
value: cred.id,
|
||||
}))
|
||||
|
||||
if (credentials.length === 0) {
|
||||
options.push({
|
||||
label: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label:
|
||||
credentials.length > 0
|
||||
? `Connect another ${getProviderName(provider)} account`
|
||||
: `Connect ${getProviderName(provider)} account`,
|
||||
value: '__connect_account__',
|
||||
})
|
||||
|
||||
return options
|
||||
}, [credentials, provider])
|
||||
@@ -213,7 +237,7 @@ export function ToolCredentialSelector({
|
||||
placeholder={effectiveLabel}
|
||||
disabled={disabled}
|
||||
editable={true}
|
||||
filterOptions={!isForeign}
|
||||
filterOptions={true}
|
||||
isLoading={credentialsLoading}
|
||||
overlayContent={overlayContent}
|
||||
className={selectedId ? 'pl-[28px]' : ''}
|
||||
@@ -225,15 +249,13 @@ export function ToolCredentialSelector({
|
||||
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
|
||||
Additional permissions required
|
||||
</div>
|
||||
{!isForeign && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => setShowOAuthModal(true)}
|
||||
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
Update access
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => setShowOAuthModal(true)}
|
||||
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
Update access
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -251,31 +273,3 @@ 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])
|
||||
}
|
||||
|
||||
@@ -24,12 +24,7 @@ import {
|
||||
getMcpToolIssue as validateMcpTool,
|
||||
} from '@/lib/mcp/tool-validation'
|
||||
import type { McpToolSchema } from '@/lib/mcp/types'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
getProviderIdFromServiceId,
|
||||
type OAuthProvider,
|
||||
type OAuthService,
|
||||
} from '@/lib/oauth'
|
||||
import { 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 {
|
||||
@@ -1414,16 +1409,6 @@ 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
|
||||
@@ -1537,16 +1522,11 @@ 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 || (requiresOAuth && oauthConfig) || hasParams
|
||||
const hasToolBody = hasOperations || hasParams
|
||||
|
||||
const isExpandedForDisplay = hasToolBody
|
||||
? isPreview
|
||||
@@ -1746,39 +1726,6 @@ 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]
|
||||
@@ -1848,44 +1795,8 @@ export const ToolInput = memo(function ToolInput({
|
||||
})
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
for (const sb of displaySubBlocks) {
|
||||
renderedElements.push(renderSubBlock(sb))
|
||||
}
|
||||
|
||||
const uncoveredParams = displayParams.filter(
|
||||
@@ -1924,11 +1835,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const el = renderOAuthAccount()
|
||||
if (el) renderedElements.push(el)
|
||||
}
|
||||
|
||||
const filteredParams = displayParams.filter((param) =>
|
||||
evaluateParameterCondition(param, tool)
|
||||
)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -41,6 +41,7 @@ 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,
|
||||
@@ -67,6 +68,21 @@ 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
|
||||
*/
|
||||
@@ -338,6 +354,122 @@ 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
|
||||
*/
|
||||
@@ -368,6 +500,18 @@ 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
|
||||
@@ -659,27 +803,15 @@ export const Terminal = memo(function Terminal() {
|
||||
])
|
||||
|
||||
/**
|
||||
* Auto-expand subflows and iterations when new entries arrive.
|
||||
* 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.
|
||||
* This always runs regardless of autoSelectEnabled - new runs should always be visible.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (executionGroups.length === 0) return
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const nodeIdsToExpand = collectExpandableNodeIds(executionGroups[0].entryTree)
|
||||
|
||||
if (nodeIdsToExpand.length > 0) {
|
||||
setExpandedNodes((prev) => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type React from 'react'
|
||||
import { AlertTriangleIcon, BanIcon, RepeatIcon, SplitIcon, XCircleIcon } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BanIcon,
|
||||
NetworkIcon,
|
||||
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'
|
||||
|
||||
@@ -12,6 +20,8 @@ const SUBFLOW_COLORS = {
|
||||
parallel: '#FEE12B',
|
||||
} as const
|
||||
|
||||
const WORKFLOW_COLOR = '#8b5cf6'
|
||||
|
||||
/**
|
||||
* Special block type colors for errors and system messages
|
||||
*/
|
||||
@@ -41,6 +51,10 @@ export function getBlockIcon(
|
||||
return SplitIcon
|
||||
}
|
||||
|
||||
if (blockType === 'workflow') {
|
||||
return NetworkIcon
|
||||
}
|
||||
|
||||
if (blockType === 'error') {
|
||||
return XCircleIcon
|
||||
}
|
||||
@@ -71,6 +85,9 @@ 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
|
||||
@@ -120,7 +137,7 @@ export function isSubflowBlockType(blockType: string): boolean {
|
||||
/**
|
||||
* Node type for the tree structure
|
||||
*/
|
||||
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
|
||||
export type EntryNodeType = 'block' | 'subflow' | 'iteration' | 'workflow'
|
||||
|
||||
/**
|
||||
* Entry node for tree structure - represents a block, subflow, or iteration
|
||||
@@ -168,6 +185,36 @@ 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
|
||||
@@ -175,18 +222,37 @@ interface IterationGroup {
|
||||
* Sorts by start time to ensure chronological order.
|
||||
*/
|
||||
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
// Separate regular blocks from iteration entries
|
||||
// Separate entries into three buckets:
|
||||
// 1. Iteration entries (loop/parallel children)
|
||||
// 2. Workflow child entries (blocks inside a child workflow)
|
||||
// 3. Regular blocks
|
||||
const regularBlocks: ConsoleEntry[] = []
|
||||
const iterationEntries: ConsoleEntry[] = []
|
||||
const workflowChildEntries: ConsoleEntry[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.iterationType && entry.iterationCurrent !== undefined) {
|
||||
if (entry.childWorkflowBlockId) {
|
||||
// Child workflow entries take priority over iteration classification
|
||||
workflowChildEntries.push(entry)
|
||||
} else 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) {
|
||||
@@ -261,6 +327,9 @@ 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
|
||||
@@ -276,7 +345,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
startedAt: new Date(subflowStartMs).toISOString(),
|
||||
executionOrder: subflowExecutionOrder,
|
||||
endedAt: new Date(subflowEndMs).toISOString(),
|
||||
durationMs: totalDuration,
|
||||
durationMs: subflowDuration,
|
||||
success: !allBlocks.some((b) => b.error),
|
||||
}
|
||||
|
||||
@@ -291,6 +360,9 @@ 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))
|
||||
@@ -305,7 +377,7 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
startedAt: new Date(iterStartMs).toISOString(),
|
||||
executionOrder: iterExecutionOrder,
|
||||
endedAt: new Date(iterEndMs).toISOString(),
|
||||
durationMs: iterDuration,
|
||||
durationMs: iterDisplayDuration,
|
||||
success: !iterBlocks.some((b) => b.error),
|
||||
iterationCurrent: iterGroup.iterationCurrent,
|
||||
iterationTotal: iterGroup.iterationTotal,
|
||||
@@ -313,12 +385,24 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
iterationContainerId: iterGroup.iterationContainerId,
|
||||
}
|
||||
|
||||
// Block nodes within this iteration
|
||||
const blockNodes: EntryNode[] = iterBlocks.map((block) => ({
|
||||
entry: block,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
// 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 }
|
||||
})
|
||||
|
||||
return {
|
||||
entry: syntheticIteration,
|
||||
@@ -338,19 +422,61 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Build nodes for regular blocks
|
||||
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
|
||||
// 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) => ({
|
||||
entry,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
|
||||
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
|
||||
const allNodes = [...subflowNodes, ...regularNodes]
|
||||
const allNodes = [...subflowNodes, ...workflowNodes, ...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.
|
||||
@@ -458,7 +584,7 @@ export function flattenBlockEntriesOnly(
|
||||
): NavigableBlockEntry[] {
|
||||
const result: NavigableBlockEntry[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === 'block') {
|
||||
if (node.nodeType === 'block' || node.nodeType === 'workflow') {
|
||||
result.push({
|
||||
entry: node.entry,
|
||||
executionId,
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 {
|
||||
@@ -63,6 +64,7 @@ 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>
|
||||
@@ -309,6 +311,7 @@ export function useWorkflowExecution() {
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs,
|
||||
accumulatedBlockStates,
|
||||
executedBlockIds,
|
||||
@@ -327,11 +330,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
|
||||
if (!workflowId) return
|
||||
if (isActive) {
|
||||
activeBlocksSet.add(blockId)
|
||||
} else {
|
||||
activeBlocksSet.delete(blockId)
|
||||
}
|
||||
updateActiveBlockRefCount(activeBlockRefCounts, activeBlocksSet, blockId, isActive)
|
||||
setActiveBlocks(workflowId, new Set(activeBlocksSet))
|
||||
}
|
||||
|
||||
@@ -384,6 +383,9 @@ export function useWorkflowExecution() {
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -407,6 +409,9 @@ export function useWorkflowExecution() {
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -426,6 +431,9 @@ export function useWorkflowExecution() {
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
@@ -448,6 +456,9 @@ export function useWorkflowExecution() {
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
childWorkflowInstanceId: data.childWorkflowInstanceId,
|
||||
},
|
||||
executionIdRef.current
|
||||
)
|
||||
@@ -479,6 +490,8 @@ export function useWorkflowExecution() {
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
iterationContainerId: data.iterationContainerId,
|
||||
childWorkflowBlockId: data.childWorkflowBlockId,
|
||||
childWorkflowName: data.childWorkflowName,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -536,7 +549,27 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
return { onBlockStarted, onBlockCompleted, onBlockError }
|
||||
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 }
|
||||
},
|
||||
[addConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, updateConsole]
|
||||
)
|
||||
@@ -1280,6 +1313,7 @@ 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>()
|
||||
@@ -1292,6 +1326,7 @@ export function useWorkflowExecution() {
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs,
|
||||
accumulatedBlockStates,
|
||||
executedBlockIds,
|
||||
@@ -1334,6 +1369,7 @@ export function useWorkflowExecution() {
|
||||
onBlockStarted: blockHandlers.onBlockStarted,
|
||||
onBlockCompleted: blockHandlers.onBlockCompleted,
|
||||
onBlockError: blockHandlers.onBlockError,
|
||||
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
|
||||
|
||||
onStreamChunk: (data) => {
|
||||
const existing = streamedContent.get(data.blockId) || ''
|
||||
@@ -1902,6 +1938,7 @@ 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({
|
||||
@@ -1909,6 +1946,7 @@ export function useWorkflowExecution() {
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs,
|
||||
accumulatedBlockStates,
|
||||
executedBlockIds,
|
||||
@@ -1929,6 +1967,7 @@ export function useWorkflowExecution() {
|
||||
onBlockStarted: blockHandlers.onBlockStarted,
|
||||
onBlockCompleted: blockHandlers.onBlockCompleted,
|
||||
onBlockError: blockHandlers.onBlockError,
|
||||
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
|
||||
|
||||
onExecutionCompleted: (data) => {
|
||||
if (data.success) {
|
||||
@@ -2104,6 +2143,7 @@ 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>()
|
||||
@@ -2115,6 +2155,7 @@ export function useWorkflowExecution() {
|
||||
executionIdRef,
|
||||
workflowEdges,
|
||||
activeBlocksSet,
|
||||
activeBlockRefCounts,
|
||||
accumulatedBlockLogs,
|
||||
accumulatedBlockStates,
|
||||
executedBlockIds,
|
||||
@@ -2155,6 +2196,10 @@ export function useWorkflowExecution() {
|
||||
clearOnce()
|
||||
handlers.onBlockError(data)
|
||||
},
|
||||
onBlockChildWorkflowStarted: (data) => {
|
||||
clearOnce()
|
||||
handlers.onBlockChildWorkflowStarted(data)
|
||||
},
|
||||
onExecutionCompleted: () => {
|
||||
const currentId = useExecutionStore
|
||||
.getState()
|
||||
|
||||
@@ -5,6 +5,30 @@ 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>
|
||||
@@ -39,6 +63,7 @@ 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,
|
||||
@@ -103,7 +128,12 @@ export async function executeWorkflowWithFullLogging(
|
||||
|
||||
switch (event.type) {
|
||||
case 'block:started': {
|
||||
activeBlocksSet.add(event.data.blockId)
|
||||
updateActiveBlockRefCount(
|
||||
activeBlockRefCounts,
|
||||
activeBlocksSet,
|
||||
event.data.blockId,
|
||||
true
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
const incomingEdges = workflowEdges.filter(
|
||||
@@ -115,8 +145,13 @@ export async function executeWorkflowWithFullLogging(
|
||||
break
|
||||
}
|
||||
|
||||
case 'block:completed':
|
||||
activeBlocksSet.delete(event.data.blockId)
|
||||
case 'block:completed': {
|
||||
updateActiveBlockRefCount(
|
||||
activeBlockRefCounts,
|
||||
activeBlocksSet,
|
||||
event.data.blockId,
|
||||
false
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'success')
|
||||
@@ -138,15 +173,24 @@ 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':
|
||||
activeBlocksSet.delete(event.data.blockId)
|
||||
case 'block:error': {
|
||||
updateActiveBlockRefCount(
|
||||
activeBlockRefCounts,
|
||||
activeBlocksSet,
|
||||
event.data.blockId,
|
||||
false
|
||||
)
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'error')
|
||||
@@ -169,8 +213,30 @@ 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 = {
|
||||
|
||||
@@ -255,6 +255,69 @@ 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,
|
||||
|
||||
@@ -473,7 +473,7 @@ function ConnectionsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{/* Secrets */}
|
||||
{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)]'
|
||||
)}
|
||||
>
|
||||
Environment Variables
|
||||
Secrets
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
|
||||
@@ -31,10 +31,9 @@ const logger = createLogger('ApiKeys')
|
||||
|
||||
interface ApiKeysProps {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
}
|
||||
|
||||
export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id
|
||||
const params = useParams()
|
||||
@@ -118,12 +117,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
onOpenChange?.(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
registerCloseHandler(handleModalClose)
|
||||
}
|
||||
}, [registerCloseHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,864 +0,0 @@
|
||||
'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,11 @@ 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'
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, 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,7 +20,6 @@ import {
|
||||
import {
|
||||
Card,
|
||||
Connections,
|
||||
FolderCode,
|
||||
HexSimple,
|
||||
Key,
|
||||
SModal,
|
||||
@@ -45,12 +44,11 @@ import {
|
||||
BYOK,
|
||||
Copilot,
|
||||
CredentialSets,
|
||||
Credentials,
|
||||
CustomTools,
|
||||
Debug,
|
||||
EnvironmentVariables,
|
||||
FileUploads,
|
||||
General,
|
||||
Integrations,
|
||||
MCP,
|
||||
Skills,
|
||||
Subscription,
|
||||
@@ -80,9 +78,8 @@ interface SettingsModalProps {
|
||||
|
||||
type SettingsSection =
|
||||
| 'general'
|
||||
| 'environment'
|
||||
| 'credentials'
|
||||
| 'template-profile'
|
||||
| 'integrations'
|
||||
| 'credential-sets'
|
||||
| 'access-control'
|
||||
| 'apikeys'
|
||||
@@ -156,11 +153,10 @@ const allNavigationItems: NavigationItem[] = [
|
||||
requiresHosted: true,
|
||||
requiresTeam: true,
|
||||
},
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
{ id: 'credentials', label: 'Credentials', icon: Connections, section: 'account' },
|
||||
{ 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' },
|
||||
{
|
||||
@@ -218,8 +214,6 @@ 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
|
||||
@@ -256,9 +250,6 @@ 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
|
||||
}
|
||||
@@ -327,26 +318,9 @@ 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]
|
||||
@@ -475,23 +449,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dialog close - delegate to environment component if it's active
|
||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||
if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'environment' &&
|
||||
environmentBeforeLeaveHandler.current
|
||||
) {
|
||||
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
|
||||
} else if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'integrations' &&
|
||||
integrationsCloseHandler.current
|
||||
) {
|
||||
integrationsCloseHandler.current(newOpen)
|
||||
} else {
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -502,7 +461,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
</VisuallyHidden.Root>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogPrimitive.Description>
|
||||
Configure your workspace settings, environment variables, integrations, and preferences
|
||||
Configure your workspace settings, credentials, and preferences
|
||||
</DialogPrimitive.Description>
|
||||
</VisuallyHidden.Root>
|
||||
|
||||
@@ -539,18 +498,10 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
</SModalMainHeader>
|
||||
<SModalMainBody>
|
||||
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'environment' && (
|
||||
<EnvironmentVariables
|
||||
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
||||
/>
|
||||
{effectiveActiveSection === 'credentials' && (
|
||||
<Credentials onOpenChange={onOpenChange} />
|
||||
)}
|
||||
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
||||
{effectiveActiveSection === 'integrations' && (
|
||||
<Integrations
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={registerIntegrationsCloseHandler}
|
||||
/>
|
||||
)}
|
||||
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
||||
{effectiveActiveSection === 'access-control' && <AccessControl />}
|
||||
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
|
||||
@@ -126,6 +126,8 @@ 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,
|
||||
@@ -134,6 +136,19 @@ 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',
|
||||
@@ -732,6 +747,7 @@ 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' },
|
||||
|
||||
@@ -32,6 +32,8 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Airtable Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'airtable',
|
||||
requiredScopes: [
|
||||
'data.records:read',
|
||||
@@ -42,6 +44,15 @@ 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',
|
||||
@@ -219,7 +230,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, records, fields, ...rest } = params
|
||||
const { oauthCredential, records, fields, ...rest } = params
|
||||
let parsedRecords: any | undefined
|
||||
let parsedFields: any | undefined
|
||||
|
||||
@@ -237,7 +248,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
|
||||
|
||||
// Construct parameters based on operation
|
||||
const baseParams = {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
...rest,
|
||||
}
|
||||
|
||||
@@ -255,7 +266,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Airtable access token' },
|
||||
oauthCredential: { type: 'string', description: 'Airtable access token' },
|
||||
baseId: { type: 'string', description: 'Airtable base identifier' },
|
||||
tableId: { type: 'string', description: 'Airtable table identifier' },
|
||||
// Conditional inputs
|
||||
|
||||
@@ -32,12 +32,22 @@ 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',
|
||||
@@ -215,7 +225,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, operation } = params
|
||||
const { oauthCredential, operation } = params
|
||||
|
||||
const projectsArray = params.projects
|
||||
? params.projects
|
||||
@@ -225,7 +235,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
|
||||
: undefined
|
||||
|
||||
const baseParams = {
|
||||
accessToken: credential?.accessToken,
|
||||
accessToken: oauthCredential?.accessToken,
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
@@ -284,6 +294,7 @@ 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' },
|
||||
|
||||
@@ -33,6 +33,7 @@ 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' },
|
||||
@@ -42,6 +43,7 @@ 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' },
|
||||
|
||||
@@ -49,9 +49,20 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
|
||||
title: 'Cal.com Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'calcom',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select Cal.com account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Cal.com Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// === Create Booking fields ===
|
||||
{
|
||||
@@ -555,7 +566,7 @@ Return ONLY valid JSON - no explanations.`,
|
||||
params: (params) => {
|
||||
const {
|
||||
operation,
|
||||
credential,
|
||||
oauthCredential,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
attendeeTimeZone,
|
||||
@@ -745,7 +756,7 @@ Return ONLY valid JSON - no explanations.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Cal.com OAuth credential' },
|
||||
oauthCredential: { type: 'string', description: 'Cal.com OAuth credential' },
|
||||
eventTypeId: { type: 'number', description: 'Event type ID' },
|
||||
start: { type: 'string', description: 'Start time (ISO 8601)' },
|
||||
end: { type: 'string', description: 'End time (ISO 8601)' },
|
||||
|
||||
@@ -51,6 +51,8 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Confluence Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'confluence',
|
||||
requiredScopes: [
|
||||
'read:confluence-content.all',
|
||||
@@ -85,6 +87,15 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
placeholder: 'Select Confluence account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Confluence Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'pageId',
|
||||
title: 'Select Page',
|
||||
@@ -287,7 +298,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
pageId,
|
||||
operation,
|
||||
attachmentFile,
|
||||
@@ -300,7 +311,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'upload_attachment') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
file: attachmentFile,
|
||||
@@ -311,7 +322,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
}
|
||||
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId || undefined,
|
||||
operation,
|
||||
...rest,
|
||||
@@ -322,7 +333,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
domain: { type: 'string', description: 'Confluence domain' },
|
||||
credential: { type: 'string', description: 'Confluence access token' },
|
||||
oauthCredential: { type: 'string', description: 'Confluence access token' },
|
||||
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
|
||||
spaceId: { type: 'string', description: 'Space identifier' },
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
@@ -428,6 +439,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Confluence Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'confluence',
|
||||
requiredScopes: [
|
||||
'read:confluence-content.all',
|
||||
@@ -462,6 +475,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
placeholder: 'Select Confluence account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Confluence Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
@@ -943,7 +965,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
pageId,
|
||||
operation,
|
||||
attachmentFile,
|
||||
@@ -968,7 +990,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'add_label') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
prefix: labelPrefix || 'global',
|
||||
@@ -978,7 +1000,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'create_blogpost') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
operation,
|
||||
status: blogPostStatus || 'current',
|
||||
...rest,
|
||||
@@ -987,7 +1009,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'delete') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
purge: purge || false,
|
||||
@@ -997,7 +1019,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'list_comments') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
bodyFormat: bodyFormat || 'storage',
|
||||
@@ -1023,7 +1045,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (supportsCursor.includes(operation) && cursor) {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId || undefined,
|
||||
operation,
|
||||
cursor,
|
||||
@@ -1036,7 +1058,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
throw new Error('Property key is required for this operation.')
|
||||
}
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
key: propertyKey,
|
||||
@@ -1047,7 +1069,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'delete_page_property') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
propertyId,
|
||||
@@ -1057,7 +1079,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'get_pages_by_label') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
operation,
|
||||
labelId,
|
||||
cursor: cursor || undefined,
|
||||
@@ -1067,7 +1089,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
|
||||
if (operation === 'list_space_labels') {
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
operation,
|
||||
cursor: cursor || undefined,
|
||||
...rest,
|
||||
@@ -1080,7 +1102,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
throw new Error('File is required for upload attachment operation.')
|
||||
}
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId,
|
||||
operation,
|
||||
file: normalizedFile,
|
||||
@@ -1091,7 +1113,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
}
|
||||
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
pageId: effectivePageId || undefined,
|
||||
blogPostId: blogPostId || undefined,
|
||||
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
|
||||
@@ -1104,7 +1126,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
domain: { type: 'string', description: 'Confluence domain' },
|
||||
credential: { type: 'string', description: 'Confluence access token' },
|
||||
oauthCredential: { type: 'string', description: 'Confluence access token' },
|
||||
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
|
||||
spaceId: { type: 'string', description: 'Space identifier' },
|
||||
blogPostId: { type: 'string', description: 'Blog post identifier' },
|
||||
|
||||
@@ -38,6 +38,8 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Dropbox Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'dropbox',
|
||||
requiredScopes: [
|
||||
'account_info.read',
|
||||
@@ -51,6 +53,15 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
|
||||
placeholder: 'Select Dropbox account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Dropbox Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Upload operation inputs
|
||||
{
|
||||
id: 'path',
|
||||
@@ -348,7 +359,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Dropbox OAuth credential' },
|
||||
oauthCredential: { type: 'string', description: 'Dropbox OAuth credential' },
|
||||
// Common inputs
|
||||
path: { type: 'string', description: 'Path in Dropbox' },
|
||||
autorename: { type: 'boolean', description: 'Auto-rename on conflict' },
|
||||
|
||||
@@ -76,6 +76,8 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Gmail Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
serviceId: 'gmail',
|
||||
requiredScopes: [
|
||||
'https://www.googleapis.com/auth/gmail.send',
|
||||
@@ -85,6 +87,15 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
|
||||
placeholder: 'Select Gmail account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Gmail Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Send Email Fields
|
||||
{
|
||||
id: 'to',
|
||||
@@ -406,7 +417,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
tool: selectGmailToolId,
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
folder,
|
||||
addLabelIds,
|
||||
removeLabelIds,
|
||||
@@ -467,7 +478,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
|
||||
return {
|
||||
...rest,
|
||||
credential,
|
||||
oauthCredential,
|
||||
...(normalizedAttachments && { attachments: normalizedAttachments }),
|
||||
}
|
||||
},
|
||||
@@ -475,7 +486,7 @@ Return ONLY the search query - no explanations, no extra text.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Gmail access token' },
|
||||
oauthCredential: { type: 'string', description: 'Gmail access token' },
|
||||
// Send operation inputs
|
||||
to: { type: 'string', description: 'Recipient email address' },
|
||||
subject: { type: 'string', description: 'Email subject' },
|
||||
|
||||
@@ -39,11 +39,22 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Google Calendar Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-calendar',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
|
||||
placeholder: 'Select Google Calendar account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Calendar Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Calendar selector (basic mode) - not needed for list_calendars
|
||||
{
|
||||
id: 'calendarId',
|
||||
@@ -512,7 +523,7 @@ Return ONLY the natural language event text - no explanations.`,
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
operation,
|
||||
attendees,
|
||||
replaceExisting,
|
||||
@@ -576,7 +587,7 @@ Return ONLY the natural language event text - no explanations.`,
|
||||
}
|
||||
|
||||
return {
|
||||
credential,
|
||||
oauthCredential,
|
||||
...processedParams,
|
||||
}
|
||||
},
|
||||
@@ -584,7 +595,7 @@ Return ONLY the natural language event text - no explanations.`,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Calendar access token' },
|
||||
oauthCredential: { type: 'string', description: 'Google Calendar access token' },
|
||||
calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' },
|
||||
|
||||
// Create/Update operation inputs
|
||||
|
||||
@@ -32,6 +32,8 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Google Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-docs',
|
||||
requiredScopes: [
|
||||
@@ -40,6 +42,15 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Document selector (basic mode)
|
||||
{
|
||||
id: 'documentId',
|
||||
@@ -157,7 +168,7 @@ Return ONLY the document content - no explanations, no extra text.`,
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, documentId, folderId, ...rest } = params
|
||||
const { oauthCredential, documentId, folderId, ...rest } = params
|
||||
|
||||
const effectiveDocumentId = documentId ? String(documentId).trim() : ''
|
||||
const effectiveFolderId = folderId ? String(folderId).trim() : ''
|
||||
@@ -166,14 +177,14 @@ Return ONLY the document content - no explanations, no extra text.`,
|
||||
...rest,
|
||||
documentId: effectiveDocumentId || undefined,
|
||||
folderId: effectiveFolderId || undefined,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Docs access token' },
|
||||
oauthCredential: { type: 'string', description: 'Google Docs access token' },
|
||||
documentId: { type: 'string', description: 'Document identifier (canonical param)' },
|
||||
title: { type: 'string', description: 'Document title' },
|
||||
folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' },
|
||||
|
||||
@@ -44,6 +44,8 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Google Drive Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-drive',
|
||||
requiredScopes: [
|
||||
@@ -52,6 +54,15 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
|
||||
],
|
||||
placeholder: 'Select Google Drive account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Drive Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Create/Upload File Fields
|
||||
{
|
||||
id: 'fileName',
|
||||
@@ -786,7 +797,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
// Folder canonical params (per-operation)
|
||||
uploadFolderId,
|
||||
createFolderParentId,
|
||||
@@ -873,7 +884,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
|
||||
sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined
|
||||
|
||||
return {
|
||||
credential,
|
||||
oauthCredential,
|
||||
folderId: effectiveFolderId,
|
||||
fileId: effectiveFileId,
|
||||
destinationFolderId: effectiveDestinationFolderId,
|
||||
@@ -891,7 +902,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Drive access token' },
|
||||
oauthCredential: { type: 'string', description: 'Google Drive access token' },
|
||||
// Folder canonical params (per-operation)
|
||||
uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' },
|
||||
createFolderParentId: { type: 'string', description: 'Parent folder for create folder' },
|
||||
|
||||
@@ -34,6 +34,8 @@ export const GoogleFormsBlock: BlockConfig = {
|
||||
id: 'credential',
|
||||
title: 'Google Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-forms',
|
||||
requiredScopes: [
|
||||
@@ -45,6 +47,15 @@ export const GoogleFormsBlock: BlockConfig = {
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Form selector (basic mode)
|
||||
{
|
||||
id: 'formSelector',
|
||||
@@ -233,7 +244,7 @@ Example for "Add a required multiple choice question about favorite color":
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
operation,
|
||||
formId, // Canonical param from formSelector (basic) or manualFormId (advanced)
|
||||
responseId,
|
||||
@@ -251,7 +262,7 @@ Example for "Add a required multiple choice question about favorite color":
|
||||
...rest
|
||||
} = params
|
||||
|
||||
const baseParams = { ...rest, credential }
|
||||
const baseParams = { ...rest, oauthCredential }
|
||||
const effectiveFormId = formId ? String(formId).trim() : undefined
|
||||
|
||||
switch (operation) {
|
||||
@@ -309,7 +320,7 @@ Example for "Add a required multiple choice question about favorite color":
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google OAuth credential' },
|
||||
oauthCredential: { type: 'string', description: 'Google OAuth credential' },
|
||||
formId: { type: 'string', description: 'Google Form ID' },
|
||||
responseId: { type: 'string', description: 'Specific response ID' },
|
||||
pageSize: { type: 'string', description: 'Max responses to retrieve' },
|
||||
|
||||
@@ -42,6 +42,8 @@ export const GoogleGroupsBlock: BlockConfig = {
|
||||
id: 'credential',
|
||||
title: 'Google Groups Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-groups',
|
||||
requiredScopes: [
|
||||
@@ -50,6 +52,15 @@ export const GoogleGroupsBlock: BlockConfig = {
|
||||
],
|
||||
placeholder: 'Select Google Workspace account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Groups Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'customer',
|
||||
@@ -311,12 +322,12 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, operation, ...rest } = params
|
||||
const { oauthCredential, operation, ...rest } = params
|
||||
|
||||
switch (operation) {
|
||||
case 'list_groups':
|
||||
return {
|
||||
credential,
|
||||
oauthCredential,
|
||||
customer: rest.customer,
|
||||
domain: rest.domain,
|
||||
query: rest.query,
|
||||
@@ -325,19 +336,19 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
case 'get_group':
|
||||
case 'delete_group':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
}
|
||||
case 'create_group':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
email: rest.email,
|
||||
name: rest.name,
|
||||
description: rest.description,
|
||||
}
|
||||
case 'update_group':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
name: rest.newName,
|
||||
email: rest.newEmail,
|
||||
@@ -345,7 +356,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
}
|
||||
case 'list_members':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
maxResults: rest.maxResults ? Number(rest.maxResults) : undefined,
|
||||
roles: rest.roles,
|
||||
@@ -353,66 +364,66 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
case 'get_member':
|
||||
case 'remove_member':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
memberKey: rest.memberKey,
|
||||
}
|
||||
case 'add_member':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
email: rest.memberEmail,
|
||||
role: rest.role,
|
||||
}
|
||||
case 'update_member':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
memberKey: rest.memberKey,
|
||||
role: rest.role,
|
||||
}
|
||||
case 'has_member':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
memberKey: rest.memberKey,
|
||||
}
|
||||
case 'list_aliases':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
}
|
||||
case 'add_alias':
|
||||
return {
|
||||
credential,
|
||||
credential: oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
alias: rest.alias,
|
||||
}
|
||||
case 'remove_alias':
|
||||
return {
|
||||
credential,
|
||||
oauthCredential,
|
||||
groupKey: rest.groupKey,
|
||||
alias: rest.alias,
|
||||
}
|
||||
case 'get_settings':
|
||||
return {
|
||||
credential,
|
||||
oauthCredential,
|
||||
groupEmail: rest.groupEmail,
|
||||
}
|
||||
case 'update_settings':
|
||||
return {
|
||||
credential,
|
||||
oauthCredential,
|
||||
groupEmail: rest.groupEmail,
|
||||
}
|
||||
default:
|
||||
return { credential, ...rest }
|
||||
return { oauthCredential, ...rest }
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Workspace OAuth credential' },
|
||||
oauthCredential: { type: 'string', description: 'Google Workspace OAuth credential' },
|
||||
customer: { type: 'string', description: 'Customer ID for listing groups' },
|
||||
domain: { type: 'string', description: 'Domain filter for listing groups' },
|
||||
query: { type: 'string', description: 'Search query for filtering groups' },
|
||||
|
||||
@@ -36,6 +36,8 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Google Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-sheets',
|
||||
requiredScopes: [
|
||||
@@ -44,6 +46,15 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Spreadsheet Selector
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
@@ -246,7 +257,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, values, spreadsheetId, ...rest } = params
|
||||
const { oauthCredential, values, spreadsheetId, ...rest } = params
|
||||
|
||||
const parsedValues = values ? JSON.parse(values as string) : undefined
|
||||
|
||||
@@ -260,14 +271,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
...rest,
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
values: parsedValues,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Sheets access token' },
|
||||
oauthCredential: { type: 'string', description: 'Google Sheets access token' },
|
||||
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
|
||||
range: { type: 'string', description: 'Cell range' },
|
||||
values: { type: 'string', description: 'Cell values data' },
|
||||
@@ -323,6 +334,8 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
|
||||
id: 'credential',
|
||||
title: 'Google Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-sheets',
|
||||
requiredScopes: [
|
||||
@@ -331,6 +344,15 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Spreadsheet Selector (basic mode) - not for create operation
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
@@ -715,7 +737,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
}),
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
values,
|
||||
spreadsheetId,
|
||||
sheetName,
|
||||
@@ -739,7 +761,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
return {
|
||||
title: (title as string)?.trim(),
|
||||
sheetTitles: sheetTitlesArray,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,7 +775,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
if (operation === 'get_info') {
|
||||
return {
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,7 +785,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
return {
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
ranges: parsedRanges,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,7 +796,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
...rest,
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
data: parsedData,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,7 +806,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
return {
|
||||
spreadsheetId: effectiveSpreadsheetId,
|
||||
ranges: parsedRanges,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,7 +816,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
sourceSpreadsheetId: effectiveSpreadsheetId,
|
||||
sheetId: Number.parseInt(sheetId as string, 10),
|
||||
destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(),
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,14 +835,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
|
||||
sheetName: effectiveSheetName,
|
||||
cellRange: cellRange ? (cellRange as string).trim() : undefined,
|
||||
values: parsedValues,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Sheets access token' },
|
||||
oauthCredential: { type: 'string', description: 'Google Sheets access token' },
|
||||
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
|
||||
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
|
||||
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },
|
||||
|
||||
@@ -46,6 +46,8 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
|
||||
id: 'credential',
|
||||
title: 'Google Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-drive',
|
||||
requiredScopes: [
|
||||
@@ -54,6 +56,15 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
|
||||
],
|
||||
placeholder: 'Select Google account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Presentation selector (basic mode) - for operations that need an existing presentation
|
||||
{
|
||||
id: 'presentationId',
|
||||
@@ -662,7 +673,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
|
||||
},
|
||||
params: (params) => {
|
||||
const {
|
||||
credential,
|
||||
oauthCredential,
|
||||
presentationId,
|
||||
folderId,
|
||||
slideIndex,
|
||||
@@ -679,7 +690,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
|
||||
const result: Record<string, any> = {
|
||||
...rest,
|
||||
presentationId: effectivePresentationId || undefined,
|
||||
credential,
|
||||
oauthCredential,
|
||||
}
|
||||
|
||||
// Handle operation-specific params
|
||||
@@ -799,7 +810,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Slides access token' },
|
||||
oauthCredential: { type: 'string', description: 'Google Slides access token' },
|
||||
presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' },
|
||||
// Write operation
|
||||
slideIndex: { type: 'number', description: 'Slide index to write to' },
|
||||
|
||||
@@ -34,6 +34,8 @@ export const GoogleVaultBlock: BlockConfig = {
|
||||
id: 'credential',
|
||||
title: 'Google Vault Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-vault',
|
||||
requiredScopes: [
|
||||
@@ -42,6 +44,15 @@ export const GoogleVaultBlock: BlockConfig = {
|
||||
],
|
||||
placeholder: 'Select Google Vault account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Vault Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
// Create Hold inputs
|
||||
{
|
||||
id: 'matterId',
|
||||
@@ -438,10 +449,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, holdStartTime, holdEndTime, holdTerms, ...rest } = params
|
||||
const { oauthCredential, holdStartTime, holdEndTime, holdTerms, ...rest } = params
|
||||
return {
|
||||
...rest,
|
||||
credential,
|
||||
oauthCredential,
|
||||
// Map hold-specific fields to their tool parameter names
|
||||
...(holdStartTime && { startTime: holdStartTime }),
|
||||
...(holdEndTime && { endTime: holdEndTime }),
|
||||
@@ -453,7 +464,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
|
||||
inputs: {
|
||||
// Core inputs
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Google Vault OAuth credential' },
|
||||
oauthCredential: { type: 'string', description: 'Google Vault OAuth credential' },
|
||||
matterId: { type: 'string', description: 'Matter ID' },
|
||||
|
||||
// Create export inputs
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user