diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 1d50d0d068..dcd5741f2b 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -710,6 +710,17 @@ export function NotionIcon(props: SVGProps) { ) } +export function GongIcon(props: SVGProps) { + return ( + + + + ) +} + export function GmailIcon(props: SVGProps) { return ( ) { ) } +export function AttioIcon(props: SVGProps) { + return ( + + + + + ) +} + export function AsanaIcon(props: SVGProps) { return ( @@ -5824,7 +5844,7 @@ export function HexIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 5929ccca3d..5121253240 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -13,6 +13,7 @@ import { ApolloIcon, ArxivIcon, AsanaIcon, + AttioIcon, BrainIcon, BrowserUseIcon, CalComIcon, @@ -40,6 +41,7 @@ import { GithubIcon, GitLabIcon, GmailIcon, + GongIcon, GoogleBooksIcon, GoogleCalendarIcon, GoogleDocsIcon, @@ -158,6 +160,7 @@ export const blockTypeToIconMap: Record = { apollo: ApolloIcon, arxiv: ArxivIcon, asana: AsanaIcon, + attio: AttioIcon, browser_use: BrowserUseIcon, calcom: CalComIcon, calendly: CalendlyIcon, @@ -183,6 +186,7 @@ export const blockTypeToIconMap: Record = { github_v2: GithubIcon, gitlab: GitLabIcon, gmail_v2: GmailIcon, + gong: GongIcon, google_books: GoogleBooksIcon, google_calendar_v2: GoogleCalendarIcon, google_docs: GoogleDocsIcon, diff --git a/apps/docs/content/docs/de/variables/environment-variables.mdx b/apps/docs/content/docs/de/variables/environment-variables.mdx deleted file mode 100644 index 523c85d967..0000000000 --- a/apps/docs/content/docs/de/variables/environment-variables.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Umgebungsvariablen ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Image } from '@/components/ui/image' - -Umgebungsvariablen bieten eine sichere Möglichkeit, Konfigurationswerte und Geheimnisse in Ihren Workflows zu verwalten, einschließlich API-Schlüssel und anderer sensibler Daten, auf die Ihre Workflows zugreifen müssen. Sie halten Geheimnisse aus Ihren Workflow-Definitionen heraus und machen sie während der Ausführung verfügbar. - -## Variablentypen - -Umgebungsvariablen in Sim funktionieren auf zwei Ebenen: - -- **Persönliche Umgebungsvariablen**: Privat für Ihr Konto, nur Sie können sie sehen und verwenden -- **Workspace-Umgebungsvariablen**: Werden im gesamten Workspace geteilt und sind für alle Teammitglieder verfügbar - - -Workspace-Umgebungsvariablen haben Vorrang vor persönlichen Variablen, wenn es einen Namenskonflikt gibt. - - -## Einrichten von Umgebungsvariablen - -Navigieren Sie zu den Einstellungen, um Ihre Umgebungsvariablen zu konfigurieren: - - - -In Ihren Workspace-Einstellungen können Sie sowohl persönliche als auch Workspace-Umgebungsvariablen erstellen und verwalten. Persönliche Variablen sind privat für Ihr Konto, während Workspace-Variablen mit allen Teammitgliedern geteilt werden. - -### Variablen auf Workspace-Ebene setzen - -Verwenden Sie den Workspace-Bereichsschalter, um Variablen für Ihr gesamtes Team verfügbar zu machen: - - - -Wenn Sie den Workspace-Bereich aktivieren, wird die Variable für alle Workspace-Mitglieder verfügbar und kann in jedem Workflow innerhalb dieses Workspaces verwendet werden. - -### Ansicht der Workspace-Variablen - -Sobald Sie Workspace-Variablen haben, erscheinen sie in Ihrer Liste der Umgebungsvariablen: - - - -## Verwendung von Variablen in Workflows - -Um Umgebungsvariablen in Ihren Workflows zu referenzieren, verwenden Sie die `{{}}` Notation. Wenn Sie `{{` in ein beliebiges Eingabefeld eingeben, erscheint ein Dropdown-Menü mit Ihren persönlichen und Workspace-Umgebungsvariablen. Wählen Sie einfach die Variable aus, die Sie verwenden möchten. - - - -## Wie Variablen aufgelöst werden - -**Workspace-Variablen haben immer Vorrang** vor persönlichen Variablen, unabhängig davon, wer den Workflow ausführt. - -Wenn keine Workspace-Variable für einen Schlüssel existiert, werden persönliche Variablen verwendet: -- **Manuelle Ausführungen (UI)**: Ihre persönlichen Variablen -- **Automatisierte Ausführungen (API, Webhook, Zeitplan, bereitgestellter Chat)**: Persönliche Variablen des Workflow-Besitzers - - -Persönliche Variablen eignen sich am besten zum Testen. Verwenden Sie Workspace-Variablen für Produktions-Workflows. - - -## Sicherheits-Best-Practices - -### Für sensible Daten -- Speichern Sie API-Schlüssel, Tokens und Passwörter als Umgebungsvariablen anstatt sie im Code festzuschreiben -- Verwenden Sie Workspace-Variablen für gemeinsam genutzte Ressourcen, die mehrere Teammitglieder benötigen -- Bewahren Sie persönliche Anmeldedaten in persönlichen Variablen auf - -### Variablenbenennung -- Verwenden Sie beschreibende Namen: `DATABASE_URL` anstatt `DB` -- Folgen Sie einheitlichen Benennungskonventionen in Ihrem Team -- Erwägen Sie Präfixe, um Konflikte zu vermeiden: `PROD_API_KEY`, `DEV_API_KEY` - -### Zugriffskontrolle -- Workspace-Umgebungsvariablen respektieren Workspace-Berechtigungen -- Nur Benutzer mit Schreibzugriff oder höher können Workspace-Variablen erstellen/ändern -- Persönliche Variablen sind immer privat für den einzelnen Benutzer \ No newline at end of file diff --git a/apps/docs/content/docs/en/blocks/api.mdx b/apps/docs/content/docs/en/blocks/api.mdx index 6bf1d05226..5cdab33785 100644 --- a/apps/docs/content/docs/en/blocks/api.mdx +++ b/apps/docs/content/docs/en/blocks/api.mdx @@ -95,11 +95,17 @@ const apiUrl = `https://api.example.com/users/${userId}/profile`; ### Request Retries -The API block automatically handles: -- Network timeouts with exponential backoff -- Rate limit responses (429 status codes) -- Server errors (5xx status codes) with retry logic -- Connection failures with reconnection attempts +The API block supports **configurable retries** (see the block’s **Advanced** settings): + +- **Retries**: Number of retry attempts (additional tries after the first request) +- **Retry delay (ms)**: Initial delay before retrying (uses exponential backoff) +- **Max retry delay (ms)**: Maximum delay between retries +- **Retry non-idempotent methods**: Allow retries for **POST/PATCH** (may create duplicate requests) + +Retries are attempted for: + +- Network/connection failures and timeouts (with exponential backoff) +- Rate limits (**429**) and server errors (**5xx**) ### Response Validation diff --git a/apps/docs/content/docs/en/credentials/index.mdx b/apps/docs/content/docs/en/credentials/index.mdx new file mode 100644 index 0000000000..c0c47b04aa --- /dev/null +++ b/apps/docs/content/docs/en/credentials/index.mdx @@ -0,0 +1,192 @@ +--- +title: Credentials +description: Manage secrets, API keys, and OAuth connections for your workflows +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Image } from '@/components/ui/image' +import { Step, Steps } from 'fumadocs-ui/components/steps' + +Credentials provide a secure way to manage API keys, tokens, and third-party service connections across your workflows. Instead of hardcoding sensitive values into your workflow, you store them as credentials and reference them at runtime. + +Sim supports two categories of credentials: **secrets** for static values like API keys, and **OAuth accounts** for authenticated service connections like Google or Slack. + +## Getting Started + +To manage credentials, open your workspace **Settings** and navigate to the **Secrets** tab. + + + +From here you can search, create, and delete both secrets and OAuth connections. + +## Secrets + +Secrets are key-value pairs that store sensitive data like API keys, tokens, and passwords. Each secret has a **key** (used to reference it in workflows) and a **value** (the actual secret). + +### Creating a Secret + + + + + + Click **+ Add** and select **Secret** as the type + + + Enter a **Key** name (letters, numbers, and underscores only, e.g. `OPENAI_API_KEY`) + + + Enter the **Value** + + + Optionally add a **Description** to help your team understand what the secret is for + + + Choose the **Scope** — Workspace or Personal + + + Click **Create** + + + +### Using Secrets in Workflows + +To reference a secret in any input field, type `{{` to open the dropdown. It will show your available secrets grouped by scope. + + + +Select the secret you want to use. The reference will appear highlighted in blue, indicating it will be resolved at runtime. + + + + +Secret values are never exposed in the workflow editor or logs. They are only resolved during execution. + + +### Bulk Import + +You can import multiple secrets at once by pasting `.env`-style content: + +1. Click **+ Add**, then switch to **Bulk** mode +2. Paste your environment variables in `KEY=VALUE` format +3. Choose the scope for all imported secrets +4. Click **Create** + +The parser supports standard `KEY=VALUE` pairs, quoted values, comments (`#`), and blank lines. + +## OAuth Accounts + +OAuth accounts are authenticated connections to third-party services like Google, Slack, GitHub, and more. Sim handles the OAuth flow, token storage, and automatic refresh. + +You can connect **multiple accounts per provider** — for example, two separate Gmail accounts for different workflows. + +### Connecting an OAuth Account + + + + + + Click **+ Add** and select **OAuth Account** as the type + + + Enter a **Display name** to identify this connection (e.g. "Work Gmail" or "Marketing Slack") + + + Optionally add a **Description** + + + Select the **Account** provider from the dropdown + + + Click **Connect** and complete the authorization flow + + + +### Using OAuth Accounts in Workflows + +Blocks that require authentication (e.g. Gmail, Slack, Google Sheets) display a credential selector dropdown. Select the OAuth account you want the block to use. + + + +You can also connect additional accounts directly from the block by selecting **Connect another account** at the bottom of the dropdown. + + +If a block requires an OAuth connection and none is selected, the workflow will fail at that step. + + +## Workspace vs. Personal + +Credentials can be scoped to your **workspace** (shared with your team) or kept **personal** (private to you). + +| | Workspace | Personal | +|---|---|---| +| **Visibility** | All workspace members | Only you | +| **Use in workflows** | Any member can use | Only you can use | +| **Best for** | Production workflows, shared services | Testing, personal API keys | +| **Who can edit** | Workspace admins | Only you | +| **Auto-shared** | Yes — all members get access on creation | No — only you have access | + + +When a workspace and personal secret share the same key name, the **workspace secret takes precedence**. + + +### Resolution Order + +When a workflow runs, Sim resolves secrets in this order: + +1. **Workspace secrets** are checked first +2. **Personal secrets** are used as a fallback — from the user who triggered the run (manual) or the workflow owner (automated runs via API, webhook, or schedule) + +## Access Control + +Each credential has role-based access control: + +- **Admin** — can view, edit, delete, and manage who has access +- **Member** — can use the credential in workflows (read-only) + +When you create a workspace secret, all current workspace members are automatically granted access. Personal secrets are only accessible to you by default. + +### Sharing a Credential + +To share a credential with specific team members: + +1. Click **Details** on the credential +2. Invite members by email +3. Assign them an **Admin** or **Member** role + +## Best Practices + +- **Use workspace credentials for production** so workflows work regardless of who triggers them +- **Use personal credentials for development** to keep your test keys separate +- **Name keys descriptively** — `STRIPE_SECRET_KEY` over `KEY1` +- **Connect multiple OAuth accounts** when you need different permissions or identities per workflow +- **Never hardcode secrets** in workflow input fields — always use `{{KEY}}` references diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 2f38998574..05a5e43f4b 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -13,6 +13,7 @@ "skills", "knowledgebase", "variables", + "credentials", "execution", "permissions", "sdks", diff --git a/apps/docs/content/docs/en/tools/attio.mdx b/apps/docs/content/docs/en/tools/attio.mdx new file mode 100644 index 0000000000..85ad63126b --- /dev/null +++ b/apps/docs/content/docs/en/tools/attio.mdx @@ -0,0 +1,1046 @@ +--- +title: Attio +description: Manage records, notes, tasks, lists, comments, and more in Attio CRM +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Attio](https://www.attio.com/) is a modern and flexible CRM platform built to help teams manage relationships, data, and workflows more efficiently. Attio enables organizations to create and organize custom objects (like people, companies, deals, and more), manage notes and tasks, collaborate as a team, and automate work across their relationship and data pipelines. + +With Attio, you can: + +- **Manage records for any object**: Store and organize people, companies, or any custom objects to fit your team's needs. +- **Track, connect, and update information**: Add and edit notes, comments, tasks, and linked records so your contextual data is always in sync. +- **Build and customize lists**: Segment records, filter and sort with powerful queries, and build views that fit your workflow. +- **Collaborate with your team**: Assign tasks, share comments, and see activities in real time. +- **Automate CRM workflows**: Create, update, and read record data via API to keep your tools and teams in the loop, or trigger actions as relationships evolve. + +In Sim, the Attio integration lets your agents programmatically query lists, fetch and manipulate records, manage entries, tasks, comments, and more—making it easy to automate CRM operations, enrich data, synchronize with other systems, or trigger workflow automations based on relationship or record events. Use these tools to ensure your CRM stays up-to-date and powerful directly from within your Sim projects. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Connect to Attio to manage CRM records (people, companies, custom objects), notes, tasks, lists, list entries, comments, workspace members, and webhooks. + + + +## Tools + +### `attio_list_records` + +Query and list records for a given object type (e.g. people, companies) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The object type slug \(e.g. people, companies\) | +| `filter` | string | No | JSON filter object for querying records | +| `sorts` | string | No | JSON array of sort objects, e.g. \[\{"direction":"asc","attribute":"name"\}\] | +| `limit` | number | No | Maximum number of records to return \(default 500\) | +| `offset` | number | No | Number of records to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `records` | array | Array of Attio records | +| ↳ `id` | object | The record identifier | +| ↳ `workspace_id` | string | The workspace ID | +| ↳ `object_id` | string | The object ID | +| ↳ `record_id` | string | The record ID | +| ↳ `created_at` | string | When the record was created | +| ↳ `web_url` | string | URL to view the record in Attio | +| ↳ `values` | json | The record attribute values | +| `count` | number | Number of records returned | + +### `attio_get_record` + +Get a single record by ID from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The object type slug \(e.g. people, companies\) | +| `recordId` | string | Yes | The ID of the record to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | object | An Attio record | +| ↳ `id` | object | The record identifier | +| ↳ `workspace_id` | string | The workspace ID | +| ↳ `object_id` | string | The object ID | +| ↳ `record_id` | string | The record ID | +| ↳ `created_at` | string | When the record was created | +| ↳ `web_url` | string | URL to view the record in Attio | +| ↳ `values` | json | The record attribute values | +| `recordId` | string | The record ID | +| `webUrl` | string | URL to view the record in Attio | + +### `attio_create_record` + +Create a new record in Attio for a given object type + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The object type slug \(e.g. people, companies\) | +| `values` | string | Yes | JSON object of attribute values to set on the record | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | object | An Attio record | +| ↳ `id` | object | The record identifier | +| ↳ `workspace_id` | string | The workspace ID | +| ↳ `object_id` | string | The object ID | +| ↳ `record_id` | string | The record ID | +| ↳ `created_at` | string | When the record was created | +| ↳ `web_url` | string | URL to view the record in Attio | +| ↳ `values` | json | The record attribute values | +| `recordId` | string | The ID of the created record | +| `webUrl` | string | URL to view the record in Attio | + +### `attio_update_record` + +Update an existing record in Attio (appends multiselect values) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The object type slug \(e.g. people, companies\) | +| `recordId` | string | Yes | The ID of the record to update | +| `values` | string | Yes | JSON object of attribute values to update | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | object | An Attio record | +| ↳ `id` | object | The record identifier | +| ↳ `workspace_id` | string | The workspace ID | +| ↳ `object_id` | string | The object ID | +| ↳ `record_id` | string | The record ID | +| ↳ `created_at` | string | When the record was created | +| ↳ `web_url` | string | URL to view the record in Attio | +| ↳ `values` | json | The record attribute values | +| `recordId` | string | The ID of the updated record | +| `webUrl` | string | URL to view the record in Attio | + +### `attio_delete_record` + +Delete a record from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The object type slug \(e.g. people, companies\) | +| `recordId` | string | Yes | The ID of the record to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the record was deleted | + +### `attio_search_records` + +Fuzzy search for records across object types in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | The search query \(max 256 characters\) | +| `objects` | string | Yes | Comma-separated object slugs to search \(e.g. people,companies\) | +| `limit` | number | No | Maximum number of results \(1-25, default 25\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Search results | +| ↳ `recordId` | string | The record ID | +| ↳ `objectId` | string | The object type ID | +| ↳ `objectSlug` | string | The object type slug | +| ↳ `recordText` | string | Display text for the record | +| ↳ `recordImage` | string | Image URL for the record | +| `count` | number | Number of results returned | + +### `attio_assert_record` + +Upsert a record in Attio — creates it if no match is found, updates it if a match exists + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objectType` | string | Yes | The object type slug \(e.g. people, companies\) | +| `matchingAttribute` | string | Yes | The attribute slug to match on for upsert \(e.g. email_addresses for people, domains for companies\) | +| `values` | string | Yes | JSON object of attribute values \(e.g. \{"email_addresses":\[\{"email_address":"test@example.com"\}\]\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | object | The upserted record | +| ↳ `id` | object | The record identifier | +| ↳ `workspace_id` | string | The workspace ID | +| ↳ `object_id` | string | The object ID | +| ↳ `record_id` | string | The record ID | +| ↳ `created_at` | string | When the record was created | +| ↳ `web_url` | string | URL to view the record in Attio | +| ↳ `values` | json | The record attribute values | +| `recordId` | string | The record ID | +| `webUrl` | string | URL to view the record in Attio | + +### `attio_list_notes` + +List notes in Attio, optionally filtered by parent record + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `parentObject` | string | No | Object type slug to filter notes by \(e.g. people, companies\) | +| `parentRecordId` | string | No | Record ID to filter notes by | +| `limit` | number | No | Maximum number of notes to return \(default 10, max 50\) | +| `offset` | number | No | Number of notes to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | array | Array of notes | +| ↳ `noteId` | string | The note ID | +| ↳ `parentObject` | string | The parent object slug | +| ↳ `parentRecordId` | string | The parent record ID | +| ↳ `title` | string | The note title | +| ↳ `contentPlaintext` | string | The note content as plaintext | +| ↳ `contentMarkdown` | string | The note content as markdown | +| ↳ `meetingId` | string | The linked meeting ID | +| ↳ `tags` | array | Tags on the note | +| ↳ `type` | string | The tag type \(e.g. workspace-member\) | +| ↳ `workspaceMemberId` | string | The workspace member ID of the tagger | +| ↳ `createdByActor` | object | The actor who created the note | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| ↳ `createdAt` | string | When the note was created | +| `count` | number | Number of notes returned | + +### `attio_get_note` + +Get a single note by ID from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `noteId` | string | Yes | The ID of the note to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `noteId` | string | The note ID | +| `parentObject` | string | The parent object slug | +| `parentRecordId` | string | The parent record ID | +| `title` | string | The note title | +| `contentPlaintext` | string | The note content as plaintext | +| `contentMarkdown` | string | The note content as markdown | +| `meetingId` | string | The linked meeting ID | +| `tags` | array | Tags on the note | +| ↳ `type` | string | The tag type \(e.g. workspace-member\) | +| ↳ `workspaceMemberId` | string | The workspace member ID of the tagger | +| `createdByActor` | object | The actor who created the note | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the note was created | + +### `attio_create_note` + +Create a note on a record in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `parentObject` | string | Yes | The parent object type slug \(e.g. people, companies\) | +| `parentRecordId` | string | Yes | The parent record ID to attach the note to | +| `title` | string | Yes | The note title | +| `content` | string | Yes | The note content | +| `format` | string | No | Content format: plaintext or markdown \(default plaintext\) | +| `createdAt` | string | No | Backdate the note creation time \(ISO 8601 format\) | +| `meetingId` | string | No | Associate the note with a meeting ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `noteId` | string | The note ID | +| `parentObject` | string | The parent object slug | +| `parentRecordId` | string | The parent record ID | +| `title` | string | The note title | +| `contentPlaintext` | string | The note content as plaintext | +| `contentMarkdown` | string | The note content as markdown | +| `meetingId` | string | The linked meeting ID | +| `tags` | array | Tags on the note | +| ↳ `type` | string | The tag type \(e.g. workspace-member\) | +| ↳ `workspaceMemberId` | string | The workspace member ID of the tagger | +| `createdByActor` | object | The actor who created the note | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the note was created | + +### `attio_delete_note` + +Delete a note from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `noteId` | string | Yes | The ID of the note to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the note was deleted | + +### `attio_list_tasks` + +List tasks in Attio, optionally filtered by record, assignee, or completion status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `linkedObject` | string | No | Object type slug to filter tasks by \(requires linkedRecordId\) | +| `linkedRecordId` | string | No | Record ID to filter tasks by \(requires linkedObject\) | +| `assignee` | string | No | Assignee email or member ID to filter by | +| `isCompleted` | boolean | No | Filter by completion status | +| `sort` | string | No | Sort order: created_at:asc or created_at:desc | +| `limit` | number | No | Maximum number of tasks to return \(default 500\) | +| `offset` | number | No | Number of tasks to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tasks` | array | Array of tasks | +| ↳ `taskId` | string | The task ID | +| ↳ `content` | string | The task content | +| ↳ `deadlineAt` | string | The task deadline | +| ↳ `isCompleted` | boolean | Whether the task is completed | +| ↳ `linkedRecords` | array | Records linked to this task | +| ↳ `targetObjectId` | string | The linked object ID | +| ↳ `targetRecordId` | string | The linked record ID | +| ↳ `assignees` | array | Task assignees | +| ↳ `type` | string | The assignee actor type \(e.g. workspace-member\) | +| ↳ `id` | string | The assignee actor ID | +| ↳ `createdByActor` | object | The actor who created this task | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| ↳ `createdAt` | string | When the task was created | +| `count` | number | Number of tasks returned | + +### `attio_create_task` + +Create a task in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `content` | string | Yes | The task content \(max 2000 characters\) | +| `deadlineAt` | string | No | Deadline in ISO 8601 format \(e.g. 2024-12-01T15:00:00.000Z\) | +| `isCompleted` | boolean | No | Whether the task is completed \(default false\) | +| `linkedRecords` | string | No | JSON array of linked records \(e.g. \[\{"target_object":"people","target_record_id":"..."\}\]\) | +| `assignees` | string | No | JSON array of assignees \(e.g. \[\{"referenced_actor_type":"workspace-member","referenced_actor_id":"..."\}\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | The task ID | +| `content` | string | The task content | +| `deadlineAt` | string | The task deadline | +| `isCompleted` | boolean | Whether the task is completed | +| `linkedRecords` | array | Records linked to this task | +| ↳ `targetObjectId` | string | The linked object ID | +| ↳ `targetRecordId` | string | The linked record ID | +| `assignees` | array | Task assignees | +| ↳ `type` | string | The assignee actor type \(e.g. workspace-member\) | +| ↳ `id` | string | The assignee actor ID | +| `createdByActor` | object | The actor who created this task | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the task was created | + +### `attio_update_task` + +Update a task in Attio (deadline, completion status, linked records, assignees) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | The ID of the task to update | +| `deadlineAt` | string | No | New deadline in ISO 8601 format | +| `isCompleted` | boolean | No | Whether the task is completed | +| `linkedRecords` | string | No | JSON array of linked records | +| `assignees` | string | No | JSON array of assignees | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `taskId` | string | The task ID | +| `content` | string | The task content | +| `deadlineAt` | string | The task deadline | +| `isCompleted` | boolean | Whether the task is completed | +| `linkedRecords` | array | Records linked to this task | +| ↳ `targetObjectId` | string | The linked object ID | +| ↳ `targetRecordId` | string | The linked record ID | +| `assignees` | array | Task assignees | +| ↳ `type` | string | The assignee actor type \(e.g. workspace-member\) | +| ↳ `id` | string | The assignee actor ID | +| `createdByActor` | object | The actor who created this task | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the task was created | + +### `attio_delete_task` + +Delete a task from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `taskId` | string | Yes | The ID of the task to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the task was deleted | + +### `attio_list_objects` + +List all objects (system and custom) in the Attio workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `objects` | array | Array of objects | +| ↳ `objectId` | string | The object ID | +| ↳ `apiSlug` | string | The API slug \(e.g. people, companies\) | +| ↳ `singularNoun` | string | Singular display name | +| ↳ `pluralNoun` | string | Plural display name | +| ↳ `createdAt` | string | When the object was created | +| `count` | number | Number of objects returned | + +### `attio_get_object` + +Get a single object by ID or slug + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `object` | string | Yes | The object ID or slug \(e.g. people, companies\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `objectId` | string | The object ID | +| `apiSlug` | string | The API slug \(e.g. people, companies\) | +| `singularNoun` | string | Singular display name | +| `pluralNoun` | string | Plural display name | +| `createdAt` | string | When the object was created | + +### `attio_create_object` + +Create a custom object in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiSlug` | string | Yes | The API slug for the object \(e.g. projects\) | +| `singularNoun` | string | Yes | Singular display name \(e.g. Project\) | +| `pluralNoun` | string | Yes | Plural display name \(e.g. Projects\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `objectId` | string | The object ID | +| `apiSlug` | string | The API slug \(e.g. people, companies\) | +| `singularNoun` | string | Singular display name | +| `pluralNoun` | string | Plural display name | +| `createdAt` | string | When the object was created | + +### `attio_update_object` + +Update a custom object in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `object` | string | Yes | The object ID or slug to update | +| `apiSlug` | string | No | New API slug | +| `singularNoun` | string | No | New singular display name | +| `pluralNoun` | string | No | New plural display name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `objectId` | string | The object ID | +| `apiSlug` | string | The API slug \(e.g. people, companies\) | +| `singularNoun` | string | Singular display name | +| `pluralNoun` | string | Plural display name | +| `createdAt` | string | When the object was created | + +### `attio_list_lists` + +List all lists in the Attio workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `lists` | array | Array of lists | +| ↳ `listId` | string | The list ID | +| ↳ `apiSlug` | string | The API slug for the list | +| ↳ `name` | string | The list name | +| ↳ `parentObject` | string | The parent object slug \(e.g. people, companies\) | +| ↳ `workspaceAccess` | string | Workspace-level access \(e.g. full-access, read-only\) | +| ↳ `workspaceMemberAccess` | json | Member-level access entries | +| ↳ `createdByActor` | object | The actor who created the list | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| ↳ `createdAt` | string | When the list was created | +| `count` | number | Number of lists returned | + +### `attio_get_list` + +Get a single list by ID or slug + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `listId` | string | The list ID | +| `apiSlug` | string | The API slug for the list | +| `name` | string | The list name | +| `parentObject` | string | The parent object slug \(e.g. people, companies\) | +| `workspaceAccess` | string | Workspace-level access \(e.g. full-access, read-only\) | +| `workspaceMemberAccess` | json | Member-level access entries | +| `createdByActor` | object | The actor who created the list | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the list was created | + +### `attio_create_list` + +Create a new list in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | The list name | +| `apiSlug` | string | No | The API slug for the list \(auto-generated from name if omitted\) | +| `parentObject` | string | Yes | The parent object slug \(e.g. people, companies\) | +| `workspaceAccess` | string | No | Workspace-level access: full-access, read-and-write, or read-only \(omit for private\) | +| `workspaceMemberAccess` | string | No | JSON array of member access entries, e.g. \[\{"workspace_member_id":"...","level":"read-and-write"\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `listId` | string | The list ID | +| `apiSlug` | string | The API slug for the list | +| `name` | string | The list name | +| `parentObject` | string | The parent object slug \(e.g. people, companies\) | +| `workspaceAccess` | string | Workspace-level access \(e.g. full-access, read-only\) | +| `workspaceMemberAccess` | json | Member-level access entries | +| `createdByActor` | object | The actor who created the list | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the list was created | + +### `attio_update_list` + +Update a list in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug to update | +| `name` | string | No | New name for the list | +| `apiSlug` | string | No | New API slug for the list | +| `workspaceAccess` | string | No | New workspace-level access: full-access, read-and-write, or read-only \(omit for private\) | +| `workspaceMemberAccess` | string | No | JSON array of member access entries, e.g. \[\{"workspace_member_id":"...","level":"read-and-write"\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `listId` | string | The list ID | +| `apiSlug` | string | The API slug for the list | +| `name` | string | The list name | +| `parentObject` | string | The parent object slug \(e.g. people, companies\) | +| `workspaceAccess` | string | Workspace-level access \(e.g. full-access, read-only\) | +| `workspaceMemberAccess` | json | Member-level access entries | +| `createdByActor` | object | The actor who created the list | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the list was created | + +### `attio_query_list_entries` + +Query entries in an Attio list with optional filter, sort, and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug | +| `filter` | string | No | JSON filter object for querying entries | +| `sorts` | string | No | JSON array of sort objects \(e.g. \[\{"attribute":"created_at","direction":"desc"\}\]\) | +| `limit` | number | No | Maximum number of entries to return \(default 500\) | +| `offset` | number | No | Number of entries to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entries` | array | Array of list entries | +| ↳ `entryId` | string | The list entry ID | +| ↳ `listId` | string | The list ID | +| ↳ `parentRecordId` | string | The parent record ID | +| ↳ `parentObject` | string | The parent object slug | +| ↳ `createdAt` | string | When the entry was created | +| ↳ `entryValues` | json | The entry attribute values \(dynamic per list\) | +| `count` | number | Number of entries returned | + +### `attio_get_list_entry` + +Get a single list entry by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug | +| `entryId` | string | Yes | The entry ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entryId` | string | The list entry ID | +| `listId` | string | The list ID | +| `parentRecordId` | string | The parent record ID | +| `parentObject` | string | The parent object slug | +| `createdAt` | string | When the entry was created | +| `entryValues` | json | The entry attribute values \(dynamic per list\) | + +### `attio_create_list_entry` + +Add a record to an Attio list as a new entry + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug | +| `parentRecordId` | string | Yes | The record ID to add to the list | +| `parentObject` | string | Yes | The object type slug of the record \(e.g. people, companies\) | +| `entryValues` | string | No | JSON object of entry attribute values | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entryId` | string | The list entry ID | +| `listId` | string | The list ID | +| `parentRecordId` | string | The parent record ID | +| `parentObject` | string | The parent object slug | +| `createdAt` | string | When the entry was created | +| `entryValues` | json | The entry attribute values \(dynamic per list\) | + +### `attio_update_list_entry` + +Update entry attribute values on an Attio list entry (appends multiselect values) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug | +| `entryId` | string | Yes | The entry ID to update | +| `entryValues` | string | Yes | JSON object of entry attribute values to update | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entryId` | string | The list entry ID | +| `listId` | string | The list ID | +| `parentRecordId` | string | The parent record ID | +| `parentObject` | string | The parent object slug | +| `createdAt` | string | When the entry was created | +| `entryValues` | json | The entry attribute values \(dynamic per list\) | + +### `attio_delete_list_entry` + +Remove an entry from an Attio list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `list` | string | Yes | The list ID or slug | +| `entryId` | string | Yes | The entry ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the entry was deleted | + +### `attio_list_members` + +List all workspace members in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Array of workspace members | +| ↳ `memberId` | string | The workspace member ID | +| ↳ `firstName` | string | First name | +| ↳ `lastName` | string | Last name | +| ↳ `avatarUrl` | string | Avatar URL | +| ↳ `emailAddress` | string | Email address | +| ↳ `accessLevel` | string | Access level \(admin, member, suspended\) | +| ↳ `createdAt` | string | When the member was added | +| `count` | number | Number of members returned | + +### `attio_get_member` + +Get a single workspace member by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `memberId` | string | Yes | The workspace member ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberId` | string | The workspace member ID | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `avatarUrl` | string | Avatar URL | +| `emailAddress` | string | Email address | +| `accessLevel` | string | Access level \(admin, member, suspended\) | +| `createdAt` | string | When the member was added | + +### `attio_create_comment` + +Create a comment on a list entry in Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `content` | string | Yes | The comment content | +| `format` | string | No | Content format: plaintext or markdown \(default plaintext\) | +| `authorType` | string | Yes | Author type \(e.g. workspace-member\) | +| `authorId` | string | Yes | Author workspace member ID | +| `list` | string | Yes | The list ID or slug the entry belongs to | +| `entryId` | string | Yes | The entry ID to comment on | +| `threadId` | string | No | Thread ID to reply to \(omit to start a new thread\) | +| `createdAt` | string | No | Backdate the comment \(ISO 8601 format\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commentId` | string | The comment ID | +| `threadId` | string | The thread ID | +| `contentPlaintext` | string | The comment content as plaintext | +| `author` | object | The comment author | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `entry` | object | The list entry this comment is on | +| ↳ `listId` | string | The list ID | +| ↳ `entryId` | string | The entry ID | +| `record` | object | The record this comment is on | +| ↳ `objectId` | string | The object ID | +| ↳ `recordId` | string | The record ID | +| `resolvedAt` | string | When the thread was resolved | +| `resolvedBy` | object | Who resolved the thread | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the comment was created | + +### `attio_get_comment` + +Get a single comment by ID from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | The comment ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commentId` | string | The comment ID | +| `threadId` | string | The thread ID | +| `contentPlaintext` | string | The comment content as plaintext | +| `author` | object | The comment author | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `entry` | object | The list entry this comment is on | +| ↳ `listId` | string | The list ID | +| ↳ `entryId` | string | The entry ID | +| `record` | object | The record this comment is on | +| ↳ `objectId` | string | The object ID | +| ↳ `recordId` | string | The record ID | +| `resolvedAt` | string | When the thread was resolved | +| `resolvedBy` | object | Who resolved the thread | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the comment was created | + +### `attio_delete_comment` + +Delete a comment in Attio (if head of thread, deletes entire thread) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | The comment ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the comment was deleted | + +### `attio_list_threads` + +List comment threads in Attio, optionally filtered by record or list entry + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `recordId` | string | No | Filter by record ID \(requires object\) | +| `object` | string | No | Object slug to filter by \(requires recordId\) | +| `entryId` | string | No | Filter by list entry ID \(requires list\) | +| `list` | string | No | List ID or slug to filter by \(requires entryId\) | +| `limit` | number | No | Maximum number of threads to return \(max 50\) | +| `offset` | number | No | Number of threads to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `threads` | array | Array of threads | +| ↳ `threadId` | string | The thread ID | +| ↳ `comments` | array | Comments in the thread | +| ↳ `commentId` | string | The comment ID | +| ↳ `contentPlaintext` | string | Comment content | +| ↳ `author` | object | Comment author | +| ↳ `type` | string | Actor type | +| ↳ `id` | string | Actor ID | +| ↳ `createdAt` | string | When the comment was created | +| ↳ `createdAt` | string | When the thread was created | +| `count` | number | Number of threads returned | + +### `attio_get_thread` + +Get a single comment thread by ID from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `threadId` | string | Yes | The thread ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `threadId` | string | The thread ID | +| `comments` | array | Comments in the thread | +| ↳ `type` | string | The actor type \(e.g. workspace-member, api-token, system\) | +| ↳ `id` | string | The actor ID | +| `createdAt` | string | When the thread was created | + +### `attio_list_webhooks` + +List all webhooks in the Attio workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `limit` | number | No | Maximum number of webhooks to return | +| `offset` | number | No | Number of webhooks to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `webhooks` | array | Array of webhooks | +| ↳ `webhookId` | string | The webhook ID | +| ↳ `targetUrl` | string | The webhook target URL | +| ↳ `subscriptions` | array | Event subscriptions | +| ↳ `eventType` | string | The event type \(e.g. record.created\) | +| ↳ `filter` | json | Optional event filter | +| ↳ `status` | string | Webhook status \(active, degraded, inactive\) | +| ↳ `createdAt` | string | When the webhook was created | +| `count` | number | Number of webhooks returned | + +### `attio_get_webhook` + +Get a single webhook by ID from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `webhookId` | string | Yes | The webhook ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `webhookId` | string | The webhook ID | +| `targetUrl` | string | The webhook target URL | +| `subscriptions` | array | Event subscriptions | +| ↳ `eventType` | string | The event type \(e.g. record.created\) | +| ↳ `filter` | json | Optional event filter | +| `status` | string | Webhook status \(active, degraded, inactive\) | +| `createdAt` | string | When the webhook was created | + +### `attio_create_webhook` + +Create a webhook in Attio to receive event notifications + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `targetUrl` | string | Yes | The HTTPS URL to receive webhook events | +| `subscriptions` | string | Yes | JSON array of subscriptions \(e.g. \[\{"event_type":"record.created","filter":\{"object_id":"..."\}\}\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `webhookId` | string | The webhook ID | +| `targetUrl` | string | The webhook target URL | +| `subscriptions` | array | Event subscriptions | +| ↳ `eventType` | string | The event type \(e.g. record.created\) | +| ↳ `filter` | json | Optional event filter | +| `status` | string | Webhook status \(active, degraded, inactive\) | +| `createdAt` | string | When the webhook was created | +| `secret` | string | The webhook signing secret \(only returned on creation\) | + +### `attio_update_webhook` + +Update a webhook in Attio (target URL and/or subscriptions) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `webhookId` | string | Yes | The webhook ID to update | +| `targetUrl` | string | Yes | HTTPS target URL for webhook delivery | +| `subscriptions` | string | Yes | JSON array of subscriptions, e.g. \[\{"event_type":"note.created"\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `webhookId` | string | The webhook ID | +| `targetUrl` | string | The webhook target URL | +| `subscriptions` | array | Event subscriptions | +| ↳ `eventType` | string | The event type \(e.g. record.created\) | +| ↳ `filter` | json | Optional event filter | +| `status` | string | Webhook status \(active, degraded, inactive\) | +| `createdAt` | string | When the webhook was created | + +### `attio_delete_webhook` + +Delete a webhook from Attio + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `webhookId` | string | Yes | The webhook ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the webhook was deleted | + + diff --git a/apps/docs/content/docs/en/tools/gong.mdx b/apps/docs/content/docs/en/tools/gong.mdx new file mode 100644 index 0000000000..34ef1563d6 --- /dev/null +++ b/apps/docs/content/docs/en/tools/gong.mdx @@ -0,0 +1,774 @@ +--- +title: Gong +description: Revenue intelligence and conversation analytics +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Gong](https://www.gong.io/) is a revenue intelligence platform that captures and analyzes customer interactions across calls, emails, and meetings. By integrating Gong with Sim, your agents can access conversation data, user analytics, coaching metrics, and more through automated workflows. + +The Gong integration in Sim provides tools to: + +- **List and retrieve calls:** Fetch calls by date range, get individual call details, or retrieve extensive call data including trackers, topics, interaction stats, and points of interest. +- **Access call transcripts:** Retrieve full transcripts with speaker turns, topics, and sentence-level timestamps for any recorded call. +- **Manage users:** List all Gong users in your account or retrieve detailed information for a specific user, including settings, spoken languages, and contact details. +- **Analyze activity and performance:** Pull aggregated activity statistics, interaction stats (longest monologue, interactivity, patience, question rate), and answered scorecard data for your team. +- **Work with scorecards and trackers:** List scorecard definitions and keyword tracker configurations to understand how your team's conversations are being evaluated and monitored. +- **Browse the call library:** List library folders and retrieve their contents, including call snippets and notes curated by your team. +- **Access coaching metrics:** Retrieve coaching data for managers and their direct reports to track team development. +- **List Engage flows:** Fetch sales engagement sequences (flows) with visibility and ownership details. +- **Look up contacts by email or phone:** Find all Gong references to a specific email address or phone number, including related calls, emails, meetings, CRM data, and customer engagement events. + +By combining these capabilities, you can automate sales coaching workflows, extract conversation insights, monitor team performance, sync Gong data with other systems, and build intelligent pipelines around your organization's revenue conversations -- all securely using your Gong API credentials. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Gong into your workflow. Access call recordings, transcripts, user data, activity stats, scorecards, trackers, library content, coaching metrics, and more via the Gong API. + + + +## Tools + +### `gong_list_calls` + +Retrieve call data by date range from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `fromDateTime` | string | Yes | Start date/time in ISO-8601 format \(e.g., 2024-01-01T00:00:00Z\) | +| `toDateTime` | string | No | End date/time in ISO-8601 format \(e.g., 2024-01-31T23:59:59Z\). If omitted, lists calls up to the most recent. | +| `cursor` | string | No | Pagination cursor from a previous response | +| `workspaceId` | string | No | Gong workspace ID to filter calls | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `calls` | array | List of calls matching the date range | +| ↳ `id` | string | Gong's unique numeric identifier for the call | +| ↳ `title` | string | Call title | +| ↳ `scheduled` | string | Scheduled call time in ISO-8601 format | +| ↳ `started` | string | Recording start time in ISO-8601 format | +| ↳ `duration` | number | Call duration in seconds | +| ↳ `direction` | string | Call direction \(Inbound/Outbound\) | +| ↳ `system` | string | Communication platform used \(e.g., Outreach\) | +| ↳ `scope` | string | Call scope: 'Internal', 'External', or 'Unknown' | +| ↳ `media` | string | Media type \(e.g., Video\) | +| ↳ `language` | string | Language code in ISO-639-2B format | +| ↳ `url` | string | URL to the call in the Gong web app | +| ↳ `primaryUserId` | string | Host team member identifier | +| ↳ `workspaceId` | string | Workspace identifier | +| ↳ `sdrDisposition` | string | SDR disposition classification | +| ↳ `clientUniqueId` | string | Call identifier from the origin recording system | +| ↳ `customData` | string | Metadata provided during call creation | +| ↳ `purpose` | string | Call purpose | +| ↳ `meetingUrl` | string | Web conference provider URL | +| ↳ `isPrivate` | boolean | Whether the call is private | +| ↳ `calendarEventId` | string | Calendar event identifier | +| `cursor` | string | Pagination cursor for the next page | +| `totalRecords` | number | Total number of records matching the filter | + +### `gong_get_call` + +Retrieve detailed data for a specific call from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `callId` | string | Yes | The Gong call ID to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Gong's unique numeric identifier for the call | +| `title` | string | Call title | +| `url` | string | URL to the call in the Gong web app | +| `scheduled` | string | Scheduled call time in ISO-8601 format | +| `started` | string | Recording start time in ISO-8601 format | +| `duration` | number | Call duration in seconds | +| `direction` | string | Call direction \(Inbound/Outbound\) | +| `system` | string | Communication platform used \(e.g., Outreach\) | +| `scope` | string | Call scope: 'Internal', 'External', or 'Unknown' | +| `media` | string | Media type \(e.g., Video\) | +| `language` | string | Language code in ISO-639-2B format | +| `primaryUserId` | string | Host team member identifier | +| `workspaceId` | string | Workspace identifier | +| `sdrDisposition` | string | SDR disposition classification | +| `clientUniqueId` | string | Call identifier from the origin recording system | +| `customData` | string | Metadata provided during call creation | +| `purpose` | string | Call purpose | +| `meetingUrl` | string | Web conference provider URL | +| `isPrivate` | boolean | Whether the call is private | +| `calendarEventId` | string | Calendar event identifier | + +### `gong_get_call_transcript` + +Retrieve transcripts of calls from Gong by call IDs or date range. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `callIds` | string | No | Comma-separated list of call IDs to retrieve transcripts for | +| `fromDateTime` | string | No | Start date/time filter in ISO-8601 format | +| `toDateTime` | string | No | End date/time filter in ISO-8601 format | +| `workspaceId` | string | No | Gong workspace ID to filter calls | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `callTranscripts` | array | List of call transcripts with speaker turns and sentences | +| ↳ `callId` | string | Gong's unique numeric identifier for the call | +| ↳ `transcript` | array | List of monologues in the call | +| ↳ `speakerId` | string | Unique ID of the speaker, cross-reference with parties | +| ↳ `topic` | string | Name of the topic being discussed | +| ↳ `sentences` | array | List of sentences spoken in the monologue | +| ↳ `start` | number | Start time of the sentence in milliseconds from call start | +| ↳ `end` | number | End time of the sentence in milliseconds from call start | +| ↳ `text` | string | The sentence text | +| `cursor` | string | Pagination cursor for the next page | + +### `gong_get_extensive_calls` + +Retrieve detailed call data including trackers, topics, and highlights from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `callIds` | string | No | Comma-separated list of call IDs to retrieve detailed data for | +| `fromDateTime` | string | No | Start date/time filter in ISO-8601 format | +| `toDateTime` | string | No | End date/time filter in ISO-8601 format | +| `workspaceId` | string | No | Gong workspace ID to filter calls | +| `primaryUserIds` | string | No | Comma-separated list of user IDs to filter calls by host | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `calls` | array | List of detailed call objects with metadata, content, interaction stats, and collaboration data | +| ↳ `metaData` | object | Call metadata \(same fields as CallBasicData\) | +| ↳ `id` | string | Call ID | +| ↳ `title` | string | Call title | +| ↳ `scheduled` | string | Scheduled time in ISO-8601 | +| ↳ `started` | string | Start time in ISO-8601 | +| ↳ `duration` | number | Duration in seconds | +| ↳ `direction` | string | Call direction | +| ↳ `system` | string | Communication platform | +| ↳ `scope` | string | Internal/External/Unknown | +| ↳ `media` | string | Media type | +| ↳ `language` | string | Language code \(ISO-639-2B\) | +| ↳ `url` | string | Gong web app URL | +| ↳ `primaryUserId` | string | Host user ID | +| ↳ `workspaceId` | string | Workspace ID | +| ↳ `sdrDisposition` | string | SDR disposition | +| ↳ `clientUniqueId` | string | Origin system call ID | +| ↳ `customData` | string | Custom metadata | +| ↳ `purpose` | string | Call purpose | +| ↳ `meetingUrl` | string | Meeting URL | +| ↳ `isPrivate` | boolean | Whether call is private | +| ↳ `calendarEventId` | string | Calendar event ID | +| ↳ `context` | array | Links to external systems \(CRM, Dialer, etc.\) | +| ↳ `system` | string | External system name \(e.g., Salesforce\) | +| ↳ `objects` | array | List of objects within the external system | +| ↳ `parties` | array | List of call participants | +| ↳ `id` | string | Unique participant ID in the call | +| ↳ `name` | string | Participant name | +| ↳ `emailAddress` | string | Email address | +| ↳ `title` | string | Job title | +| ↳ `phoneNumber` | string | Phone number | +| ↳ `speakerId` | string | Speaker ID for transcript cross-reference | +| ↳ `userId` | string | Gong user ID | +| ↳ `affiliation` | string | Company or non-company | +| ↳ `methods` | array | Whether invited or attended | +| ↳ `context` | array | Links to external systems for this party | +| ↳ `content` | object | Call content data | +| ↳ `structure` | array | Call agenda parts | +| ↳ `name` | string | Agenda name | +| ↳ `duration` | number | Duration of this part in seconds | +| ↳ `topics` | array | Topics and their durations | +| ↳ `name` | string | Topic name \(e.g., Pricing\) | +| ↳ `duration` | number | Time spent on topic in seconds | +| ↳ `trackers` | array | Trackers found in the call | +| ↳ `id` | string | Tracker ID | +| ↳ `name` | string | Tracker name | +| ↳ `count` | number | Number of occurrences | +| ↳ `type` | string | Keyword or Smart | +| ↳ `occurrences` | array | Details for each occurrence | +| ↳ `speakerId` | string | Speaker who said it | +| ↳ `startTime` | number | Seconds from call start | +| ↳ `phrases` | array | Per-phrase occurrence counts | +| ↳ `phrase` | string | Specific phrase | +| ↳ `count` | number | Occurrences of this phrase | +| ↳ `occurrences` | array | Details per occurrence | +| ↳ `highlights` | array | AI-generated highlights including next steps, action items, and key moments | +| ↳ `title` | string | Title of the highlight | +| ↳ `interaction` | object | Interaction statistics | +| ↳ `interactionStats` | array | Interaction stats per user | +| ↳ `userId` | string | Gong user ID | +| ↳ `userEmailAddress` | string | User email | +| ↳ `personInteractionStats` | array | Stats list \(Longest Monologue, Interactivity, Patience, etc.\) | +| ↳ `name` | string | Stat name | +| ↳ `value` | number | Stat value | +| ↳ `speakers` | array | Talk duration per speaker | +| ↳ `id` | string | Participant ID | +| ↳ `userId` | string | Gong user ID | +| ↳ `talkTime` | number | Talk duration in seconds | +| ↳ `video` | array | Video statistics | +| ↳ `name` | string | Segment type: Browser, Presentation, WebcamPrimaryUser, WebcamNonCompany, Webcam | +| ↳ `duration` | number | Total segment duration in seconds | +| ↳ `questions` | object | Question counts | +| ↳ `companyCount` | number | Questions by company speakers | +| ↳ `nonCompanyCount` | number | Questions by non-company speakers | +| ↳ `collaboration` | object | Collaboration data | +| ↳ `publicComments` | array | Public comments on the call | +| ↳ `id` | string | Comment ID | +| ↳ `commenterUserId` | string | Commenter user ID | +| ↳ `comment` | string | Comment text | +| ↳ `posted` | string | Posted time in ISO-8601 | +| ↳ `audioStartTime` | number | Seconds from call start the comment refers to | +| ↳ `audioEndTime` | number | Seconds from call start the comment end refers to | +| ↳ `duringCall` | boolean | Whether the comment was posted during the call | +| ↳ `inReplyTo` | string | ID of original comment if this is a reply | +| ↳ `media` | object | Media download URLs \(available for 8 hours\) | +| ↳ `audioUrl` | string | Audio download URL | +| ↳ `videoUrl` | string | Video download URL | +| `cursor` | string | Pagination cursor for the next page | + +### `gong_list_users` + +List all users in your Gong account. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `cursor` | string | No | Pagination cursor from a previous response | +| `includeAvatars` | string | No | Whether to include avatar URLs \(true/false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | List of Gong users | +| ↳ `id` | string | Unique numeric user ID \(up to 20 digits\) | +| ↳ `emailAddress` | string | User email address | +| ↳ `created` | string | User creation timestamp \(ISO-8601\) | +| ↳ `active` | boolean | Whether the user is active | +| ↳ `emailAliases` | array | Alternative email addresses for the user | +| ↳ `trustedEmailAddress` | string | Trusted email address for the user | +| ↳ `firstName` | string | First name | +| ↳ `lastName` | string | Last name | +| ↳ `title` | string | Job title | +| ↳ `phoneNumber` | string | Phone number | +| ↳ `extension` | string | Phone extension number | +| ↳ `personalMeetingUrls` | array | Personal meeting URLs | +| ↳ `settings` | object | User settings | +| ↳ `webConferencesRecorded` | boolean | Whether web conferences are recorded | +| ↳ `preventWebConferenceRecording` | boolean | Whether web conference recording is prevented | +| ↳ `telephonyCallsImported` | boolean | Whether telephony calls are imported | +| ↳ `emailsImported` | boolean | Whether emails are imported | +| ↳ `preventEmailImport` | boolean | Whether email import is prevented | +| ↳ `nonRecordedMeetingsImported` | boolean | Whether non-recorded meetings are imported | +| ↳ `gongConnectEnabled` | boolean | Whether Gong Connect is enabled | +| ↳ `managerId` | string | Manager user ID | +| ↳ `meetingConsentPageUrl` | string | Meeting consent page URL | +| ↳ `spokenLanguages` | array | Languages spoken by the user | +| ↳ `language` | string | Language code | +| ↳ `primary` | boolean | Whether this is the primary language | +| `cursor` | string | Pagination cursor for the next page | +| `totalRecords` | number | Total number of user records | +| `currentPageSize` | number | Number of records in the current page | +| `currentPageNumber` | number | Current page number | + +### `gong_get_user` + +Retrieve details for a specific user from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `userId` | string | Yes | The Gong user ID to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Unique numeric user ID \(up to 20 digits\) | +| `emailAddress` | string | User email address | +| `created` | string | User creation timestamp \(ISO-8601\) | +| `active` | boolean | Whether the user is active | +| `emailAliases` | array | Alternative email addresses for the user | +| `trustedEmailAddress` | string | Trusted email address for the user | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `title` | string | Job title | +| `phoneNumber` | string | Phone number | +| `extension` | string | Phone extension number | +| `personalMeetingUrls` | array | Personal meeting URLs | +| `settings` | object | User settings | +| ↳ `webConferencesRecorded` | boolean | Whether web conferences are recorded | +| ↳ `preventWebConferenceRecording` | boolean | Whether web conference recording is prevented | +| ↳ `telephonyCallsImported` | boolean | Whether telephony calls are imported | +| ↳ `emailsImported` | boolean | Whether emails are imported | +| ↳ `preventEmailImport` | boolean | Whether email import is prevented | +| ↳ `nonRecordedMeetingsImported` | boolean | Whether non-recorded meetings are imported | +| ↳ `gongConnectEnabled` | boolean | Whether Gong Connect is enabled | +| `managerId` | string | Manager user ID | +| `meetingConsentPageUrl` | string | Meeting consent page URL | +| `spokenLanguages` | array | Languages spoken by the user | +| ↳ `language` | string | Language code | +| ↳ `primary` | boolean | Whether this is the primary language | + +### `gong_aggregate_activity` + +Retrieve aggregated activity statistics for users by date range from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | +| `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | +| `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `usersActivity` | array | Aggregated activity statistics per user | +| ↳ `userId` | string | Gong's unique numeric identifier for the user | +| ↳ `userEmailAddress` | string | Email address of the Gong user | +| ↳ `callsAsHost` | number | Number of recorded calls this user hosted | +| ↳ `callsAttended` | number | Number of calls where this user was a participant \(not host\) | +| ↳ `callsGaveFeedback` | number | Number of recorded calls the user gave feedback on | +| ↳ `callsReceivedFeedback` | number | Number of recorded calls the user received feedback on | +| ↳ `callsRequestedFeedback` | number | Number of recorded calls the user requested feedback on | +| ↳ `callsScorecardsFilled` | number | Number of scorecards the user completed | +| ↳ `callsScorecardsReceived` | number | Number of calls where someone filled a scorecard on the user's calls | +| ↳ `ownCallsListenedTo` | number | Number of the user's own calls the user listened to | +| ↳ `othersCallsListenedTo` | number | Number of other users' calls the user listened to | +| ↳ `callsSharedInternally` | number | Number of calls the user shared internally | +| ↳ `callsSharedExternally` | number | Number of calls the user shared externally | +| ↳ `callsCommentsGiven` | number | Number of calls where the user provided at least one comment | +| ↳ `callsCommentsReceived` | number | Number of calls where the user received at least one comment | +| ↳ `callsMarkedAsFeedbackGiven` | number | Number of calls where the user selected Mark as reviewed | +| ↳ `callsMarkedAsFeedbackReceived` | number | Number of calls where others selected Mark as reviewed on the user's calls | +| `timeZone` | string | The company's defined timezone in Gong | +| `fromDateTime` | string | Start of results in ISO-8601 format | +| `toDateTime` | string | End of results in ISO-8601 format | +| `cursor` | string | Pagination cursor for the next page | + +### `gong_interaction_stats` + +Retrieve interaction statistics for users by date range from Gong. Only includes calls with Whisper enabled. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | +| `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | +| `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `peopleInteractionStats` | array | Email address of the Gong user | +| ↳ `userId` | string | Gong's unique numeric identifier for the user | +| ↳ `userEmailAddress` | string | Email address of the Gong user | +| ↳ `personInteractionStats` | array | List of interaction stat measurements for this user | +| ↳ `name` | string | Stat name \(e.g. Longest Monologue, Interactivity, Patience, Question Rate\) | +| ↳ `value` | number | Stat measurement value \(can be double or integer\) | +| `timeZone` | string | The company's defined timezone in Gong | +| `fromDateTime` | string | Start of results in ISO-8601 format | +| `toDateTime` | string | End of results in ISO-8601 format | +| `cursor` | string | Pagination cursor for the next page | + +### `gong_answered_scorecards` + +Retrieve answered scorecards for reviewed users or by date range from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `callFromDate` | string | No | Start date for calls in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest recorded call. | +| `callToDate` | string | No | End date for calls in YYYY-MM-DD format \(exclusive, in company timezone\). Defaults to latest recorded call. | +| `reviewFromDate` | string | No | Start date for reviews in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest reviewed call. | +| `reviewToDate` | string | No | End date for reviews in YYYY-MM-DD format \(exclusive, in company timezone\). Defaults to latest reviewed call. | +| `scorecardIds` | string | No | Comma-separated list of scorecard IDs to filter by | +| `reviewedUserIds` | string | No | Comma-separated list of reviewed user IDs to filter by | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `answeredScorecards` | array | List of answered scorecards with scores and answers | +| ↳ `answeredScorecardId` | number | Identifier of the answered scorecard | +| ↳ `scorecardId` | number | Identifier of the scorecard | +| ↳ `scorecardName` | string | Scorecard name | +| ↳ `callId` | number | Gong's unique numeric identifier for the call | +| ↳ `callStartTime` | string | Date/time of the call in ISO-8601 format | +| ↳ `reviewedUserId` | number | User ID of the team member being reviewed | +| ↳ `reviewerUserId` | number | User ID of the team member who completed the scorecard | +| ↳ `reviewTime` | string | Date/time when the review was completed in ISO-8601 format | +| ↳ `visibilityType` | string | Visibility type of the scorecard answer | +| ↳ `answers` | array | Answers in the answered scorecard | +| ↳ `questionId` | number | Identifier of the question | +| ↳ `questionRevisionId` | number | Identifier of the revision version of the question | +| ↳ `isOverall` | boolean | Whether this is the overall question | +| ↳ `score` | number | Score between 1 to 5 if answered, null otherwise | +| ↳ `answerText` | string | The answer's text if answered, null otherwise | +| ↳ `notApplicable` | boolean | Whether the question is not applicable to this call | +| `cursor` | string | Pagination cursor for the next page | + +### `gong_list_library_folders` + +Retrieve library folders from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `workspaceId` | string | No | Gong workspace ID to filter folders | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folders` | array | List of library folders with id, name, and parent relationships | +| ↳ `id` | string | Gong unique numeric identifier for the folder | +| ↳ `name` | string | Display name of the folder | +| ↳ `parentFolderId` | string | Gong unique numeric identifier for the parent folder \(null for root folder\) | +| ↳ `createdBy` | string | Gong unique numeric identifier for the user who added the folder | +| ↳ `updated` | string | Folder's last update time in ISO-8601 format | + +### `gong_get_folder_content` + +Retrieve the list of calls in a specific library folder from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `folderId` | string | Yes | The library folder ID to retrieve content for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `folderId` | string | Gong's unique numeric identifier for the folder | +| `folderName` | string | Display name of the folder | +| `createdBy` | string | Gong's unique numeric identifier for the user who added the folder | +| `updated` | string | Folder's last update time in ISO-8601 format | +| `calls` | array | List of calls in the library folder | +| ↳ `id` | string | Gong unique numeric identifier of the call | +| ↳ `title` | string | The title of the call | +| ↳ `note` | string | A note attached to the call in the folder | +| ↳ `addedBy` | string | Gong unique numeric identifier for the user who added the call | +| ↳ `created` | string | Date and time the call was added to folder in ISO-8601 format | +| ↳ `url` | string | URL of the call | +| ↳ `snippet` | object | Call snippet time range | +| ↳ `fromSec` | number | Snippet start in seconds relative to call start | +| ↳ `toSec` | number | Snippet end in seconds relative to call start | + +### `gong_list_scorecards` + +Retrieve scorecard definitions from Gong settings. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `scorecards` | array | List of scorecard definitions with questions | +| ↳ `scorecardId` | string | Unique identifier for the scorecard | +| ↳ `scorecardName` | string | Display name of the scorecard | +| ↳ `workspaceId` | string | Workspace identifier associated with this scorecard | +| ↳ `enabled` | boolean | Whether the scorecard is active | +| ↳ `updaterUserId` | string | ID of the user who last modified the scorecard | +| ↳ `created` | string | Creation timestamp in ISO-8601 format | +| ↳ `updated` | string | Last update timestamp in ISO-8601 format | +| ↳ `questions` | array | List of questions in the scorecard | +| ↳ `questionId` | string | Unique identifier for the question | +| ↳ `questionText` | string | The text content of the question | +| ↳ `questionRevisionId` | string | Identifier for the specific revision of the question | +| ↳ `isOverall` | boolean | Whether this is the primary overall question | +| ↳ `created` | string | Question creation timestamp in ISO-8601 format | +| ↳ `updated` | string | Question last update timestamp in ISO-8601 format | +| ↳ `updaterUserId` | string | ID of the user who last modified the question | + +### `gong_list_trackers` + +Retrieve smart tracker and keyword tracker definitions from Gong settings. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `workspaceId` | string | No | The ID of the workspace the keyword trackers are in. When empty, all trackers in all workspaces are returned. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `trackers` | array | List of keyword tracker definitions | +| ↳ `trackerId` | string | Unique identifier for the tracker | +| ↳ `trackerName` | string | Display name of the tracker | +| ↳ `workspaceId` | string | ID of the workspace containing the tracker | +| ↳ `languageKeywords` | array | Keywords organized by language | +| ↳ `language` | string | ISO 639-2/B language code \("mul" means keywords apply across all languages\) | +| ↳ `keywords` | array | Words and phrases in the designated language | +| ↳ `includeRelatedForms` | boolean | Whether to include different word forms | +| ↳ `affiliation` | string | Speaker affiliation filter: "Anyone", "Company", or "NonCompany" | +| ↳ `partOfQuestion` | boolean | Whether to track keywords only within questions | +| ↳ `saidAt` | string | Position in call: "Anytime", "First", or "Last" | +| ↳ `saidAtInterval` | number | Duration to search \(in minutes or percentage\) | +| ↳ `saidAtUnit` | string | Unit for saidAtInterval | +| ↳ `saidInTopics` | array | Topics where keywords should be detected | +| ↳ `saidInCallParts` | array | Specific call segments to monitor | +| ↳ `filterQuery` | string | JSON-formatted call filtering criteria | +| ↳ `created` | string | Creation timestamp in ISO-8601 format | +| ↳ `creatorUserId` | string | ID of the user who created the tracker \(null for built-in trackers\) | +| ↳ `updated` | string | Last modification timestamp in ISO-8601 format | +| ↳ `updaterUserId` | string | ID of the user who last modified the tracker | + +### `gong_list_workspaces` + +List all company workspaces in Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workspaces` | array | List of Gong workspaces | +| ↳ `id` | string | Gong unique numeric identifier for the workspace | +| ↳ `name` | string | Display name of the workspace | +| ↳ `description` | string | Description of the workspace's purpose or content | + +### `gong_list_flows` + +List Gong Engage flows (sales engagement sequences). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `flowOwnerEmail` | string | Yes | Email of a Gong user. The API will return 'PERSONAL' flows belonging to this user in addition to 'COMPANY' flows. | +| `workspaceId` | string | No | Optional workspace ID to filter flows to a specific workspace | +| `cursor` | string | No | Pagination cursor from a previous API call to retrieve the next page of records | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | +| `flows` | array | List of Gong Engage flows | +| ↳ `id` | string | The ID of the flow | +| ↳ `name` | string | The name of the flow | +| ↳ `folderId` | string | The ID of the folder this flow is under | +| ↳ `folderName` | string | The name of the folder this flow is under | +| ↳ `visibility` | string | The flow visibility type \(COMPANY, PERSONAL, or SHARED\) | +| ↳ `creationDate` | string | Creation time of the flow in ISO-8601 format | +| ↳ `exclusive` | boolean | Indicates whether a prospect in this flow can be added to other flows | +| `totalRecords` | number | Total number of flow records available | +| `currentPageSize` | number | Number of records returned in the current page | +| `currentPageNumber` | number | Current page number | +| `cursor` | string | Pagination cursor for retrieving the next page of records | + +### `gong_get_coaching` + +Retrieve coaching metrics for a manager from Gong. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `managerId` | string | Yes | Gong user ID of the manager | +| `workspaceId` | string | Yes | Gong workspace ID | +| `fromDate` | string | Yes | Start date in ISO-8601 format | +| `toDate` | string | Yes | End date in ISO-8601 format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | +| `coachingData` | array | The manager user information | +| ↳ `manager` | object | The manager user information | +| ↳ `id` | string | Gong unique numeric identifier for the user | +| ↳ `emailAddress` | string | Email address of the Gong user | +| ↳ `firstName` | string | First name of the Gong user | +| ↳ `lastName` | string | Last name of the Gong user | +| ↳ `title` | string | Job title of the Gong user | +| ↳ `directReportsMetrics` | array | Coaching metrics for each direct report | +| ↳ `report` | object | The direct report user information | +| ↳ `id` | string | Gong unique numeric identifier for the user | +| ↳ `emailAddress` | string | Email address of the Gong user | +| ↳ `firstName` | string | First name of the Gong user | +| ↳ `lastName` | string | Last name of the Gong user | +| ↳ `title` | string | Job title of the Gong user | +| ↳ `metrics` | json | A map of metric names to arrays of string values representing coaching metrics | + +### `gong_lookup_email` + +Find all references to an email address in Gong (calls, email messages, meetings, CRM data, engagement). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `emailAddress` | string | Yes | Email address to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | Gong request reference ID for troubleshooting | +| `calls` | array | Related calls referencing this email address | +| ↳ `id` | string | Gong's unique numeric identifier for the call \(up to 20 digits\) | +| ↳ `status` | string | Call status | +| ↳ `externalSystems` | array | Links to external systems such as CRM, Telephony System, etc. | +| ↳ `system` | string | External system name | +| ↳ `objects` | array | List of objects within the external system | +| ↳ `objectType` | string | Object type | +| ↳ `externalId` | string | External ID | +| `emails` | array | Related email messages referencing this email address | +| ↳ `id` | string | Gong's unique 32 character identifier for the email message | +| ↳ `from` | string | The sender's email address | +| ↳ `sentTime` | string | Date and time the email was sent in ISO-8601 format | +| ↳ `mailbox` | string | The mailbox from which the email was retrieved | +| ↳ `messageHash` | string | Hash code of the email message | +| `meetings` | array | Related meetings referencing this email address | +| ↳ `id` | string | Gong's unique identifier for the meeting | +| `customerData` | array | Links to data from external systems \(CRM, Telephony, etc.\) that reference this email | +| ↳ `system` | string | External system name | +| ↳ `objects` | array | List of objects in the external system | +| ↳ `id` | string | Gong's unique numeric identifier for the Lead or Contact \(up to 20 digits\) | +| ↳ `objectType` | string | Object type | +| ↳ `externalId` | string | External ID | +| ↳ `mirrorId` | string | CRM Mirror ID | +| ↳ `fields` | array | Object fields | +| ↳ `name` | string | Field name | +| ↳ `value` | json | Field value | +| `customerEngagement` | array | Customer engagement events \(such as viewing external shared calls\) | +| ↳ `eventType` | string | Event type | +| ↳ `eventName` | string | Event name | +| ↳ `timestamp` | string | Date and time the event occurred in ISO-8601 format | +| ↳ `contentId` | string | Event content ID | +| ↳ `contentUrl` | string | Event content URL | +| ↳ `reportingSystem` | string | Event reporting system | +| ↳ `sourceEventId` | string | Source event ID | + +### `gong_lookup_phone` + +Find all references to a phone number in Gong (calls, email messages, meetings, CRM data, and associated contacts). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `phoneNumber` | string | Yes | Phone number to look up \(must start with + followed by country code\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | Gong request reference ID for troubleshooting | +| `suppliedPhoneNumber` | string | The phone number that was supplied in the request | +| `matchingPhoneNumbers` | array | Phone numbers found in the system that match the supplied number | +| `emailAddresses` | array | Email addresses associated with the phone number | +| `calls` | array | Related calls referencing this phone number | +| ↳ `id` | string | Gong's unique numeric identifier for the call \(up to 20 digits\) | +| ↳ `status` | string | Call status | +| ↳ `externalSystems` | array | Links to external systems such as CRM, Telephony System, etc. | +| ↳ `system` | string | External system name | +| ↳ `objects` | array | List of objects within the external system | +| ↳ `objectType` | string | Object type | +| ↳ `externalId` | string | External ID | +| `emails` | array | Related email messages associated with contacts matching this phone number | +| ↳ `id` | string | Gong's unique 32 character identifier for the email message | +| ↳ `from` | string | The sender's email address | +| ↳ `sentTime` | string | Date and time the email was sent in ISO-8601 format | +| ↳ `mailbox` | string | The mailbox from which the email was retrieved | +| ↳ `messageHash` | string | Hash code of the email message | +| `meetings` | array | Related meetings associated with this phone number | +| ↳ `id` | string | Gong's unique identifier for the meeting | +| `customerData` | array | Links to data from external systems \(CRM, Telephony, etc.\) that reference this phone number | +| ↳ `system` | string | External system name | +| ↳ `objects` | array | List of objects in the external system | +| ↳ `id` | string | Gong's unique numeric identifier for the Lead or Contact \(up to 20 digits\) | +| ↳ `objectType` | string | Object type | +| ↳ `externalId` | string | External ID | +| ↳ `mirrorId` | string | CRM Mirror ID | +| ↳ `fields` | array | Object fields | +| ↳ `name` | string | Field name | +| ↳ `value` | json | Field value | + + diff --git a/apps/docs/content/docs/en/tools/hex.mdx b/apps/docs/content/docs/en/tools/hex.mdx index c979333847..28e2a18647 100644 --- a/apps/docs/content/docs/en/tools/hex.mdx +++ b/apps/docs/content/docs/en/tools/hex.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 3a3a1cc16d..9fc1cc577e 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -10,6 +10,7 @@ "apollo", "arxiv", "asana", + "attio", "browser_use", "calcom", "calendly", @@ -35,6 +36,7 @@ "github", "gitlab", "gmail", + "gong", "google_books", "google_calendar", "google_docs", diff --git a/apps/docs/content/docs/en/variables/environment-variables.mdx b/apps/docs/content/docs/en/variables/environment-variables.mdx deleted file mode 100644 index 76fb1946c2..0000000000 --- a/apps/docs/content/docs/en/variables/environment-variables.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Environment Variables ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Image } from '@/components/ui/image' - -Environment variables provide a secure way to manage configuration values and secrets across your workflows, including API keys and other sensitive data that your workflows need to access. They keep secrets out of your workflow definitions while making them available during execution. - -## Variable Types - -Environment variables in Sim work at two levels: - -- **Personal Environment Variables**: Private to your account, only you can see and use them -- **Workspace Environment Variables**: Shared across the entire workspace, available to all team members - - -Workspace environment variables take precedence over personal ones when there's a naming conflict. - - -## Setting up Environment Variables - -Navigate to Settings to configure your environment variables: - - - -From your workspace settings, you can create and manage both personal and workspace-level environment variables. Personal variables are private to your account, while workspace variables are shared with all team members. - -### Making Variables Workspace-Scoped - -Use the workspace scope toggle to make variables available to your entire team: - - - -When you enable workspace scope, the variable becomes available to all workspace members and can be used in any workflow within that workspace. - -### Workspace Variables View - -Once you have workspace-scoped variables, they appear in your environment variables list: - - - -## Using Variables in Workflows - -To reference environment variables in your workflows, use the `{{}}` notation. When you type `{{` in any input field, a dropdown will appear showing both your personal and workspace-level environment variables. Simply select the variable you want to use. - - - -## How Variables are Resolved - -**Workspace variables always take precedence** over personal variables, regardless of who runs the workflow. - -When no workspace variable exists for a key, personal variables are used: -- **Manual runs (UI)**: Your personal variables -- **Automated runs (API, webhook, schedule, deployed chat)**: Workflow owner's personal variables - - -Personal variables are best for testing. Use workspace variables for production workflows. - - -## Security Best Practices - -### For Sensitive Data -- Store API keys, tokens, and passwords as environment variables instead of hardcoding them -- Use workspace variables for shared resources that multiple team members need -- Keep personal credentials in personal variables - -### Variable Naming -- Use descriptive names: `DATABASE_URL` instead of `DB` -- Follow consistent naming conventions across your team -- Consider prefixes to avoid conflicts: `PROD_API_KEY`, `DEV_API_KEY` - -### Access Control -- Workspace environment variables respect workspace permissions -- Only users with write access or higher can create/modify workspace variables -- Personal variables are always private to the individual user \ No newline at end of file diff --git a/apps/docs/content/docs/es/variables/environment-variables.mdx b/apps/docs/content/docs/es/variables/environment-variables.mdx deleted file mode 100644 index a231b75341..0000000000 --- a/apps/docs/content/docs/es/variables/environment-variables.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Variables de entorno ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Image } from '@/components/ui/image' - -Las variables de entorno proporcionan una forma segura de gestionar valores de configuración y secretos en tus flujos de trabajo, incluyendo claves API y otros datos sensibles que tus flujos de trabajo necesitan acceder. Mantienen los secretos fuera de las definiciones de tu flujo de trabajo mientras los hacen disponibles durante la ejecución. - -## Tipos de variables - -Las variables de entorno en Sim funcionan en dos niveles: - -- **Variables de entorno personales**: Privadas para tu cuenta, solo tú puedes verlas y usarlas -- **Variables de entorno del espacio de trabajo**: Compartidas en todo el espacio de trabajo, disponibles para todos los miembros del equipo - - -Las variables de entorno del espacio de trabajo tienen prioridad sobre las personales cuando hay un conflicto de nombres. - - -## Configuración de variables de entorno - -Navega a Configuración para configurar tus variables de entorno: - - - -Desde la configuración de tu espacio de trabajo, puedes crear y gestionar variables de entorno tanto personales como a nivel de espacio de trabajo. Las variables personales son privadas para tu cuenta, mientras que las variables del espacio de trabajo se comparten con todos los miembros del equipo. - -### Hacer variables con ámbito de espacio de trabajo - -Usa el interruptor de ámbito del espacio de trabajo para hacer que las variables estén disponibles para todo tu equipo: - - - -Cuando habilitas el ámbito del espacio de trabajo, la variable se vuelve disponible para todos los miembros del espacio de trabajo y puede ser utilizada en cualquier flujo de trabajo dentro de ese espacio de trabajo. - -### Vista de variables del espacio de trabajo - -Una vez que tienes variables con ámbito de espacio de trabajo, aparecen en tu lista de variables de entorno: - - - -## Uso de variables en flujos de trabajo - -Para hacer referencia a variables de entorno en tus flujos de trabajo, utiliza la notación `{{}}`. Cuando escribas `{{` en cualquier campo de entrada, aparecerá un menú desplegable mostrando tanto tus variables de entorno personales como las del espacio de trabajo. Simplemente selecciona la variable que deseas utilizar. - - - -## Cómo se resuelven las variables - -**Las variables del espacio de trabajo siempre tienen prioridad** sobre las variables personales, independientemente de quién ejecute el flujo de trabajo. - -Cuando no existe una variable de espacio de trabajo para una clave, se utilizan las variables personales: -- **Ejecuciones manuales (UI)**: Tus variables personales -- **Ejecuciones automatizadas (API, webhook, programación, chat implementado)**: Variables personales del propietario del flujo de trabajo - - -Las variables personales son mejores para pruebas. Usa variables de espacio de trabajo para flujos de trabajo de producción. - - -## Mejores prácticas de seguridad - -### Para datos sensibles -- Almacena claves API, tokens y contraseñas como variables de entorno en lugar de codificarlos directamente -- Usa variables de espacio de trabajo para recursos compartidos que varios miembros del equipo necesitan -- Mantén las credenciales personales en variables personales - -### Nomenclatura de variables -- Usa nombres descriptivos: `DATABASE_URL` en lugar de `DB` -- Sigue convenciones de nomenclatura consistentes en todo tu equipo -- Considera usar prefijos para evitar conflictos: `PROD_API_KEY`, `DEV_API_KEY` - -### Control de acceso -- Las variables de entorno del espacio de trabajo respetan los permisos del espacio de trabajo -- Solo los usuarios con acceso de escritura o superior pueden crear/modificar variables del espacio de trabajo -- Las variables personales siempre son privadas para el usuario individual \ No newline at end of file diff --git a/apps/docs/content/docs/fr/variables/environment-variables.mdx b/apps/docs/content/docs/fr/variables/environment-variables.mdx deleted file mode 100644 index 07fd82e3cc..0000000000 --- a/apps/docs/content/docs/fr/variables/environment-variables.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: Variables d'environnement ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Image } from '@/components/ui/image' - -Les variables d'environnement offrent un moyen sécurisé de gérer les valeurs de configuration et les secrets dans vos workflows, y compris les clés API et autres données sensibles dont vos workflows ont besoin. Elles gardent les secrets en dehors de vos définitions de workflow tout en les rendant disponibles pendant l'exécution. - -## Types de variables - -Les variables d'environnement dans Sim fonctionnent à deux niveaux : - -- **Variables d'environnement personnelles** : privées à votre compte, vous seul pouvez les voir et les utiliser -- **Variables d'environnement d'espace de travail** : partagées dans tout l'espace de travail, disponibles pour tous les membres de l'équipe - - -Les variables d'environnement d'espace de travail ont priorité sur les variables personnelles en cas de conflit de noms. - - -## Configuration des variables d'environnement - -Accédez aux Paramètres pour configurer vos variables d'environnement : - - - -Depuis les paramètres de votre espace de travail, vous pouvez créer et gérer des variables d'environnement personnelles et au niveau de l'espace de travail. Les variables personnelles sont privées à votre compte, tandis que les variables d'espace de travail sont partagées avec tous les membres de l'équipe. - -### Définir des variables au niveau de l'espace de travail - -Utilisez le bouton de portée d'espace de travail pour rendre les variables disponibles à toute votre équipe : - - - -Lorsque vous activez la portée d'espace de travail, la variable devient disponible pour tous les membres de l'espace de travail et peut être utilisée dans n'importe quel workflow au sein de cet espace de travail. - -### Vue des variables d'espace de travail - -Une fois que vous avez des variables à portée d'espace de travail, elles apparaissent dans votre liste de variables d'environnement : - - - -## Utilisation des variables dans les workflows - -Pour référencer des variables d'environnement dans vos workflows, utilisez la notation `{{}}`. Lorsque vous tapez `{{` dans n'importe quel champ de saisie, un menu déroulant apparaîtra affichant à la fois vos variables d'environnement personnelles et celles au niveau de l'espace de travail. Sélectionnez simplement la variable que vous souhaitez utiliser. - - - -## Comment les variables sont résolues - -**Les variables d'espace de travail ont toujours la priorité** sur les variables personnelles, quel que soit l'utilisateur qui exécute le flux de travail. - -Lorsqu'aucune variable d'espace de travail n'existe pour une clé, les variables personnelles sont utilisées : -- **Exécutions manuelles (UI)** : Vos variables personnelles -- **Exécutions automatisées (API, webhook, planification, chat déployé)** : Variables personnelles du propriétaire du flux de travail - - -Les variables personnelles sont idéales pour les tests. Utilisez les variables d'espace de travail pour les flux de travail en production. - - -## Bonnes pratiques de sécurité - -### Pour les données sensibles -- Stockez les clés API, les jetons et les mots de passe comme variables d'environnement au lieu de les coder en dur -- Utilisez des variables d'espace de travail pour les ressources partagées dont plusieurs membres de l'équipe ont besoin -- Conservez vos identifiants personnels dans des variables personnelles - -### Nommage des variables -- Utilisez des noms descriptifs : `DATABASE_URL` au lieu de `DB` -- Suivez des conventions de nommage cohérentes au sein de votre équipe -- Envisagez des préfixes pour éviter les conflits : `PROD_API_KEY`, `DEV_API_KEY` - -### Contrôle d'accès -- Les variables d'environnement de l'espace de travail respectent les permissions de l'espace de travail -- Seuls les utilisateurs disposant d'un accès en écriture ou supérieur peuvent créer/modifier les variables d'espace de travail -- Les variables personnelles sont toujours privées pour l'utilisateur individuel \ No newline at end of file diff --git a/apps/docs/content/docs/ja/variables/environment-variables.mdx b/apps/docs/content/docs/ja/variables/environment-variables.mdx deleted file mode 100644 index 92b5c983a7..0000000000 --- a/apps/docs/content/docs/ja/variables/environment-variables.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: 環境変数 ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Image } from '@/components/ui/image' - -環境変数は、APIキーやワークフローがアクセスする必要のあるその他の機密データなど、ワークフロー全体で設定値や機密情報を安全に管理する方法を提供します。これにより、実行中にそれらを利用可能にしながら、ワークフロー定義から機密情報を切り離すことができます。 - -## 変数タイプ - -Simの環境変数は2つのレベルで機能します: - -- **個人環境変数**:あなたのアカウントに限定され、あなただけが閲覧・使用できます -- **ワークスペース環境変数**:ワークスペース全体で共有され、すべてのチームメンバーが利用できます - - -名前の競合がある場合、ワークスペース環境変数は個人環境変数よりも優先されます。 - - -## 環境変数の設定 - -設定に移動して環境変数を構成します: - - - -ワークスペース設定から、個人レベルとワークスペースレベルの両方の環境変数を作成・管理できます。個人変数はあなたのアカウントに限定されますが、ワークスペース変数はすべてのチームメンバーと共有されます。 - -### 変数をワークスペーススコープにする - -ワークスペーススコープトグルを使用して、変数をチーム全体で利用可能にします: - - - -ワークスペーススコープを有効にすると、その変数はすべてのワークスペースメンバーが利用でき、そのワークスペース内のあらゆるワークフローで使用できるようになります。 - -### ワークスペース変数ビュー - -ワークスペーススコープの変数を作成すると、環境変数リストに表示されます: - - - -## ワークフローでの変数の使用 - -ワークフローで環境変数を参照するには、`{{}}`表記を使用します。任意の入力フィールドで`{{`と入力すると、個人用とワークスペースレベルの両方の環境変数を表示するドロップダウンが表示されます。使用したい変数を選択するだけです。 - - - -## 変数の解決方法 - -**ワークスペース変数は常に優先されます**。誰がワークフローを実行するかに関わらず、個人変数よりも優先されます。 - -キーに対するワークスペース変数が存在しない場合、個人変数が使用されます: -- **手動実行(UI)**:あなたの個人変数 -- **自動実行(API、ウェブフック、スケジュール、デプロイされたチャット)**:ワークフロー所有者の個人変数 - - -個人変数はテストに最適です。本番環境のワークフローにはワークスペース変数を使用してください。 - - -## セキュリティのベストプラクティス - -### 機密データについて -- APIキー、トークン、パスワードはハードコーディングせず、環境変数として保存してください -- 複数のチームメンバーが必要とする共有リソースにはワークスペース変数を使用してください -- 個人の認証情報は個人変数に保管してください - -### 変数の命名 -- 説明的な名前を使用する:`DATABASE_URL`ではなく`DB` -- チーム全体で一貫した命名規則に従う -- 競合を避けるために接頭辞を検討する:`PROD_API_KEY`、`DEV_API_KEY` - -### アクセス制御 -- ワークスペース環境変数はワークスペースの権限を尊重します -- 書き込みアクセス権以上を持つユーザーのみがワークスペース変数を作成/変更できます -- 個人変数は常に個々のユーザーにプライベートです \ No newline at end of file diff --git a/apps/docs/content/docs/zh/variables/environment-variables.mdx b/apps/docs/content/docs/zh/variables/environment-variables.mdx deleted file mode 100644 index cab2aa5b3f..0000000000 --- a/apps/docs/content/docs/zh/variables/environment-variables.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: 环境变量 ---- - -import { Callout } from 'fumadocs-ui/components/callout' -import { Image } from '@/components/ui/image' - -环境变量为管理工作流中的配置值和密钥(包括 API 密钥和其他敏感数据)提供了一种安全的方式。它们可以在执行期间使用,同时将敏感信息从工作流定义中隔离开来。 - -## 变量类型 - -Sim 中的环境变量分为两个级别: - -- **个人环境变量**:仅限于您的账户,只有您可以查看和使用 -- **工作区环境变量**:在整个工作区内共享,所有团队成员都可以使用 - - -当命名冲突时,工作区环境变量优先于个人环境变量。 - - -## 设置环境变量 - -前往设置页面配置您的环境变量: - - - -在工作区设置中,您可以创建和管理个人及工作区级别的环境变量。个人变量仅限于您的账户,而工作区变量会与所有团队成员共享。 - -### 将变量设为工作区范围 - -使用工作区范围切换按钮,使变量对整个团队可用: - - - -启用工作区范围后,该变量将对所有工作区成员可用,并可在该工作区内的任何工作流中使用。 - -### 工作区变量视图 - -一旦您拥有了工作区范围的变量,它们将显示在您的环境变量列表中: - - - -## 在工作流中使用变量 - -要在工作流中引用环境变量,请使用 `{{}}` 表示法。当您在任何输入字段中键入 `{{` 时,将会出现一个下拉菜单,显示您的个人和工作区级别的环境变量。只需选择您想要使用的变量即可。 - - - -## 变量的解析方式 - -**工作区变量始终优先于**个人变量,无论是谁运行工作流。 - -当某个键没有工作区变量时,将使用个人变量: -- **手动运行(UI)**:使用您的个人变量 -- **自动运行(API、Webhook、计划任务、已部署的聊天)**:使用工作流所有者的个人变量 - - -个人变量最适合用于测试。生产环境的工作流请使用工作区变量。 - - -## 安全最佳实践 - -### 针对敏感数据 -- 将 API 密钥、令牌和密码存储为环境变量,而不是硬编码它们 -- 对于多个团队成员需要的共享资源,使用工作区变量 -- 将个人凭据保存在个人变量中 - -### 变量命名 -- 使用描述性名称:`DATABASE_URL` 而不是 `DB` -- 在团队中遵循一致的命名约定 -- 考虑使用前缀以避免冲突:`PROD_API_KEY`、`DEV_API_KEY` - -### 访问控制 -- 工作区环境变量遵循工作区权限 -- 只有具有写入权限或更高权限的用户才能创建/修改工作区变量 -- 个人变量始终对个人用户私有 \ No newline at end of file diff --git a/apps/docs/public/static/credentials/create-oauth.png b/apps/docs/public/static/credentials/create-oauth.png new file mode 100644 index 0000000000..c77f8aedf1 Binary files /dev/null and b/apps/docs/public/static/credentials/create-oauth.png differ diff --git a/apps/docs/public/static/credentials/create-secret.png b/apps/docs/public/static/credentials/create-secret.png new file mode 100644 index 0000000000..ee3d6978ff Binary files /dev/null and b/apps/docs/public/static/credentials/create-secret.png differ diff --git a/apps/docs/public/static/credentials/oauth-selector.png b/apps/docs/public/static/credentials/oauth-selector.png new file mode 100644 index 0000000000..784f97a5b4 Binary files /dev/null and b/apps/docs/public/static/credentials/oauth-selector.png differ diff --git a/apps/docs/public/static/credentials/secret-dropdown.png b/apps/docs/public/static/credentials/secret-dropdown.png new file mode 100644 index 0000000000..6f03ecdb2b Binary files /dev/null and b/apps/docs/public/static/credentials/secret-dropdown.png differ diff --git a/apps/docs/public/static/credentials/secret-resolved.png b/apps/docs/public/static/credentials/secret-resolved.png new file mode 100644 index 0000000000..f5aadb13d6 Binary files /dev/null and b/apps/docs/public/static/credentials/secret-resolved.png differ diff --git a/apps/docs/public/static/credentials/settings-secrets.png b/apps/docs/public/static/credentials/settings-secrets.png new file mode 100644 index 0000000000..76b1e4f2eb Binary files /dev/null and b/apps/docs/public/static/credentials/settings-secrets.png differ diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 29ab636e74..36227d54f7 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -5,6 +5,7 @@ import { createContext, useCallback, useEffect, useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import posthog from 'posthog-js' import { client } from '@/lib/auth/auth-client' +import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response' export type AppSession = { user: { @@ -45,7 +46,8 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { const res = bypassCache ? await client.getSession({ query: { disableCookieCache: true } }) : await client.getSession() - setData(res?.data ?? null) + const session = extractSessionDataFromAuthClientResult(res) as AppSession + setData(session) } catch (e) { setError(e instanceof Error ? e : new Error('Failed to fetch session')) } finally { diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts new file mode 100644 index 0000000000..6d049612e9 --- /dev/null +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, setupCommonApiMocks } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlerMocks = vi.hoisted(() => ({ + betterAuthGET: vi.fn(), + betterAuthPOST: vi.fn(), + ensureAnonymousUserExists: vi.fn(), + createAnonymousGetSessionResponse: vi.fn(() => ({ + data: { + user: { id: 'anon' }, + session: { id: 'anon-session' }, + }, + })), +})) + +vi.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ + GET: handlerMocks.betterAuthGET, + POST: handlerMocks.betterAuthPOST, + }), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { handler: {} }, +})) + +vi.mock('@/lib/auth/anonymous', () => ({ + ensureAnonymousUserExists: handlerMocks.ensureAnonymousUserExists, + createAnonymousGetSessionResponse: handlerMocks.createAnonymousGetSessionResponse, +})) + +describe('auth catch-all route (DISABLE_AUTH get-session)', () => { + beforeEach(() => { + vi.resetModules() + setupCommonApiMocks() + handlerMocks.betterAuthGET.mockReset() + handlerMocks.betterAuthPOST.mockReset() + handlerMocks.ensureAnonymousUserExists.mockReset() + handlerMocks.createAnonymousGetSessionResponse.mockClear() + }) + + it('returns anonymous session in better-auth response envelope when auth is disabled', async () => { + vi.doMock('@/lib/core/config/feature-flags', () => ({ isAuthDisabled: true })) + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/get-session' + ) + const { GET } = await import('@/app/api/auth/[...all]/route') + + const res = await GET(req as any) + const json = await res.json() + + expect(handlerMocks.ensureAnonymousUserExists).toHaveBeenCalledTimes(1) + expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled() + expect(json).toEqual({ + data: { + user: { id: 'anon' }, + session: { id: 'anon-session' }, + }, + }) + }) + + it('delegates to better-auth handler when auth is enabled', async () => { + vi.doMock('@/lib/core/config/feature-flags', () => ({ isAuthDisabled: false })) + + handlerMocks.betterAuthGET.mockResolvedValueOnce( + new (await import('next/server')).NextResponse(JSON.stringify({ data: { ok: true } }), { + headers: { 'content-type': 'application/json' }, + }) as any + ) + + const req = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/get-session' + ) + const { GET } = await import('@/app/api/auth/[...all]/route') + + const res = await GET(req as any) + const json = await res.json() + + expect(handlerMocks.ensureAnonymousUserExists).not.toHaveBeenCalled() + expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1) + expect(json).toEqual({ data: { ok: true } }) + }) +}) diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 05a02b1a0b..8578b25e18 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -1,7 +1,7 @@ import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' -import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { createAnonymousGetSessionResponse, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { isAuthDisabled } from '@/lib/core/config/feature-flags' export const dynamic = 'force-dynamic' @@ -14,7 +14,7 @@ export async function GET(request: NextRequest) { if (path === 'get-session' && isAuthDisabled) { await ensureAnonymousUserExists() - return NextResponse.json(createAnonymousSession()) + return NextResponse.json(createAnonymousGetSessionResponse()) } return betterAuthGET(request) diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index a51d8585c2..aebb5d6a28 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -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 }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index c83ed6625a..0e40d18de6 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -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) }) }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 7809e5543c..deb2100b56 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -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('-') - - // Try multiple methods to get a user-friendly display name - let displayName = '' - - // Method 1: Try to extract email from ID token (works for Google, etc.) - if (acc.idToken) { - try { - const decoded = jwtDecode(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, - }) - } - } - - // Method 2: For GitHub, the accountId might be the username - if (!displayName && baseProvider === 'github') { - displayName = `${acc.accountId} (GitHub)` + 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) + + if (platformCredential) { + if (platformCredential.type !== 'oauth' || !platformCredential.accountId) { + 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) - - if (userRecord.length > 0) { - displayName = userRecord[0].email - } - } catch (_error) { - logger.warn(`[${requestId}] Error fetching user email`, { - userId: acc.userId, - }) + 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 }) } } - // Fallback: Use accountId with provider type as context - if (!displayName) { - displayName = `${acc.accountId} (${baseProvider})` + if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) { + return NextResponse.json({ credentials: [] }, { status: 200 }) } - 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, - } + return NextResponse.json( + { + credentials: [ + toCredentialResponse( + platformCredential.id, + platformCredential.displayName, + platformCredential.accountProviderId, + platformCredential.accountUpdatedAt, + platformCredential.accountScope + ), + ], + }, + { status: 200 } + ) + } + } + + 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 }) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index ab35816c72..a2f0e62b73 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -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))) diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts index af9d5d47e8..c653d35bf6 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -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 ) diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index 1a689b808d..23bd2e57e5 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -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 ) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 3ed0c576ad..d9f563b89f 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -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', diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index f6728fe696..d8b1b45741 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -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 diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 352ba5e786..7320a7bb9a 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -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) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 891e4ca4d1..4228c3f3f2 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -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 { @@ -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() diff --git a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts index 61fc0b591d..ee4b6f1958 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -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`) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts index e276111762..aaa1678cac 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts @@ -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] - - // 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 }) - } + const accountRow = credentials[0] - // 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`) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index cf7aef92a6..85729149b9 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -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, diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index fff52b0a84..97fc9b8abc 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -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() diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 87b7f14f15..7f9f990077 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -33,7 +33,6 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Update cost request started`) if (!isBillingEnabled) { - logger.debug(`[${requestId}] Billing is disabled, skipping cost update`) return NextResponse.json({ success: true, message: 'Billing disabled, cost update skipped', diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 7a9b7bdee9..e518ceb28a 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -117,8 +117,6 @@ export async function POST( const requestId = generateRequestId() try { - logger.debug(`[${requestId}] Processing OTP request for identifier: ${identifier}`) - const body = await request.json() const { email } = otpRequestSchema.parse(body) @@ -211,8 +209,6 @@ export async function PUT( const requestId = generateRequestId() try { - logger.debug(`[${requestId}] Verifying OTP for identifier: ${identifier}`) - const body = await request.json() const { email, otp } = otpVerifySchema.parse(body) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 57041c4cc5..772b1c56f4 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -42,8 +42,6 @@ export async function POST( const requestId = generateRequestId() try { - logger.debug(`[${requestId}] Processing chat request for identifier: ${identifier}`) - let parsedBody try { const rawBody = await request.json() @@ -294,8 +292,6 @@ export async function GET( const requestId = generateRequestId() try { - logger.debug(`[${requestId}] Fetching chat info for identifier: ${identifier}`) - const deploymentResult = await db .select({ id: chat.id, diff --git a/apps/sim/app/api/creators/route.ts b/apps/sim/app/api/creators/route.ts index 1113de3d45..843c0aea49 100644 --- a/apps/sim/app/api/creators/route.ts +++ b/apps/sim/app/api/creators/route.ts @@ -95,11 +95,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const data = CreateCreatorProfileSchema.parse(body) - logger.debug(`[${requestId}] Creating creator profile:`, { - referenceType: data.referenceType, - referenceId: data.referenceId, - }) - // Validate permissions if (data.referenceType === 'user') { if (data.referenceId !== session.user.id) { diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts index b48a544e28..9a91b86b8e 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -150,6 +150,7 @@ export async function POST( }) recordAudit({ + workspaceId: null, actorId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, @@ -158,7 +159,7 @@ export async function POST( resourceId: id, resourceName: result.set.name, description: `Resent credential set invitation to ${invitation.email}`, - metadata: { invitationId, email: invitation.email }, + metadata: { invitationId, targetEmail: invitation.email }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index 914dd37ace..9fcf13d212 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -186,6 +186,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: actorEmail: session.user.email ?? undefined, resourceName: result.set.name, description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`, + metadata: { targetEmail: email || undefined }, request: req, }) @@ -239,7 +240,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) } - await db + const [revokedInvitation] = await db .update(credentialSetInvitation) .set({ status: 'cancelled' }) .where( @@ -248,6 +249,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i eq(credentialSetInvitation.credentialSetId, id) ) ) + .returning({ email: credentialSetInvitation.email }) recordAudit({ workspaceId: null, @@ -259,6 +261,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i actorEmail: session.user.email ?? undefined, resourceName: result.set.name, description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`, + metadata: { targetEmail: revokedInvitation?.email ?? undefined }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index 2ea8b18890..2799feb784 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -151,8 +151,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i } const [memberToRemove] = await db - .select() + .select({ + id: credentialSetMember.id, + credentialSetId: credentialSetMember.credentialSetId, + userId: credentialSetMember.userId, + status: credentialSetMember.status, + email: user.email, + }) .from(credentialSetMember) + .innerJoin(user, eq(credentialSetMember.userId, user.id)) .where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id))) .limit(1) @@ -189,6 +196,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i actorEmail: session.user.email ?? undefined, resourceName: result.set.name, description: `Removed member from credential set "${result.set.name}"`, + metadata: { targetEmail: memberToRemove.email ?? undefined }, request: req, }) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts new file mode 100644 index 0000000000..11cee7f717 --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts new file mode 100644 index 0000000000..7da93846c7 --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -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 = {} + + 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 | 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 | 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 }) + } +} diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts new file mode 100644 index 0000000000..ac700f088e --- /dev/null +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts new file mode 100644 index 0000000000..a3d72ea90b --- /dev/null +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts new file mode 100644 index 0000000000..e0fea07f3e --- /dev/null +++ b/apps/sim/app/api/credentials/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 57def36986..8b8f9f7159 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -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 { +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}` ) diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 494058f9c1..39c91668f6 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -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, diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 3cbaa1b43b..54eafdf36f 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -148,6 +148,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: actorEmail: session.user.email ?? undefined, resourceName: name, description: `Duplicated folder "${sourceFolder.name}" as "${name}"`, + metadata: { + sourceId: sourceFolder.id, + affected: { workflows: workflowStats.succeeded, folders: folderMapping.size }, + }, request: req, }) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 64fb830e2e..96ab40c2f1 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -178,6 +178,12 @@ export async function DELETE( resourceId: id, resourceName: existingFolder.name, description: `Deleted folder "${existingFolder.name}"`, + metadata: { + affected: { + workflows: deletionStats.workflows, + subfolders: deletionStats.folders - 1, + }, + }, request, }) diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index a4ad31eef8..d6d4f019e4 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -58,8 +58,6 @@ export async function POST( const requestId = generateRequestId() try { - logger.debug(`[${requestId}] Processing form submission for identifier: ${identifier}`) - let parsedBody try { const rawBody = await request.json() @@ -300,8 +298,6 @@ export async function GET( const requestId = generateRequestId() try { - logger.debug(`[${requestId}] Fetching form info for identifier: ${identifier}`) - const deploymentResult = await db .select({ id: form.id, diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index f874c6304b..2c2e24ddb7 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -77,8 +77,6 @@ export async function POST(req: NextRequest) { } } - logger.debug(`[${requestId}] Help request includes ${images.length} images`) - const userId = session.user.id let emailText = ` Type: ${type} diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index d7f6932c22..c14d8e2681 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -281,6 +281,7 @@ export async function DELETE( resourceId: documentId, resourceName: accessCheck.document?.filename, description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`, + metadata: { fileName: accessCheck.document?.filename }, request: req, }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 4c0ba0217d..d8ac7324cf 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -255,6 +255,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceId: knowledgeBaseId, resourceName: `${createdDocuments.length} document(s)`, description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, + metadata: { + fileCount: createdDocuments.length, + fileNames: createdDocuments.map((doc) => doc.filename), + }, request: req, }) @@ -316,6 +320,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceId: knowledgeBaseId, resourceName: validatedData.filename, description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`, + metadata: { + fileName: validatedData.filename, + fileType: validatedData.mimeType, + fileSize: validatedData.fileSize, + }, request: req, }) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index c3e6993140..686f7c19cc 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -186,8 +186,6 @@ export async function POST(request: NextRequest) { valueTo: filter.valueTo, } }) - - logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`) } if (accessibleKbIds.length === 0) { @@ -220,7 +218,6 @@ export async function POST(request: NextRequest) { if (!hasQuery && hasFilters) { // Tag-only search without vector similarity - logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters) results = await handleTagOnlySearch({ knowledgeBaseIds: accessibleKbIds, topK: validatedData.topK, @@ -244,7 +241,6 @@ export async function POST(request: NextRequest) { }) } else if (hasQuery && !hasFilters) { // Vector-only search - logger.debug(`[${requestId}] Executing vector-only search`) const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK) const queryVector = JSON.stringify(await queryEmbeddingPromise) diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index 3eba10f911..dc112fe24a 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -1,11 +1,8 @@ import { db } from '@sim/db' import { document, embedding } from '@sim/db/schema' -import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' -const logger = createLogger('KnowledgeSearchUtils') - export async function getDocumentNamesByIds( documentIds: string[] ): Promise> { @@ -140,17 +137,12 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) { const { tagSlot, fieldType, operator, value, valueTo } = filter if (!isTagSlotKey(tagSlot)) { - logger.debug(`[getStructuredTagFilters] Unknown tag slot: ${tagSlot}`) return null } const column = embeddingTable[tagSlot] if (!column) return null - logger.debug( - `[getStructuredTagFilters] Processing ${tagSlot} (${fieldType}) ${operator} ${value}` - ) - // Handle text operators if (fieldType === 'text') { const stringValue = String(value) @@ -208,7 +200,6 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) { const dateStr = String(value) // Validate YYYY-MM-DD format if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { - logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`) return null } @@ -287,9 +278,6 @@ function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: an conditions.push(slotConditions[0]) } else { // Multiple conditions for same slot - OR them together - logger.debug( - `[getStructuredTagFilters] OR'ing ${slotConditions.length} conditions for ${slot}` - ) conditions.push(sql`(${sql.join(slotConditions, sql` OR `)})`) } } @@ -380,8 +368,6 @@ export async function handleTagOnlySearch(params: SearchParams): Promise`${embedding.embedding} <=> ${queryVector}::vector`.as('distance') @@ -489,23 +473,13 @@ export async function handleTagAndVectorSearch(params: SearchParams): Promise r.id), diff --git a/apps/sim/app/api/logs/execution/[executionId]/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts index 27a75298d2..90e0747b00 100644 --- a/apps/sim/app/api/logs/execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -34,10 +34,6 @@ export async function GET( const authenticatedUserId = authResult.userId - logger.debug( - `[${requestId}] Fetching execution data for: ${executionId} (auth: ${authResult.authType})` - ) - const [workflowLog] = await db .select({ id: workflowExecutionLogs.id, @@ -125,11 +121,6 @@ export async function GET( }, } - logger.debug(`[${requestId}] Successfully fetched execution data for: ${executionId}`) - logger.debug( - `[${requestId}] Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` - ) - return NextResponse.json(response) } catch (error) { logger.error(`[${requestId}] Error fetching execution data:`, error) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 1c694a59af..99f2a83089 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -23,6 +23,7 @@ import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -181,7 +182,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise }, executeAuthContext, - server.isPublic ? server.createdBy : undefined + server.isPublic ? server.createdBy : undefined, + request.headers.get(SIM_VIA_HEADER) ) default: @@ -244,7 +246,8 @@ async function handleToolsCall( serverId: string, params: { name: string; arguments?: Record } | undefined, executeAuthContext?: ExecuteAuthContext | null, - publicServerOwnerId?: string + publicServerOwnerId?: string, + simViaHeader?: string | null ): Promise { try { if (!params?.name) { @@ -300,6 +303,10 @@ async function handleToolsCall( } } + if (simViaHeader) { + headers[SIM_VIA_HEADER] = simViaHeader + } + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) const response = await fetch(executeUrl, { diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index 4229cebbfd..d44839590f 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -83,7 +83,6 @@ export const POST = withMcpAuth('read')( serverId: serverId, serverName: 'provided-schema', } as McpTool - logger.debug(`[${requestId}] Using provided schema for ${toolName}, skipping discovery`) } else { const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) tool = tools.find((t) => t.name === toolName) ?? null diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index 0c1cd871c2..3532981267 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -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) || {}) + 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 { @@ -568,7 +598,12 @@ export async function PUT( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Organization invitation ${status} for ${orgInvitation.email}`, - metadata: { invitationId, email: orgInvitation.email, status }, + metadata: { + invitationId, + targetEmail: orgInvitation.email, + targetRole: orgInvitation.role, + status, + }, request: req, }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 54281c1a9d..9b4b690711 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -423,7 +423,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ actorEmail: session.user.email ?? undefined, resourceName: organizationEntry[0]?.name, description: `Invited ${inv.email} to organization as ${role}`, - metadata: { invitationId: inv.id, email: inv.email, role }, + metadata: { invitationId: inv.id, targetEmail: inv.email, targetRole: role }, request, }) } @@ -558,7 +558,7 @@ export async function DELETE( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Revoked organization invitation for ${result[0].email}`, - metadata: { invitationId, email: result[0].email }, + metadata: { invitationId, targetEmail: result[0].email }, request, }) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 3a2f9fa876..3d850d1f97 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -173,8 +173,15 @@ export async function PUT( } const targetMember = await db - .select() + .select({ + id: member.id, + role: member.role, + userId: member.userId, + email: user.email, + name: user.name, + }) .from(member) + .innerJoin(user, eq(member.userId, user.id)) .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) .limit(1) @@ -223,7 +230,12 @@ export async function PUT( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Changed role for member ${memberId} to ${role}`, - metadata: { targetUserId: memberId, newRole: role }, + metadata: { + targetUserId: memberId, + targetEmail: targetMember[0].email ?? undefined, + targetName: targetMember[0].name ?? undefined, + changes: [{ field: 'role', from: targetMember[0].role, to: role }], + }, request, }) @@ -286,8 +298,9 @@ export async function DELETE( } const targetMember = await db - .select({ id: member.id, role: member.role }) + .select({ id: member.id, role: member.role, email: user.email, name: user.name }) .from(member) + .innerJoin(user, eq(member.userId, user.id)) .where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId))) .limit(1) @@ -331,7 +344,12 @@ export async function DELETE( session.user.id === targetUserId ? 'Left the organization' : `Removed member ${targetUserId} from organization`, - metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId }, + metadata: { + targetUserId, + targetEmail: targetMember[0].email ?? undefined, + targetName: targetMember[0].name ?? undefined, + wasSelfRemoval: session.user.id === targetUserId, + }, request, }) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 659227bbd0..6be21be8a0 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -295,7 +295,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Invited ${normalizedEmail} to organization as ${role}`, - metadata: { invitationId, email: normalizedEmail, role }, + metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role }, request, }) diff --git a/apps/sim/app/api/permission-groups/[id]/members/route.ts b/apps/sim/app/api/permission-groups/[id]/members/route.ts index 40d71b0de2..ec57c3689c 100644 --- a/apps/sim/app/api/permission-groups/[id]/members/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/members/route.ts @@ -100,8 +100,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const { userId } = addMemberSchema.parse(body) const [orgMember] = await db - .select({ id: member.id }) + .select({ id: member.id, email: user.email }) .from(member) + .innerJoin(user, eq(member.userId, user.id)) .where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId))) .limit(1) @@ -163,7 +164,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Added member ${userId} to permission group "${result.group.name}"`, - metadata: { targetUserId: userId, permissionGroupId: id }, + metadata: { + targetUserId: userId, + targetEmail: orgMember.email ?? undefined, + permissionGroupId: id, + }, request: req, }) @@ -218,8 +223,14 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i } const [memberToRemove] = await db - .select() + .select({ + id: permissionGroupMember.id, + permissionGroupId: permissionGroupMember.permissionGroupId, + userId: permissionGroupMember.userId, + email: user.email, + }) .from(permissionGroupMember) + .innerJoin(user, eq(permissionGroupMember.userId, user.id)) .where( and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id)) ) @@ -247,7 +258,12 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`, - metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id }, + metadata: { + targetUserId: memberToRemove.userId, + targetEmail: memberToRemove.email ?? undefined, + memberId, + permissionGroupId: id, + }, request: req, }) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 6b7cc934d1..556240f33b 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -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 { 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') diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index c72aab7faa..eb65f07b53 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -26,7 +26,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ try { const { id: scheduleId } = await params - logger.debug(`[${requestId}] Reactivating schedule with ID: ${scheduleId}`) const session = await getSession() if (!session?.user?.id) { @@ -116,6 +115,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Reactivated schedule for workflow ${schedule.workflowId}`, + metadata: { cronExpression: schedule.cronExpression, timezone: schedule.timezone }, request, }) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index d985cf72b1..c928b17143 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -51,7 +51,6 @@ export async function GET(request: NextRequest) { lastQueuedAt: workflowSchedule.lastQueuedAt, }) - logger.debug(`[${requestId}] Successfully queried schedules: ${dueSchedules.length} found`) logger.info(`[${requestId}] Processing ${dueSchedules.length} due scheduled workflows`) const jobQueue = await getJobQueue() diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index b7a0425fff..2ea215566e 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -24,8 +24,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ try { const session = await getSession() - logger.debug(`[${requestId}] Fetching template: ${id}`) - const result = await db .select({ template: templates, @@ -74,8 +72,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ views: sql`${templates.views} + 1`, }) .where(eq(templates.id, id)) - - logger.debug(`[${requestId}] Incremented view count for template: ${id}`) } catch (viewError) { logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError) } diff --git a/apps/sim/app/api/templates/[id]/star/route.ts b/apps/sim/app/api/templates/[id]/star/route.ts index 8f9fc19a0a..bd8b2db082 100644 --- a/apps/sim/app/api/templates/[id]/star/route.ts +++ b/apps/sim/app/api/templates/[id]/star/route.ts @@ -58,8 +58,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.debug(`[${requestId}] Adding star for template: ${id}, user: ${session.user.id}`) - // Verify the template exists const templateExists = await db .select({ id: templates.id }) @@ -133,8 +131,6 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.debug(`[${requestId}] Removing star for template: ${id}, user: ${session.user.id}`) - // Check if the star exists const existingStar = await db .select({ id: templateStars.id }) diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 74d84be2b0..55628bfc7c 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -68,8 +68,6 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) - logger.debug(`[${requestId}] Fetching templates with params:`, params) - // Check if user is a super user const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) const isSuperUser = effectiveSuperUser @@ -187,11 +185,6 @@ export async function POST(request: NextRequest) { const body = await request.json() const data = CreateTemplateSchema.parse(body) - logger.debug(`[${requestId}] Creating template:`, { - name: data.name, - workflowId: data.workflowId, - }) - // Verify the workflow exists and belongs to the user const workflowExists = await db .select({ id: workflow.id }) diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 7994c91fd0..26437d267a 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -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 }) diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 36d9040ca4..6aed016040 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -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 + 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) { - 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 }) - } + logger.warn(`[${requestId}] Credential not found`) + 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, 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 }) diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index 67566ad8a8..eecfdb48c7 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -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] - - 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`) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index c894834576..4f6828c48c 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -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 }) diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 2cf68fa533..a4e80b66f9 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -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) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { 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 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const credentials = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index 1eac6c2678..271c4e69f7 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -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) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { 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 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const credentials = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 7be86ebff0..8bf9e906d1 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -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() ) diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index 2ffecce942..24941f034d 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -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) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { 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 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const credentials = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 7e98bf6212..de161b9730 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -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) - if (!credentials.length) { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { 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 }) + 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 accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + const credentials = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const accountRow = credentials[0] + + const accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + accountRow.userId, + requestId + ) if (!accessToken) { return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) } diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index b618470e6f..ae2afd4cc0 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -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] - - 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`) diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index a07ff62c41..efdda2b3c5 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -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] - - 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`) diff --git a/apps/sim/app/api/users/me/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts index 596d2812c8..e9344b86dd 100644 --- a/apps/sim/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -18,7 +18,6 @@ export async function DELETE( const { id } = await params try { - logger.debug(`[${requestId}] Deleting API key: ${id}`) const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index 49092d86df..30afdda571 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -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(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, }) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index 687198506c..78298feb49 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -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(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) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: wsEnvKeys, + actingUserId: body.userId, + }) + } + return singleResponse({ id: permissionId, workspaceId, diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 2bd65fa0ff..f868364ae6 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -103,12 +103,10 @@ async function updateUserStatsForWand( isBYOK = false ): Promise { if (!isBillingEnabled) { - logger.debug(`[${requestId}] Billing is disabled, skipping wand usage cost update`) return } if (!usage.total_tokens || usage.total_tokens <= 0) { - logger.debug(`[${requestId}] No tokens to update in user stats`) return } @@ -146,13 +144,6 @@ async function updateUserStatsForWand( }) .where(eq(userStats.userId, userId)) - logger.debug(`[${requestId}] Updated user stats for wand usage`, { - userId, - tokensUsed: totalTokens, - costAdded: costToStore, - isBYOK, - }) - await logModelUsage({ userId, source: 'wand', @@ -291,23 +282,8 @@ export async function POST(req: NextRequest) { messages.push({ role: 'user', content: prompt }) - logger.debug( - `[${requestId}] Calling ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API for wand generation`, - { - stream, - historyLength: history.length, - endpoint: useWandAzure ? azureEndpoint : 'api.openai.com', - model: useWandAzure ? wandModelName : 'gpt-4o', - apiVersion: useWandAzure ? azureApiVersion : 'N/A', - } - ) - if (stream) { try { - logger.debug( - `[${requestId}] Starting streaming request to ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'}` - ) - logger.info( `[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}` ) @@ -327,8 +303,6 @@ export async function POST(req: NextRequest) { headers.Authorization = `Bearer ${activeOpenAIKey}` } - logger.debug(`[${requestId}] Making streaming request to: ${apiUrl}`) - const response = await fetch(apiUrl, { method: 'POST', headers, @@ -429,7 +403,6 @@ export async function POST(req: NextRequest) { try { parsed = JSON.parse(data) } catch (parseError) { - logger.debug(`[${requestId}] Skipped non-JSON line: ${data.substring(0, 100)}`) continue } diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 447527236d..f1f1fbd628 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -21,7 +21,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ try { const { id } = await params - logger.debug(`[${requestId}] Fetching webhook with ID: ${id}`) const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -77,7 +76,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< try { const { id } = await params - logger.debug(`[${requestId}] Updating webhook with ID: ${id}`) const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -129,11 +127,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - logger.debug(`[${requestId}] Updating webhook properties`, { - hasActiveUpdate: isActive !== undefined, - hasFailedCountUpdate: failedCount !== undefined, - }) - const updatedWebhook = await db .update(webhook) .set({ @@ -161,7 +154,6 @@ export async function DELETE( try { const { id } = await params - logger.debug(`[${requestId}] Deleting webhook with ID: ${id}`) const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 27a8b866aa..4d5508a125 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -112,7 +112,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ webhooks: [] }, { status: 200 }) } - logger.debug(`[${requestId}] Fetching workspace-accessible webhooks for ${session.user.id}`) const workspacePermissionRows = await db .select({ workspaceId: permissions.entityId }) .from(permissions) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index 4dbaf214eb..ef84667d5d 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -35,8 +35,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ ) } - logger.debug(`[${requestId}] Checking chat deployment status for workflow: ${id}`) - // Find any active chat deployments for this workflow const deploymentResults = await db .select({ diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index e82221c835..1dd8798a3f 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -22,6 +22,7 @@ import { } from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDeployAPI') @@ -33,8 +34,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id } = await params try { - logger.debug(`[${requestId}] Fetching deployment info for workflow: ${id}`) - const { error, workflow: workflowData } = await validateWorkflowPermissions( id, requestId, @@ -51,6 +50,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ deployedAt: null, apiKey: null, needsRedeployment: false, + isPublicApi: workflowData.isPublicApi ?? false, }) } @@ -85,7 +85,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ variables: workflowRecord?.variables || {}, } const { hasWorkflowChanged } = await import('@/lib/workflows/comparison') - needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any) + needsRedeployment = hasWorkflowChanged( + currentState as WorkflowState, + active.state as WorkflowState + ) } } @@ -98,6 +101,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt, needsRedeployment, + isPublicApi: workflowData.isPublicApi ?? false, }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) @@ -110,8 +114,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id } = await params try { - logger.debug(`[${requestId}] Deploying workflow: ${id}`) - const { error, session, @@ -269,6 +271,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: id, resourceName: workflowData?.name, description: `Deployed workflow "${workflowData?.name || id}"`, + metadata: { version: deploymentVersionId }, request, }) @@ -301,6 +304,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + const { id } = await params + + try { + const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin') + if (error) { + return createErrorResponse(error.message, error.status) + } + + const body = await request.json() + const { isPublicApi } = body + + if (typeof isPublicApi !== 'boolean') { + return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400) + } + + if (isPublicApi) { + const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import( + '@/ee/access-control/utils/permission-check' + ) + try { + await validatePublicApiAllowed(session?.user?.id) + } catch (err) { + if (err instanceof PublicApiNotAllowedError) { + return createErrorResponse('Public API access is disabled', 403) + } + throw err + } + } + + await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id)) + + logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) + + return createSuccessResponse({ isPublicApi }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to update deployment settings' + logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error }) + return createErrorResponse(message, 500) + } +} + export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -309,8 +355,6 @@ export async function DELETE( const { id } = await params try { - logger.debug(`[${requestId}] Undeploying workflow: ${id}`) - const { error, session, diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index e939fc0f09..347e77eacb 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -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' @@ -22,8 +21,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id } = await params try { - logger.debug(`[${requestId}] Fetching deployed state for workflow: ${id}`) - const authHeader = request.headers.get('authorization') let isInternalCall = false @@ -39,25 +36,24 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const response = createErrorResponse(error.message, error.status) return addNoCacheHeaders(response) } - } else { - 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 (error) { + logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error }) + 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) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 521e6cae5f..cf8431f9dc 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -12,11 +12,16 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' +import { + buildNextCallChain, + parseCallChain, + SIM_VIA_HEADER, + validateCallChain, +} from '@/lib/execution/call-chain' import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' import { processInputFileFields } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' -import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, @@ -155,10 +160,11 @@ type AsyncExecutionParams = { input: any triggerType: CoreTriggerType executionId: string + callChain?: string[] } async function handleAsyncExecution(params: AsyncExecutionParams): Promise { - const { requestId, workflowId, userId, input, triggerType, executionId } = params + const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params const payload: WorkflowExecutionPayload = { workflowId, @@ -166,6 +172,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise { const { selectedOutputs, @@ -344,19 +400,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: })() : validatedInput - const shouldUseDraftState = useDraftState ?? auth.authType === 'session' - const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: shouldUseDraftState ? 'write' : 'read', - }) - if (!workflowAuthorization.allowed) { - return NextResponse.json( - { error: workflowAuthorization.message || 'Access denied' }, - { status: workflowAuthorization.status } - ) - } + // Public API callers must not inject arbitrary workflow state overrides (code injection risk). + // stopAfterBlockId and runFromBlock are safe — they control execution flow within the deployed state. + const sanitizedWorkflowStateOverride = isPublicApiAccess ? undefined : workflowStateOverride + // Public API callers always execute the deployed state, never the draft. + const shouldUseDraftState = isPublicApiAccess + ? false + : (useDraftState ?? auth.authType === 'session') const streamHeader = req.headers.get('X-Stream-Response') === 'true' const enableSSE = streamHeader || streamParam === true const executionModeHeader = req.headers.get('X-Execution-Mode') @@ -391,6 +442,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const useAuthenticatedUserAsActor = isClientSession || (auth.authType === 'api_key' && auth.apiKeyType === 'personal') + // Authorization fetches the full workflow record and checks workspace permissions. + // Run it first so we can pass the record to preprocessing (eliminates a duplicate DB query). + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: shouldUseDraftState ? 'write' : 'read', + }) + if (!workflowAuthorization.allowed) { + return NextResponse.json( + { error: workflowAuthorization.message || 'Access denied' }, + { status: workflowAuthorization.status } + ) + } + + // Pass the pre-fetched workflow record to skip the redundant Step 1 DB query in preprocessing. const preprocessResult = await preprocessExecution({ workflowId, userId, @@ -401,6 +467,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: loggingSession, useDraftState: shouldUseDraftState, useAuthenticatedUserAsActor, + workflowRecord: workflowAuthorization.workflow ?? undefined, }) if (!preprocessResult.success) { @@ -433,6 +500,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: input, triggerType: loggingTriggerType, executionId, + callChain, }) } @@ -449,7 +517,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const workflowData = shouldUseDraftState ? await loadWorkflowFromNormalizedTables(workflowId) - : await loadDeployedWorkflowState(workflowId) + : await loadDeployedWorkflowState(workflowId, workspaceId) if (workflowData) { const deployedVariables = @@ -516,7 +584,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } - const effectiveWorkflowStateOverride = workflowStateOverride || cachedWorkflowData || undefined + const effectiveWorkflowStateOverride = + sanitizedWorkflowStateOverride || cachedWorkflowData || undefined if (!enableSSE) { logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`) @@ -537,7 +606,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: useDraftState: shouldUseDraftState, startTime: new Date().toISOString(), isClientSession, + enforceCredentialAccess: useAuthenticatedUserAsActor, workflowStateOverride: effectiveWorkflowStateOverride, + callChain, } const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {} @@ -626,12 +697,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - await loggingSession.safeCompleteWithError({ - totalDurationMs: executionResult?.metadata?.duration, - error: { message: errorMessage }, - traceSpans: executionResult?.logs as any, - }) - return NextResponse.json( { success: false, @@ -650,11 +715,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } finally { timeoutController.cleanup() if (executionId) { - try { - await cleanupExecutionBase64Cache(executionId) - } catch (error) { + void cleanupExecutionBase64Cache(executionId).catch((error) => { logger.error(`[${requestId}] Failed to cleanup base64 cache`, { error }) - } + }) } } } @@ -906,7 +969,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: useDraftState: shouldUseDraftState, startTime: new Date().toISOString(), isClientSession, + enforceCredentialAccess: useAuthenticatedUserAsActor, workflowStateOverride: effectiveWorkflowStateOverride, + callChain, } const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {} @@ -1053,15 +1118,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: logger.error(`[${requestId}] SSE execution failed: ${errorMessage}`, { isTimeout }) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - const { traceSpans, totalDuration } = executionResult - ? buildTraceSpans(executionResult) - : { traceSpans: [], totalDuration: 0 } - - await loggingSession.safeCompleteWithError({ - totalDurationMs: totalDuration || executionResult?.metadata?.duration, - error: { message: errorMessage }, - traceSpans, - }) sendEvent({ type: 'execution:error', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index e2621d6e75..140cc8ef53 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -77,18 +77,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } - logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`) const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) if (normalizedData) { - logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, { - blocksCount: Object.keys(normalizedData.blocks).length, - edgesCount: normalizedData.edges.length, - loopsCount: Object.keys(normalizedData.loops).length, - parallelsCount: Object.keys(normalizedData.parallels).length, - loops: normalizedData.loops, - }) - const finalWorkflowData = { ...workflowData, state: { @@ -347,6 +338,9 @@ export async function DELETE( resourceId: workflowId, resourceName: workflowData.name, description: `Deleted workflow "${workflowData.name}"`, + metadata: { + deleteTemplates: deleteTemplatesParam === 'delete', + }, request, }) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 1e7beaefcd..60417bf4eb 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -11,7 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' -import type { BlockState } from '@/stores/workflows/workflow/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' const logger = createLogger('WorkflowStateAPI') @@ -153,13 +153,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } // Sanitize custom tools in agent blocks before saving - const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(state.blocks as any) + const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( + state.blocks as Record + ) // Save to normalized tables // Ensure all required fields are present for WorkflowState type // Filter out blocks without type or name before saving const filteredBlocks = Object.entries(sanitizedBlocks).reduce( - (acc, [blockId, block]: [string, any]) => { + (acc, [blockId, block]: [string, BlockState]) => { if (block.type && block.name) { // Ensure all required fields are present acc[blockId] = { @@ -191,7 +193,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ deployedAt: state.deployedAt, } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState as any) + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + workflowState as WorkflowState + ) if (!saveResult.success) { logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index b83dffed3a..f53fbe05e4 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -7,6 +7,7 @@ import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowStatusAPI') @@ -64,7 +65,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any) + needsRedeployment = hasWorkflowChanged( + currentState as WorkflowState, + active.state as WorkflowState + ) } } diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 74824ddca0..7b19a0320a 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -90,6 +90,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceId: workflowId, resourceName: workflowData.name ?? undefined, description: `Updated workflow variables`, + metadata: { variableCount: Object.keys(variables).length }, request: req, }) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index 21c4f52d29..d95daf99ee 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -137,7 +137,7 @@ export async function DELETE( .where( and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace')) ) - .returning({ id: apiKey.id, name: apiKey.name }) + .returning({ id: apiKey.id, name: apiKey.name, lastUsed: apiKey.lastUsed }) if (deletedRows.length === 0) { return NextResponse.json({ error: 'API key not found' }, { status: 404 }) @@ -155,6 +155,7 @@ export async function DELETE( actorEmail: session.user.email ?? undefined, resourceName: deletedKey.name, description: `Revoked workspace API key: ${deletedKey.name}`, + metadata: { lastUsed: deletedKey.lastUsed?.toISOString() ?? null }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts index b8673546b8..c8f29e2a7b 100644 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts @@ -56,6 +56,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceId: result.id, resourceName: name, description: `Duplicated workspace to "${name}"`, + metadata: { + sourceWorkspaceId, + affected: { workflows: result.workflowsCount, folders: result.foldersCount }, + }, request: req, }) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index c53f2b7b32..25bbe66719 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -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 = (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 = (personalRow[0]?.variables as any) || {} - - // Decrypt both for UI - const decryptAll = async (src: Record) => { - const out: Record = {} - 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, @@ -166,7 +140,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ resourceType: AuditResourceType.ENVIRONMENT, resourceId: workspaceId, description: `Updated environment variables`, - metadata: { keysUpdated: Object.keys(variables) }, + metadata: { variableCount: Object.keys(variables).length }, request, }) @@ -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) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index e37cf4cfee..067256b3db 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -1,12 +1,13 @@ import crypto from 'crypto' import { db } from '@sim/db' -import { permissions, workspace } from '@sim/db/schema' +import { permissions, user, 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, @@ -131,6 +132,21 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< ) } + // Capture existing permissions and user info for audit metadata + const existingPerms = await db + .select({ + userId: permissions.userId, + permissionType: permissions.permissionType, + email: user.email, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + + const permLookup = new Map( + existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }]) + ) + await db.transaction(async (tx) => { for (const update of body.updates) { await tx @@ -155,6 +171,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) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: wsEnvKeys, + actingUserId: session.user.id, + }) + } + const updatedUsers = await getUsersWithPermissions(workspaceId) for (const update of body.updates) { @@ -167,7 +197,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Changed permissions for user ${update.userId} to ${update.permissions}`, - metadata: { targetUserId: update.userId, newPermissions: update.permissions }, + metadata: { + targetUserId: update.userId, + targetEmail: permLookup.get(update.userId)?.email ?? undefined, + changes: [ + { + field: 'permissions', + from: permLookup.get(update.userId)?.permission ?? null, + to: update.permissions, + }, + ], + }, request, }) } diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 6e057f15dc..503773be0d 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -237,6 +237,7 @@ export async function DELETE( .limit(1) // Delete workspace and all related data in a transaction + let workspaceWorkflowCount = 0 await db.transaction(async (tx) => { // Get all workflows in this workspace before deletion const workspaceWorkflows = await tx @@ -244,6 +245,8 @@ export async function DELETE( .from(workflow) .where(eq(workflow.workspaceId, workspaceId)) + workspaceWorkflowCount = workspaceWorkflows.length + if (workspaceWorkflows.length > 0) { const workflowIds = workspaceWorkflows.map((w) => w.id) @@ -299,6 +302,12 @@ export async function DELETE( resourceId: workspaceId, resourceName: workspaceRecord?.name, description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`, + metadata: { + affected: { + workflows: workspaceWorkflowCount, + }, + deleteTemplates, + }, request, }) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts index 7a5098f846..65f80c1779 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -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( diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index 85a9c2882a..fac6b6f6da 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -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) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId: invitation.workspaceId, + envKeys: wsEnvKeys, + actingUserId: session.user.id, + }) + } + recordAudit({ workspaceId: invitation.workspaceId, actorId: session.user.id, @@ -173,6 +189,7 @@ export async function GET( actorEmail: session.user.email ?? undefined, resourceName: workspaceDetails.name, description: `Accepted workspace invitation to "${workspaceDetails.name}"`, + metadata: { targetEmail: invitation.email }, request: req, }) @@ -239,7 +256,7 @@ export async function DELETE( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Revoked workspace invitation for ${invitation.email}`, - metadata: { invitationId, email: invitation.email }, + metadata: { invitationId, targetEmail: invitation.email }, request: _request, }) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 169a6d8680..543cc73727 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -225,7 +225,7 @@ export async function POST(req: NextRequest) { resourceId: workspaceId, resourceName: email, description: `Invited ${email} as ${permission}`, - metadata: { email, role: permission }, + metadata: { targetEmail: email, targetRole: permission }, request: req, }) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index fb796ce074..937c9fa5da 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -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, diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/chat/components/input/input.tsx index ea41dbb954..5e385d7f3a 100644 --- a/apps/sim/app/chat/components/input/input.tsx +++ b/apps/sim/app/chat/components/input/input.tsx @@ -3,8 +3,8 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion' -import { AlertCircle, Paperclip, Send, Square, X } from 'lucide-react' -import { Tooltip } from '@/components/emcn' +import { Paperclip, Send, Square, X } from 'lucide-react' +import { Badge, Tooltip } from '@/components/emcn' import { VoiceInput } from '@/app/chat/components/input/voice-input' const logger = createLogger('ChatInput') @@ -218,24 +218,12 @@ export const ChatInput: React.FC<{
{/* Error Messages */} {uploadErrors.length > 0 && ( -
-
-
- -
-
- File upload error -
-
- {uploadErrors.map((error, idx) => ( -
- {error} -
- ))} -
-
-
-
+
+ {uploadErrors.map((error, idx) => ( + + {error} + + ))}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index 13c01e2233..5b24a78c2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { + Badge, Button, Combobox, DatePicker, @@ -706,12 +707,10 @@ export function DocumentTagsModal({ (def) => def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase() ) && ( -
-

- Maximum tag definitions reached. You can still use existing tag - definitions, but cannot create new ones. -

-
+ + Maximum tag definitions reached. You can still use existing tag definitions, + but cannot create new ones. + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index e4d968c13c..6472b324ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -21,6 +21,7 @@ interface WorkflowDeploymentInfo { endpoint: string exampleCommand: string needsRedeployment: boolean + isPublicApi?: boolean } interface ApiDeployProps { @@ -107,12 +108,12 @@ export function ApiDeploy({ if (!info) return '' const endpoint = getBaseEndpoint() const payload = getPayloadObject() + const isPublic = info.isPublicApi switch (language) { case 'curl': return `curl -X POST \\ - -H "X-API-Key: $SIM_API_KEY" \\ - -H "Content-Type: application/json" \\ +${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -123,8 +124,7 @@ import requests response = requests.post( "${endpoint}", headers={ - "X-API-Key": os.environ.get("SIM_API_KEY"), - "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) @@ -135,8 +135,7 @@ print(response.json())` return `const response = await fetch("${endpoint}", { method: "POST", headers: { - "X-API-Key": process.env.SIM_API_KEY, - "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); @@ -148,8 +147,7 @@ console.log(data);` return `const response = await fetch("${endpoint}", { method: "POST", headers: { - "X-API-Key": process.env.SIM_API_KEY, - "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); @@ -166,12 +164,12 @@ console.log(data);` if (!info) return '' const endpoint = getBaseEndpoint() const payload = getStreamPayloadObject() + const isPublic = info.isPublicApi switch (language) { case 'curl': return `curl -X POST \\ - -H "X-API-Key: $SIM_API_KEY" \\ - -H "Content-Type: application/json" \\ +${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -182,8 +180,7 @@ import requests response = requests.post( "${endpoint}", headers={ - "X-API-Key": os.environ.get("SIM_API_KEY"), - "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}, stream=True @@ -197,8 +194,7 @@ for line in response.iter_lines(): return `const response = await fetch("${endpoint}", { method: "POST", headers: { - "X-API-Key": process.env.SIM_API_KEY, - "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); @@ -216,8 +212,7 @@ while (true) { return `const response = await fetch("${endpoint}", { method: "POST", headers: { - "X-API-Key": process.env.SIM_API_KEY, - "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); @@ -241,14 +236,14 @@ while (true) { const endpoint = getBaseEndpoint() const baseUrl = endpoint.split('/api/workflows/')[0] const payload = getPayloadObject() + const isPublic = info.isPublicApi switch (asyncExampleType) { case 'execute': switch (language) { case 'curl': return `curl -X POST \\ - -H "X-API-Key: $SIM_API_KEY" \\ - -H "Content-Type: application/json" \\ +${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ -H "X-Execution-Mode: async" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -260,8 +255,7 @@ import requests response = requests.post( "${endpoint}", headers={ - "X-API-Key": os.environ.get("SIM_API_KEY"), - "Content-Type": "application/json", +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", "X-Execution-Mode": "async" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} @@ -274,8 +268,7 @@ print(job) # Contains jobId and executionId` return `const response = await fetch("${endpoint}", { method: "POST", headers: { - "X-API-Key": process.env.SIM_API_KEY, - "Content-Type": "application/json", +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", "X-Execution-Mode": "async" }, body: JSON.stringify(${JSON.stringify(payload)}) @@ -288,8 +281,7 @@ console.log(job); // Contains jobId and executionId` return `const response = await fetch("${endpoint}", { method: "POST", headers: { - "X-API-Key": process.env.SIM_API_KEY, - "Content-Type": "application/json", +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", "X-Execution-Mode": "async" }, body: JSON.stringify(${JSON.stringify(payload)}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index 132545bda6..1dbda8f218 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge, Button, + ButtonGroup, + ButtonGroupItem, Input, Label, Modal, @@ -16,6 +18,8 @@ import { import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' +import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -40,13 +44,20 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro ) const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open }) + const updatePublicApiMutation = useUpdatePublicApi() + const { isPublicApiDisabled } = usePermissionConfig() + const [description, setDescription] = useState('') const [paramDescriptions, setParamDescriptions] = useState>({}) + const [accessMode, setAccessMode] = useState<'api_key' | 'public'>('api_key') const [isSaving, setIsSaving] = useState(false) + const [saveError, setSaveError] = useState(null) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const initialDescriptionRef = useRef('') const initialParamDescriptionsRef = useRef>({}) + const initialAccessModeRef = useRef<'api_key' | 'public'>('api_key') const starterBlockId = useMemo(() => { for (const [blockId, block] of Object.entries(blocks)) { @@ -71,6 +82,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro return normalizeInputFormatValue(blockValue) as NormalizedField[] }, [starterBlockId, subBlockValues, blocks]) + const accessModeInitializedRef = useRef(false) + useEffect(() => { if (open) { const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim() @@ -92,11 +105,24 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } setParamDescriptions(descriptions) initialParamDescriptionsRef.current = { ...descriptions } + + setSaveError(null) + accessModeInitializedRef.current = false } }, [open, workflowMetadata, inputFormat]) + useEffect(() => { + if (open && deploymentData && !accessModeInitializedRef.current) { + const initialAccess = deploymentData.isPublicApi ? 'public' : 'api_key' + setAccessMode(initialAccess) + initialAccessModeRef.current = initialAccess + accessModeInitializedRef.current = true + } + }, [open, deploymentData]) + const hasChanges = useMemo(() => { if (description.trim() !== initialDescriptionRef.current.trim()) return true + if (accessMode !== initialAccessModeRef.current) return true for (const field of inputFormat) { const currentValue = (paramDescriptions[field.name] || '').trim() @@ -105,7 +131,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } return false - }, [description, paramDescriptions, inputFormat]) + }, [description, paramDescriptions, inputFormat, accessMode]) const handleParamDescriptionChange = (fieldName: string, value: string) => { setParamDescriptions((prev) => ({ @@ -126,6 +152,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro setShowUnsavedChangesAlert(false) setDescription(initialDescriptionRef.current) setParamDescriptions({ ...initialParamDescriptionsRef.current }) + setAccessMode(initialAccessModeRef.current) onOpenChange(false) }, [onOpenChange]) @@ -138,7 +165,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } setIsSaving(true) + setSaveError(null) try { + if (accessMode !== initialAccessModeRef.current) { + await updatePublicApiMutation.mutateAsync({ + workflowId, + isPublicApi: accessMode === 'public', + }) + } + if (description.trim() !== (workflowMetadata?.description || '')) { updateWorkflow(workflowId, { description: description.trim() || 'New workflow' }) } @@ -152,6 +187,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } onOpenChange(false) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to update access settings' + setSaveError(message) } finally { setIsSaving(false) } @@ -165,6 +203,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro paramDescriptions, setValue, onOpenChange, + accessMode, + updatePublicApiMutation, ]) return ( @@ -187,6 +227,26 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro />
+ {!isPublicApiDisabled && ( +
+ + setAccessMode(val as 'api_key' | 'public')} + > + API Key + Public + +

+ {accessMode === 'public' + ? 'Anyone can call this API without authentication. You will be billed for all usage.' + : 'Requires a valid API key to call this endpoint.'} +

+
+ )} + {inputFormat.length > 0 && (
- {!isForeign && ( - - )} +
)} @@ -406,31 +412,3 @@ export function CredentialSelector({
) } - -function useCredentialRefreshTriggers(refetchCredentials: () => Promise) { - 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]) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx index 32a6dd33c4..416e07950e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx @@ -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 = ({ }, [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 = ({ }} > - Create environment variable + Create Secret ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx index 730f01b248..506eacc0dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx @@ -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(() => { return resolveSelectorForSubBlock(subBlock, { workflowId: workflowIdFromUrl, @@ -168,7 +165,6 @@ export function FileSelectorInput({ const disabledReason = finalDisabled || - isForeignCredential || missingCredential || missingDomain || missingProject || diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx index 4be4a8da3f..25fec739bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/folder-selector/components/folder-selector-input.tsx @@ -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'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx index e5b7c5d930..3df3acd464 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx @@ -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'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx index bfb9dbe4f6..ee33b320a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx @@ -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(() => { 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 ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx index b99c26bff2..e3e4e21485 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx @@ -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} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index 255d859079..7c2c77a840 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -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 { data: foreignCredentials = [], isFetching: foreignMetaLoading } = - useOAuthCredentialDetail( - shouldFetchForeignMeta ? selectedId : undefined, - activeWorkflowId || undefined, - shouldFetchForeignMeta - ) + const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState(null) - const hasForeignMeta = foreignCredentials.length > 0 - const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) + 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 (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({ Additional permissions required - {!isForeign && ( - - )} + )} @@ -251,31 +273,3 @@ export function ToolCredentialSelector({ ) } - -function useCredentialRefreshTriggers(refetchCredentials: () => Promise) { - 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]) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 6e56484001..9df538fb0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -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 (
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 ( -
-
- {credentialSubBlock?.title || 'Account'}{' '} - * -
-
- - handleParamChange(toolIndex, 'credential', value) - } - provider={oauthConfig.provider as OAuthProvider} - requiredScopes={ - credentialSubBlock?.requiredScopes || - getCanonicalScopesForProvider(oauthConfig.provider) - } - serviceId={oauthConfig.provider} - disabled={disabled} - /> -
-
- ) - } - 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) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential.ts deleted file mode 100644 index 727b09da22..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' - -export function useForeignCredential( - provider: string | undefined, - credentialId: string | undefined -) { - const [isForeign, setIsForeign] = useState(false) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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 } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts index f89971fab5..9e3830f9c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references' -import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { normalizeName } from '@/executor/constants' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' @@ -27,27 +26,12 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set(ancestorIds) accessibleIds.add(blockId) - const starterBlock = Object.values(blocks).find((block) => isInputDefinitionTrigger(block.type)) - if (starterBlock && ancestorIds.includes(starterBlock.id)) { - accessibleIds.add(starterBlock.id) - } - - const loopValues = Object.values(loops as Record) - loopValues.forEach((loop) => { - if (!loop?.nodes) return - if (loop.nodes.includes(blockId)) { - accessibleIds.add(loop.id) // Add the loop block itself - loop.nodes.forEach((nodeId) => accessibleIds.add(nodeId)) - } + Object.values(loops as Record).forEach((loop) => { + if (loop?.nodes?.includes(blockId)) accessibleIds.add(loop.id) }) - const parallelValues = Object.values(parallels as Record) - parallelValues.forEach((parallel) => { - if (!parallel?.nodes) return - if (parallel.nodes.includes(blockId)) { - accessibleIds.add(parallel.id) // Add the parallel block itself - parallel.nodes.forEach((nodeId) => accessibleIds.add(nodeId)) - } + Object.values(parallels as Record).forEach((parallel) => { + if (parallel?.nodes?.includes(blockId)) accessibleIds.add(parallel.id) }) const prefixes = new Set() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5b8b66f2b0..f88b9d9122 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 936e8f1146..87b042b695 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -473,7 +473,7 @@ function ConnectionsSection({
)} - {/* Environment Variables */} + {/* Secrets */} {envVars.length > 0 && (
- Environment Variables + Secrets 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({ @@ -149,7 +142,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { strokeWidth={2} /> 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' @@ -202,7 +195,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{workspaceKeys.length === 0 ? (
- No workspace API keys yet + No workspace Sim keys yet
) : ( workspaceKeys.map((key) => ( @@ -308,7 +301,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{isConflict && (
- Workspace API key with the same name overrides this. Rename your + Workspace Sim key with the same name overrides this. Rename your personal key to use it.
)} @@ -324,7 +317,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { filteredWorkspaceKeys.length === 0 && (personalKeys.length > 0 || workspaceKeys.length > 0) && (
- No API keys found matching "{searchTerm}" + No Sim keys found matching "{searchTerm}"
)} @@ -338,7 +331,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
- Allow personal API keys + Allow personal Sim keys @@ -390,7 +383,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { {/* Delete Confirmation Dialog */} - Delete API key + Delete Sim key

Deleting{' '} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx index 439280bf25..12b0692159 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components/create-api-key-modal/create-api-key-modal.tsx @@ -62,8 +62,8 @@ export function CreateApiKeyModal({ if (isDuplicate) { setCreateError( keyType === 'workspace' - ? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.` - : `A personal API key named "${trimmedName}" already exists. Please choose a different name.` + ? `A workspace Sim key named "${trimmedName}" already exists. Please choose a different name.` + : `A personal Sim key named "${trimmedName}" already exists. Please choose a different name.` ) return } @@ -86,11 +86,11 @@ export function CreateApiKeyModal({ } catch (error: unknown) { logger.error('API key creation failed:', { error }) const errorMessage = - error instanceof Error ? error.message : 'Failed to create API key. Please try again.' + error instanceof Error ? error.message : 'Failed to create Sim key. Please try again.' if (errorMessage.toLowerCase().includes('already exists')) { setCreateError(errorMessage) } else { - setCreateError('Failed to create API key. Please check your connection and try again.') + setCreateError('Failed to create Sim key. Please check your connection and try again.') } } } @@ -113,7 +113,7 @@ export function CreateApiKeyModal({ {/* Create API Key Dialog */} - Create new API key + Create new Sim key

{keyType === 'workspace' @@ -125,7 +125,7 @@ export function CreateApiKeyModal({ {canManageWorkspaceKeys && (

- API Key Type + Sim Key Type

- Enter a name for your API key to help you identify it later. + Enter a name for your Sim key to help you identify it later.

{/* Hidden decoy fields to prevent browser autofill */} - Your API key has been created + Your Sim key has been created

- This is the only time you will see your API key.{' '} + This is the only time you will see your Sim key.{' '} Copy it now and store it securely. diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx new file mode 100644 index 0000000000..dab88c358b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager.tsx @@ -0,0 +1,1988 @@ +'use client' + +import { createElement, useCallback, useEffect, useMemo, useState } from 'react' +import { createLogger } from '@sim/logger' +import { AlertTriangle, Check, Clipboard, Plus, Search, Share2, X } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + Badge, + Button, + ButtonGroup, + ButtonGroupItem, + Combobox, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Textarea, + Tooltip, +} from '@/components/emcn' +import { Skeleton, Input as UiInput } from '@/components/ui' +import { useSession } from '@/lib/auth/auth-client' +import { + clearPendingCredentialCreateRequest, + PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, + type PendingCredentialCreateRequest, + readPendingCredentialCreateRequest, +} from '@/lib/credentials/client-state' +import { + getCanonicalScopesForProvider, + getServiceConfigByProviderId, + type OAuthProvider, +} 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 { isValidEnvVarName } from '@/executor/constants' +import { + useCreateWorkspaceCredential, + useDeleteWorkspaceCredential, + useRemoveWorkspaceCredentialMember, + useUpdateWorkspaceCredential, + useUpsertWorkspaceCredentialMember, + useWorkspaceCredentialMembers, + useWorkspaceCredentials, + type WorkspaceCredential, + type WorkspaceCredentialRole, +} from '@/hooks/queries/credentials' +import { + usePersonalEnvironment, + useSavePersonalEnvironment, + useUpsertWorkspaceEnvironment, + useWorkspaceEnvironment, +} from '@/hooks/queries/environment' +import { + useConnectOAuthService, + useDisconnectOAuthService, + useOAuthConnections, +} from '@/hooks/queries/oauth-connections' +import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' +import { useSettingsModalStore } from '@/stores/modals/settings/store' + +const logger = createLogger('CredentialsManager') + +const roleOptions = [ + { value: 'member', label: 'Member' }, + { value: 'admin', label: 'Admin' }, +] as const + +type CreateCredentialType = 'oauth' | 'secret' +type SecretScope = 'workspace' | 'personal' +type SecretInputMode = 'single' | 'bulk' + +const createTypeOptions = [ + { value: 'secret', label: 'Secret' }, + { value: 'oauth', label: 'OAuth Account' }, +] as const + +interface ParsedEnvEntry { + key: string + value: string +} + +/** + * Parses `.env`-style text into key-value pairs. + * Supports `KEY=VALUE`, quoted values, comments (#), and blank lines. + */ +function parseEnvText(text: string): { entries: ParsedEnvEntry[]; errors: string[] } { + const entries: ParsedEnvEntry[] = [] + const errors: string[] = [] + const seenKeys = new Set() + + const lines = text.split('\n') + for (let i = 0; i < lines.length; i++) { + const raw = lines[i].trim() + if (!raw || raw.startsWith('#')) continue + + const eqIndex = raw.indexOf('=') + if (eqIndex === -1) { + errors.push(`Line ${i + 1}: missing "=" separator`) + continue + } + + const key = raw.slice(0, eqIndex).trim() + let value = raw.slice(eqIndex + 1).trim() + + if (!key) { + errors.push(`Line ${i + 1}: empty key`) + continue + } + + if (!isValidEnvVarName(key)) { + errors.push(`Line ${i + 1}: "${key}" must contain only letters, numbers, and underscores`) + continue + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + + if (!value) { + errors.push(`Line ${i + 1}: "${key}" has an empty value`) + continue + } + + if (seenKeys.has(key.toUpperCase())) { + errors.push(`Line ${i + 1}: duplicate key "${key}"`) + continue + } + + seenKeys.add(key.toUpperCase()) + entries.push({ key, value }) + } + + return { entries, errors } +} + +function getSecretCredentialType( + scope: SecretScope +): Extract { + return scope === 'workspace' ? 'env_workspace' : 'env_personal' +} + +function typeBadgeVariant(_type: WorkspaceCredential['type']): 'gray-secondary' { + return 'gray-secondary' +} + +function typeLabel(type: WorkspaceCredential['type']): string { + if (type === 'oauth') return 'oauth' + if (type === 'env_workspace') return 'workspace secret' + return 'personal secret' +} + +function normalizeEnvKeyInput(raw: string): string { + const trimmed = raw.trim() + const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed) + return wrappedMatch ? wrappedMatch[1] : trimmed +} + +function CredentialSkeleton() { + return ( +

+
+ + +
+
+ + +
+
+ ) +} + +interface CredentialsManagerProps { + onOpenChange?: (open: boolean) => void +} + +export function CredentialsManager({ onOpenChange }: CredentialsManagerProps) { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + + const [searchTerm, setSearchTerm] = useState('') + const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [memberRole, setMemberRole] = useState('admin') + const [memberUserId, setMemberUserId] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) + const [createType, setCreateType] = useState('secret') + const [createSecretScope, setCreateSecretScope] = useState('workspace') + const [createDisplayName, setCreateDisplayName] = useState('') + const [createDescription, setCreateDescription] = useState('') + const [createEnvKey, setCreateEnvKey] = useState('') + const [createEnvValue, setCreateEnvValue] = useState('') + const [isCreateEnvValueFocused, setIsCreateEnvValueFocused] = useState(false) + const [createOAuthProviderId, setCreateOAuthProviderId] = useState('') + const [createSecretInputMode, setCreateSecretInputMode] = useState('single') + const [createBulkEntries, setCreateBulkEntries] = useState([]) + const [createError, setCreateError] = useState(null) + const [detailsError, setDetailsError] = useState(null) + const [selectedEnvValueDraft, setSelectedEnvValueDraft] = useState('') + const [isEditingEnvValue, setIsEditingEnvValue] = useState(false) + const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('') + const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('') + const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false) + const [copyIdSuccess, setCopyIdSuccess] = useState(false) + const [credentialToDelete, setCredentialToDelete] = useState(null) + const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false) + const [deleteError, setDeleteError] = useState(null) + const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) + const [unsavedChangesAlertSource, setUnsavedChangesAlertSource] = useState< + 'back' | 'modal-close' + >('back') + const { data: session } = useSession() + const currentUserId = session?.user?.id || '' + + const { + data: credentials = [], + isPending: credentialsLoading, + refetch: refetchCredentials, + } = useWorkspaceCredentials({ + workspaceId, + enabled: Boolean(workspaceId), + }) + + const { data: oauthConnections = [] } = useOAuthConnections() + const connectOAuthService = useConnectOAuthService() + const disconnectOAuthService = useDisconnectOAuthService() + const savePersonalEnvironment = useSavePersonalEnvironment() + const upsertWorkspaceEnvironment = useUpsertWorkspaceEnvironment() + const { data: personalEnvironment = {} } = usePersonalEnvironment() + const { data: workspaceEnvironmentData } = useWorkspaceEnvironment(workspaceId, { + select: (data) => data, + }) + + const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null) + const selectedCredential = useMemo( + () => credentials.find((credential) => credential.id === selectedCredentialId) || null, + [credentials, selectedCredentialId] + ) + + const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers( + selectedCredential?.id + ) + + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + const deleteCredential = useDeleteWorkspaceCredential() + const upsertMember = useUpsertWorkspaceCredentialMember() + const removeMember = useRemoveWorkspaceCredentialMember() + const oauthServiceNameByProviderId = useMemo( + () => new Map(oauthConnections.map((service) => [service.providerId, service.name])), + [oauthConnections] + ) + const resolveProviderLabel = (providerId?: string | null): string => { + if (!providerId) return '' + return oauthServiceNameByProviderId.get(providerId) || providerId + } + + const filteredCredentials = useMemo(() => { + if (!searchTerm.trim()) return credentials + const normalized = searchTerm.toLowerCase() + return credentials.filter((credential) => { + return ( + credential.displayName.toLowerCase().includes(normalized) || + (credential.description || '').toLowerCase().includes(normalized) || + (credential.providerId || '').toLowerCase().includes(normalized) || + resolveProviderLabel(credential.providerId).toLowerCase().includes(normalized) || + typeLabel(credential.type).toLowerCase().includes(normalized) + ) + }) + }, [credentials, searchTerm, oauthConnections]) + + const sortedCredentials = useMemo(() => { + return [...filteredCredentials].sort((a, b) => { + const aDate = new Date(a.updatedAt).getTime() + const bDate = new Date(b.updatedAt).getTime() + return bDate - aDate + }) + }, [filteredCredentials]) + + const oauthServiceOptions = useMemo( + () => + oauthConnections.map((service) => ({ + value: service.providerId, + label: service.name, + icon: getServiceConfigByProviderId(service.providerId)?.icon, + })), + [oauthConnections] + ) + + const activeMembers = useMemo( + () => members.filter((member) => member.status === 'active'), + [members] + ) + const adminMemberCount = useMemo( + () => activeMembers.filter((member) => member.role === 'admin').length, + [activeMembers] + ) + + const workspaceUserOptions = useMemo(() => { + const activeMemberUserIds = new Set(activeMembers.map((member) => member.userId)) + return (workspacePermissions?.users || []) + .filter((user) => !activeMemberUserIds.has(user.userId)) + .map((user) => ({ + value: user.userId, + label: user.name || user.email, + })) + }, [workspacePermissions?.users, activeMembers]) + + const selectedOAuthService = useMemo( + () => oauthConnections.find((service) => service.providerId === createOAuthProviderId) || null, + [oauthConnections, createOAuthProviderId] + ) + const createOAuthRequiredScopes = useMemo(() => { + if (!createOAuthProviderId) return [] + if (selectedOAuthService?.scopes?.length) { + return selectedOAuthService.scopes + } + return getCanonicalScopesForProvider(createOAuthProviderId) + }, [selectedOAuthService, createOAuthProviderId]) + const createSecretType = useMemo( + () => getSecretCredentialType(createSecretScope), + [createSecretScope] + ) + const selectedExistingEnvCredential = useMemo(() => { + if (createType !== 'secret' || createSecretInputMode !== 'single') return null + const envKey = normalizeEnvKeyInput(createEnvKey) + if (!envKey) return null + return ( + credentials.find( + (row) => + row.type === createSecretType && (row.envKey || '').toLowerCase() === envKey.toLowerCase() + ) ?? null + ) + }, [credentials, createEnvKey, createSecretType, createType, createSecretInputMode]) + + const crossScopeEnvConflict = useMemo(() => { + if (createType !== 'secret' || createSecretInputMode !== 'single') return null + if (createSecretScope !== 'personal') return null + const envKey = normalizeEnvKeyInput(createEnvKey) + if (!envKey) return null + return ( + credentials.find( + (row) => + row.type === 'env_workspace' && (row.envKey || '').toLowerCase() === envKey.toLowerCase() + ) ?? null + ) + }, [credentials, createEnvKey, createSecretScope, createType, createSecretInputMode]) + + const existingOAuthDisplayName = useMemo(() => { + if (createType !== 'oauth') return null + const name = createDisplayName.trim() + if (!name) return null + return ( + credentials.find( + (row) => row.type === 'oauth' && row.displayName.toLowerCase() === name.toLowerCase() + ) ?? null + ) + }, [credentials, createDisplayName, createType]) + const selectedEnvCurrentValue = useMemo(() => { + if (!selectedCredential || selectedCredential.type === 'oauth') return '' + const envKey = selectedCredential.envKey || '' + if (!envKey) return '' + + if (selectedCredential.type === 'env_workspace') { + return workspaceEnvironmentData?.workspace?.[envKey] || '' + } + + if (selectedCredential.envOwnerUserId && selectedCredential.envOwnerUserId !== currentUserId) { + return '' + } + + return personalEnvironment[envKey]?.value || workspaceEnvironmentData?.personal?.[envKey] || '' + }, [selectedCredential, workspaceEnvironmentData, personalEnvironment, currentUserId]) + const isEnvValueDirty = useMemo(() => { + if (!selectedCredential || selectedCredential.type === 'oauth') return false + return selectedEnvValueDraft !== selectedEnvCurrentValue + }, [selectedCredential, selectedEnvValueDraft, selectedEnvCurrentValue]) + + const isDescriptionDirty = useMemo(() => { + if (!selectedCredential) return false + return selectedDescriptionDraft !== (selectedCredential.description || '') + }, [selectedCredential, selectedDescriptionDraft]) + + const isDisplayNameDirty = useMemo(() => { + if (!selectedCredential) return false + return selectedDisplayNameDraft !== selectedCredential.displayName + }, [selectedCredential, selectedDisplayNameDraft]) + + const isDetailsDirty = isEnvValueDirty || isDescriptionDirty || isDisplayNameDirty + const [isSavingDetails, setIsSavingDetails] = useState(false) + + const handleSaveDetails = async () => { + if (!selectedCredential || !isSelectedAdmin || !isDetailsDirty) return + setDetailsError(null) + setIsSavingDetails(true) + + try { + if (isDisplayNameDirty || isDescriptionDirty) { + await updateCredential.mutateAsync({ + credentialId: selectedCredential.id, + ...(isDisplayNameDirty && selectedCredential.type === 'oauth' + ? { displayName: selectedDisplayNameDraft.trim() } + : {}), + ...(isDescriptionDirty ? { description: selectedDescriptionDraft.trim() || null } : {}), + }) + } + + if (isEnvValueDirty && canEditSelectedEnvValue) { + const envKey = selectedCredential.envKey || '' + if (envKey) { + if (selectedCredential.type === 'env_workspace') { + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { [envKey]: selectedEnvValueDraft }, + }) + } else { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + await savePersonalEnvironment.mutateAsync({ + variables: { ...personalVariables, [envKey]: selectedEnvValueDraft }, + }) + } + } + } + + await refetchCredentials() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to save changes' + setDetailsError(message) + logger.error('Failed to save credential details', error) + } finally { + setIsSavingDetails(false) + } + } + + const handleBackAttempt = useCallback(() => { + if (isDetailsDirty && !isSavingDetails) { + setUnsavedChangesAlertSource('back') + setShowUnsavedChangesAlert(true) + } else { + setSelectedCredentialId(null) + } + }, [isDetailsDirty, isSavingDetails]) + + const handleDiscardChanges = useCallback(() => { + setShowUnsavedChangesAlert(false) + setSelectedEnvValueDraft(selectedEnvCurrentValue) + setSelectedDescriptionDraft(selectedCredential?.description || '') + setSelectedDisplayNameDraft(selectedCredential?.displayName || '') + setSelectedCredentialId(null) + }, [selectedEnvCurrentValue, selectedCredential]) + + const handleDiscardAndClose = useCallback(() => { + setShowUnsavedChangesAlert(false) + useSettingsModalStore.getState().setHasUnsavedChanges(false) + useSettingsModalStore.getState().setOnCloseAttempt(null) + onOpenChange?.(false) + }, [onOpenChange]) + + const handleCloseAttemptFromModal = useCallback(() => { + if (selectedCredentialId && isDetailsDirty && !isSavingDetails) { + setUnsavedChangesAlertSource('modal-close') + setShowUnsavedChangesAlert(true) + } + }, [selectedCredentialId, isDetailsDirty, isSavingDetails]) + + useEffect(() => { + const store = useSettingsModalStore.getState() + if (selectedCredentialId && isDetailsDirty) { + store.setHasUnsavedChanges(true) + store.setOnCloseAttempt(handleCloseAttemptFromModal) + } else { + store.setHasUnsavedChanges(false) + store.setOnCloseAttempt(null) + } + }, [selectedCredentialId, isDetailsDirty, handleCloseAttemptFromModal]) + + useEffect(() => { + return () => { + const store = useSettingsModalStore.getState() + store.setHasUnsavedChanges(false) + store.setOnCloseAttempt(null) + } + }, []) + + const applyPendingCredentialCreateRequest = useCallback( + (request: PendingCredentialCreateRequest) => { + if (request.workspaceId !== workspaceId) { + return + } + + if (Date.now() - request.requestedAt > 15 * 60 * 1000) { + clearPendingCredentialCreateRequest() + return + } + + setShowCreateModal(true) + setShowCreateOAuthRequiredModal(false) + setCreateError(null) + setCreateDescription('') + setCreateEnvValue('') + + if (request.type === 'oauth') { + setCreateType('oauth') + setCreateOAuthProviderId(request.providerId) + setCreateDisplayName(request.displayName) + setCreateEnvKey('') + } else { + setCreateType('secret') + setCreateSecretScope(request.type === 'env_workspace' ? 'workspace' : 'personal') + setCreateOAuthProviderId('') + setCreateDisplayName('') + setCreateEnvKey(request.envKey || '') + } + + clearPendingCredentialCreateRequest() + }, + [workspaceId] + ) + + useEffect(() => { + if (!workspaceId) return + const request = readPendingCredentialCreateRequest() + if (!request) return + applyPendingCredentialCreateRequest(request) + }, [workspaceId, applyPendingCredentialCreateRequest]) + + useEffect(() => { + if (!workspaceId) return + + const handlePendingCreateRequest = (event: Event) => { + const request = (event as CustomEvent).detail + if (!request) return + applyPendingCredentialCreateRequest(request) + } + + window.addEventListener( + PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, + handlePendingCreateRequest as EventListener + ) + + return () => { + window.removeEventListener( + PENDING_CREDENTIAL_CREATE_REQUEST_EVENT, + handlePendingCreateRequest as EventListener + ) + } + }, [workspaceId, applyPendingCredentialCreateRequest]) + + useEffect(() => { + if (!selectedCredential) { + setSelectedEnvValueDraft('') + setIsEditingEnvValue(false) + setSelectedDescriptionDraft('') + setSelectedDisplayNameDraft('') + return + } + + setDetailsError(null) + setSelectedDescriptionDraft(selectedCredential.description || '') + setSelectedDisplayNameDraft(selectedCredential.displayName) + + if (selectedCredential.type === 'oauth') { + setSelectedEnvValueDraft('') + setIsEditingEnvValue(false) + return + } + + const envKey = selectedCredential.envKey || '' + if (!envKey) { + setSelectedEnvValueDraft('') + return + } + + setSelectedEnvValueDraft(selectedEnvCurrentValue) + setIsEditingEnvValue(false) + }, [selectedCredential, selectedEnvCurrentValue]) + + const isSelectedAdmin = selectedCredential?.role === 'admin' + const selectedOAuthServiceConfig = useMemo(() => { + if ( + !selectedCredential || + selectedCredential.type !== 'oauth' || + !selectedCredential.providerId + ) { + return null + } + + return getServiceConfigByProviderId(selectedCredential.providerId) + }, [selectedCredential]) + + const resetCreateForm = () => { + setCreateType('secret') + setCreateSecretScope('workspace') + setCreateSecretInputMode('single') + setCreateDisplayName('') + setCreateDescription('') + setCreateEnvKey('') + setCreateEnvValue('') + setCreateBulkEntries([]) + setCreateOAuthProviderId('') + setCreateError(null) + setShowCreateOAuthRequiredModal(false) + } + + const handleSelectCredential = (credential: WorkspaceCredential) => { + setSelectedCredentialId(credential.id) + setDetailsError(null) + } + + const canEditSelectedEnvValue = useMemo(() => { + if (!selectedCredential || selectedCredential.type === 'oauth') return false + if (!isSelectedAdmin) return false + if (selectedCredential.type === 'env_workspace') return true + return Boolean( + selectedCredential.envOwnerUserId && + currentUserId && + selectedCredential.envOwnerUserId === currentUserId + ) + }, [selectedCredential, isSelectedAdmin, currentUserId]) + + const handleCreateCredential = async () => { + if (!workspaceId) return + setCreateError(null) + const normalizedDescription = createDescription.trim() + + try { + if (createType === 'oauth') { + if (!selectedOAuthService) { + setCreateError('Select an OAuth service before connecting.') + return + } + if (!createDisplayName.trim()) { + setCreateError('Display name is required.') + return + } + setShowCreateOAuthRequiredModal(true) + return + } + + if (createSecretInputMode === 'bulk') { + await handleBulkCreateSecrets() + return + } + + if (!createEnvKey.trim()) return + const normalizedEnvKey = normalizeEnvKeyInput(createEnvKey) + if (!isValidEnvVarName(normalizedEnvKey)) { + setCreateError('Secret key must contain only letters, numbers, and underscores.') + return + } + if (!createEnvValue.trim()) { + setCreateError('Secret value is required.') + return + } + + if (createSecretType === 'env_personal') { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + + await savePersonalEnvironment.mutateAsync({ + variables: { + ...personalVariables, + [normalizedEnvKey]: createEnvValue.trim(), + }, + }) + } else { + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { + ...workspaceVariables, + [normalizedEnvKey]: createEnvValue.trim(), + }, + }) + } + + const response = await createCredential.mutateAsync({ + workspaceId, + type: createSecretType, + envKey: normalizedEnvKey, + description: normalizedDescription || undefined, + }) + const credentialId = response?.credential?.id + if (credentialId) { + setSelectedCredentialId(credentialId) + } + + await refetchCredentials() + + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to create secret' + setCreateError(message) + logger.error('Failed to create credential', error) + } + } + + const handleBulkCreateSecrets = async () => { + if (!workspaceId) return + setCreateError(null) + + const entries = createBulkEntries + .map((e) => ({ key: e.key.trim(), value: e.value.trim() })) + .filter((e) => e.key || e.value) + + if (entries.length === 0) { + setCreateError('Add at least one secret.') + return + } + + const errors: string[] = [] + const seenKeys = new Set() + for (let i = 0; i < entries.length; i++) { + const { key, value } = entries[i] + if (!key) { + errors.push(`Row ${i + 1}: empty key`) + continue + } + if (!isValidEnvVarName(key)) { + errors.push(`Row ${i + 1}: "${key}" must contain only letters, numbers, and underscores`) + continue + } + if (!value) { + errors.push(`Row ${i + 1}: "${key}" has an empty value`) + continue + } + if (seenKeys.has(key.toUpperCase())) { + errors.push(`Row ${i + 1}: duplicate key "${key}"`) + continue + } + seenKeys.add(key.toUpperCase()) + } + + if (errors.length > 0) { + setCreateError(errors.join('\n')) + return + } + + try { + const newVars: Record = {} + for (const entry of entries) { + newVars[entry.key] = entry.value + } + + if (createSecretType === 'env_personal') { + const personalVariables = Object.entries(personalEnvironment).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.value, + }), + {} as Record + ) + + await savePersonalEnvironment.mutateAsync({ + variables: { ...personalVariables, ...newVars }, + }) + } else { + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { ...workspaceVariables, ...newVars }, + }) + } + + let lastCredentialId: string | null = null + for (const entry of entries) { + const response = await createCredential.mutateAsync({ + workspaceId, + type: createSecretType, + envKey: entry.key, + }) + if (response?.credential?.id) { + lastCredentialId = response.credential.id + } + } + + if (lastCredentialId) { + setSelectedCredentialId(lastCredentialId) + } + + await refetchCredentials() + + setShowCreateModal(false) + resetCreateForm() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to create secrets' + setCreateError(message) + logger.error('Failed to bulk create secrets', error) + } + } + + const handleConnectOAuthService = async () => { + if (!selectedOAuthService) { + setCreateError('Select an OAuth service before connecting.') + return + } + + const displayName = createDisplayName.trim() + if (!displayName) { + setCreateError('Display name is required.') + return + } + + setCreateError(null) + try { + await fetch('/api/credentials/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId, + providerId: selectedOAuthService.providerId, + displayName, + description: createDescription.trim() || undefined, + }), + }) + + window.sessionStorage.setItem( + 'sim.oauth-connect-pending', + JSON.stringify({ + displayName, + providerId: selectedOAuthService.providerId, + preCount: credentials.filter((c) => c.type === 'oauth').length, + workspaceId, + }) + ) + + await connectOAuthService.mutateAsync({ + providerId: selectedOAuthService.providerId, + callbackURL: window.location.href, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to start OAuth connection' + setCreateError(message) + logger.error('Failed to connect OAuth service', error) + } + } + + const handleDeleteClick = (credential: WorkspaceCredential) => { + setCredentialToDelete(credential) + setDeleteError(null) + setShowDeleteConfirmDialog(true) + } + + const handleConfirmDelete = async () => { + if (!credentialToDelete) return + setDeleteError(null) + + try { + if (credentialToDelete.type === 'oauth') { + if (!credentialToDelete.accountId || !credentialToDelete.providerId) { + const errorMessage = + 'Cannot disconnect: missing account information. Please try reconnecting this credential first.' + setDeleteError(errorMessage) + logger.error('Cannot disconnect OAuth credential: missing accountId or providerId') + return + } + await disconnectOAuthService.mutateAsync({ + provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId, + providerId: credentialToDelete.providerId, + serviceId: credentialToDelete.providerId, + accountId: credentialToDelete.accountId, + }) + await refetchCredentials() + window.dispatchEvent( + new CustomEvent('oauth-credentials-updated', { + detail: { providerId: credentialToDelete.providerId, workspaceId }, + }) + ) + } else { + await deleteCredential.mutateAsync(credentialToDelete.id) + } + if (selectedCredentialId === credentialToDelete.id) { + setSelectedCredentialId(null) + } + setShowDeleteConfirmDialog(false) + setCredentialToDelete(null) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete credential' + setDeleteError(message) + logger.error('Failed to delete credential', error) + } + } + + const [isPromoting, setIsPromoting] = useState(false) + const [isShareingWithWorkspace, setIsSharingWithWorkspace] = useState(false) + + const handleShareWithWorkspace = async () => { + if (!selectedCredential || !isSelectedAdmin) return + const usersToAdd = workspaceUserOptions + if (usersToAdd.length === 0) return + + setDetailsError(null) + setIsSharingWithWorkspace(true) + + try { + for (const user of usersToAdd) { + await upsertMember.mutateAsync({ + credentialId: selectedCredential.id, + userId: user.value, + role: 'member', + }) + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to share with workspace' + setDetailsError(message) + logger.error('Failed to share credential with workspace', error) + } finally { + setIsSharingWithWorkspace(false) + } + } + + const handlePromoteToWorkspace = async () => { + if (!selectedCredential || selectedCredential.type !== 'env_personal' || !workspaceId) return + const envKey = selectedCredential.envKey || '' + if (!envKey) return + + setDetailsError(null) + setIsPromoting(true) + + try { + const currentValue = + personalEnvironment[envKey]?.value || workspaceEnvironmentData?.personal?.[envKey] || '' + + if (!currentValue) { + setDetailsError('Cannot promote: secret value is empty.') + setIsPromoting(false) + return + } + + const workspaceVariables = workspaceEnvironmentData?.workspace ?? {} + await upsertWorkspaceEnvironment.mutateAsync({ + workspaceId, + variables: { ...workspaceVariables, [envKey]: currentValue }, + }) + + const response = await createCredential.mutateAsync({ + workspaceId, + type: 'env_workspace', + envKey, + description: selectedCredential.description || undefined, + }) + + await deleteCredential.mutateAsync(selectedCredential.id) + + const newCredentialId = response?.credential?.id + if (newCredentialId) { + setSelectedCredentialId(newCredentialId) + } else { + setSelectedCredentialId(null) + } + + await refetchCredentials() + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to promote secret' + setDetailsError(message) + logger.error('Failed to promote personal secret to workspace', error) + } finally { + setIsPromoting(false) + } + } + + const handleReconnectOAuth = async () => { + if ( + !selectedCredential || + selectedCredential.type !== 'oauth' || + !selectedCredential.providerId || + !workspaceId + ) + return + + setDetailsError(null) + + try { + await fetch('/api/credentials/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId, + providerId: selectedCredential.providerId, + displayName: selectedCredential.displayName, + description: selectedCredential.description || undefined, + credentialId: selectedCredential.id, + }), + }) + + window.sessionStorage.setItem( + 'sim.oauth-connect-pending', + JSON.stringify({ + displayName: selectedCredential.displayName, + providerId: selectedCredential.providerId, + preCount: credentials.filter((c) => c.type === 'oauth').length, + workspaceId, + reconnect: true, + }) + ) + + await connectOAuthService.mutateAsync({ + providerId: selectedCredential.providerId, + callbackURL: window.location.href, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to start reconnect' + setDetailsError(message) + logger.error('Failed to reconnect OAuth credential', error) + } + } + + const handleAddMember = async () => { + if (!selectedCredential || !memberUserId) return + try { + await upsertMember.mutateAsync({ + credentialId: selectedCredential.id, + userId: memberUserId, + role: memberRole, + }) + setMemberUserId('') + setMemberRole('admin') + } catch (error) { + logger.error('Failed to add credential member', error) + } + } + + const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { + if (!selectedCredential) return + const currentMember = activeMembers.find((member) => member.userId === userId) + if (currentMember?.role === role) return + try { + await upsertMember.mutateAsync({ + credentialId: selectedCredential.id, + userId, + role, + }) + } catch (error) { + logger.error('Failed to change member role', error) + } + } + + const handleRemoveMember = async (userId: string) => { + if (!selectedCredential) return + try { + await removeMember.mutateAsync({ + credentialId: selectedCredential.id, + userId, + }) + } catch (error) { + logger.error('Failed to remove credential member', error) + } + } + + const hasCredentials = credentials && credentials.length > 0 + const showNoResults = + searchTerm.trim() && sortedCredentials.length === 0 && credentials.length > 0 + + const createModalJsx = ( + { + setShowCreateModal(open) + if (!open) resetCreateForm() + }} + > + + Create Secret + + {(createError || + existingOAuthDisplayName || + selectedExistingEnvCredential || + crossScopeEnvConflict) && ( +
+ {createError && ( + + {createError} + + )} + {existingOAuthDisplayName && ( + + A secret named "{existingOAuthDisplayName.displayName}" already exists. + + )} + {selectedExistingEnvCredential && ( + + A secret with key "{selectedExistingEnvCredential.displayName}" already exists. + + )} + {!selectedExistingEnvCredential && crossScopeEnvConflict && ( + + A workspace secret with key "{crossScopeEnvConflict.envKey}" already exists. + Workspace secrets take precedence at runtime. + + )} +
+ )} +
+
+ +
+ ({ + value: option.value, + label: option.label, + }))} + value={ + createTypeOptions.find((option) => option.value === createType)?.label || '' + } + selectedValue={createType} + onChange={(value) => { + const newType = value as CreateCredentialType + setCreateType(newType) + setCreateError(null) + if ( + newType === 'oauth' && + !createOAuthProviderId && + oauthConnections.length > 0 + ) { + setCreateOAuthProviderId(oauthConnections[0]?.providerId || '') + } + }} + placeholder='Select type' + /> +
+
+ + {createType === 'oauth' ? ( +
+
+ + setCreateDisplayName(event.target.value)} + placeholder='Secret name' + autoComplete='off' + data-lpignore='true' + className='mt-[6px]' + /> +
+
+ +