Compare commits

..

13 Commits

Author SHA1 Message Date
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Adam Gough
13981549d1 fix(grain): grain trigger update (#2739)
* grain trigger new requirements

* removed comment

* made it generic for all triggers

* fire only for specific trigger type

* removed comments
2026-01-08 23:10:11 -08:00
Waleed
554dcdf062 improvement(execution-snapshot): enhance workflow preview in logs and deploy modal (#2742)
* added larger live deployment preview

* edited subblock UI

* removed comments

* removed carrot

* updated styling to match existing subblocks

* enriched workflow preview

* fix connetion in log preview

* cleanup

* ack PR comments

* more PR comments

* more

* cleanup

* use reactquery cache in deploy modal

* ack comments

* ack PR comment

---------

Co-authored-by: aadamgough <adam@sim.ai>
2026-01-08 23:04:54 -08:00
Adam Gough
6b28742b68 fix(linear): missing params (#2740)
* added missing params

* fixed linear bugs
2026-01-08 20:42:09 -08:00
Vikhyath Mondreti
e5c95093f6 improvement(autoconnect): click to add paths also autoconnect (#2737) 2026-01-08 18:16:15 -08:00
Waleed
b87af80bff feat(i18n): update translations (#2732)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2026-01-08 18:14:00 -08:00
Vikhyath Mondreti
c2180bf8a0 improvement(enterprise): feature flagging + runtime checks consolidation (#2730)
* improvement(enterprise): enterprise checks code consolidation

* update docs

* revert isHosted check

* add unique index to prevent multiple orgs per user

* address greptile comments

* ui bug
2026-01-08 13:53:22 -08:00
Waleed
fdac4314d2 fix(chat): update stream to respect all output select objects (#2729) 2026-01-08 11:54:07 -08:00
Waleed
a54fcbc094 improvement(auth): added ability to inject secrets to kubernetes, server-side ff to disable email registration (#2728)
* improvement(auth): added ability to inject secrets to kubernetes, server-side ff to disable email registration

* consolidated telemetry events

* comments cleanup

* ack PR comment

* refactor to use createEnvMock helper instead of local mocks
2026-01-08 11:09:35 -08:00
Waleed
05904a73b2 feat(i18n): update translations (#2721)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2026-01-08 10:30:53 -08:00
Lakshman Patel
1b22d2ce81 fix(devcontainer): use bunx for concurrently command (#2723) 2026-01-07 21:20:29 -08:00
Waleed
26dff7cffe feat(bedrock): added aws bedrock as a model provider (#2722) 2026-01-07 20:08:03 -08:00
Vikhyath Mondreti
020037728d feat(polling-groups): can invite multiple people to have their gmail/outlook inboxes connected to a workflow (#2695)
* progress on cred sets

* fix credential set system

* return data to render credential set in block preview

* progress

* invite flow

* simplify code

* fix ui

* fix tests

* fix types

* fix

* fix icon for outlook

* fix cred set name not showing up for owner

* fix rendering of credential set name

* fix outlook well known folder id resolution

* fix perms for creating cred set

* add to docs and simplify ui

* consolidate webhook code better

* fix tests

* fix credential collab logic issue

* fix ui

* fix lint
2026-01-07 17:49:40 -08:00
213 changed files with 30626 additions and 2441 deletions

View File

@@ -0,0 +1,76 @@
---
title: Enterprise
description: Enterprise-Funktionen für Organisationen mit erweiterten
Sicherheits- und Compliance-Anforderungen
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen.
---
## Bring Your Own Key (BYOK)
Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim Studio.
### Unterstützte Anbieter
| Anbieter | Verwendung |
|----------|-------|
| OpenAI | Knowledge Base-Embeddings, Agent-Block |
| Anthropic | Agent-Block |
| Google | Agent-Block |
| Mistral | Knowledge Base OCR |
### Einrichtung
1. Navigieren Sie zu **Einstellungen** → **BYOK** in Ihrem Workspace
2. Klicken Sie auf **Schlüssel hinzufügen** für Ihren Anbieter
3. Geben Sie Ihren API-Schlüssel ein und speichern Sie
<Callout type="warn">
BYOK-Schlüssel werden verschlüsselt gespeichert. Nur Organisationsadministratoren und -inhaber können Schlüssel verwalten.
</Callout>
Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim Studio. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück.
---
## Single Sign-On (SSO)
Enterprise-Authentifizierung mit SAML 2.0- und OIDC-Unterstützung für zentralisiertes Identitätsmanagement.
### Unterstützte Anbieter
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Jeder SAML 2.0- oder OIDC-Anbieter
### Einrichtung
1. Navigieren Sie zu **Einstellungen** → **SSO** in Ihrem Workspace
2. Wählen Sie Ihren Identitätsanbieter
3. Konfigurieren Sie die Verbindung mithilfe der Metadaten Ihres IdP
4. Aktivieren Sie SSO für Ihre Organisation
<Callout type="info">
Sobald SSO aktiviert ist, authentifizieren sich Teammitglieder über Ihren Identitätsanbieter anstelle von E-Mail/Passwort.
</Callout>
---
## Self-Hosted
Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgebungsvariablen aktiviert werden:
| Variable | Beschreibung |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger |
<Callout type="warn">
BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen.
</Callout>

View File

@@ -17,7 +17,7 @@ MCP-Server gruppieren Ihre Workflow-Tools zusammen. Erstellen und verwalten Sie
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navigieren Sie zu **Einstellungen → MCP-Server**
1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs**
2. Klicken Sie auf **Server erstellen**
3. Geben Sie einen Namen und eine optionale Beschreibung ein
4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients
@@ -79,7 +79,7 @@ Füge deinen API-Key-Header (`X-API-Key`) für authentifizierten Zugriff hinzu,
## Server-Verwaltung
In der Server-Detailansicht unter **Einstellungen → MCP-Server** kannst du:
In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** können Sie:
- **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden
- **URL kopieren**: Die Server-URL für MCP-Clients abrufen

View File

@@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
</div>
1. Navigieren Sie zu Ihren Workspace-Einstellungen
2. Gehen Sie zum Abschnitt **MCP-Server**
2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs**
3. Klicken Sie auf **MCP-Server hinzufügen**
4. Geben Sie die Server-Konfigurationsdetails ein
5. Speichern Sie die Konfiguration

View File

@@ -22,7 +22,7 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl
<Cards>
<Card title="Start" href="/triggers/start">
Einheitlicher Einstiegspunkt, der Editor-Ausführungen, API-Bereitstellungen und Chat-Bereitstellungen unterstützt
Einheitlicher Einstiegspunkt, der Editor-Ausführungen, API-Deployments und Chat-Deployments unterstützt
</Card>
<Card title="Webhook" href="/triggers/webhook">
Externe Webhook-Payloads empfangen
@@ -33,6 +33,9 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl
<Card title="RSS Feed" href="/triggers/rss">
RSS- und Atom-Feeds auf neue Inhalte überwachen
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
Team-Gmail- und Outlook-Postfächer überwachen
</Card>
</Cards>
## Schneller Vergleich
@@ -43,6 +46,7 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl
| **Schedule** | Timer, der im Schedule-Block verwaltet wird |
| **Webhook** | Bei eingehender HTTP-Anfrage |
| **RSS Feed** | Neues Element im Feed veröffentlicht |
| **Email Polling Groups** | Neue E-Mail in Team-Gmail- oder Outlook-Postfächern empfangen |
> Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu.
@@ -65,3 +69,25 @@ Wenn du im Editor auf **Run** klickst, wählt Sim automatisch aus, welcher Trigg
Wenn dein Workflow mehrere Trigger hat, wird der Trigger mit der höchsten Priorität ausgeführt. Wenn du beispielsweise sowohl einen Start-Block als auch einen Webhook-Trigger hast, wird beim Klicken auf Run der Start-Block ausgeführt.
**Externe Auslöser mit Mock-Payloads**: Wenn externe Auslöser (Webhooks und Integrationen) manuell ausgeführt werden, generiert Sim automatisch Mock-Payloads basierend auf der erwarteten Datenstruktur des Auslösers. Dies stellt sicher, dass nachgelagerte Blöcke während des Testens Variablen korrekt auflösen können.
## E-Mail-Polling-Gruppen
Polling-Gruppen ermöglichen es Ihnen, die Gmail- oder Outlook-Postfächer mehrerer Teammitglieder mit einem einzigen Trigger zu überwachen. Erfordert einen Team- oder Enterprise-Plan.
**Erstellen einer Polling-Gruppe** (Admin/Owner)
1. Gehen Sie zu **Einstellungen → E-Mail-Polling**
2. Klicken Sie auf **Erstellen** und wählen Sie Gmail oder Outlook
3. Geben Sie einen Namen für die Gruppe ein
**Mitglieder einladen**
1. Klicken Sie auf **Mitglieder hinzufügen** bei Ihrer Polling-Gruppe
2. Geben Sie E-Mail-Adressen ein (durch Komma oder Zeilenumbruch getrennt oder ziehen Sie eine CSV-Datei per Drag & Drop)
3. Klicken Sie auf **Einladungen senden**
Eingeladene erhalten eine E-Mail mit einem Link, um ihr Konto zu verbinden. Sobald die Verbindung hergestellt ist, wird ihr Postfach automatisch in die Polling-Gruppe aufgenommen. Eingeladene müssen keine Mitglieder Ihrer Sim-Organisation sein.
**Verwendung in einem Workflow**
Wählen Sie beim Konfigurieren eines E-Mail-Triggers Ihre Polling-Gruppe aus dem Dropdown-Menü für Anmeldeinformationen anstelle eines einzelnen Kontos aus. Das System erstellt Webhooks für jedes Mitglied und leitet alle E-Mails durch Ihren Workflow.

View File

@@ -0,0 +1,75 @@
---
title: Enterprise
description: Enterprise features for organizations with advanced security and compliance requirements
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
---
## Bring Your Own Key (BYOK)
Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
### Supported Providers
| Provider | Usage |
|----------|-------|
| OpenAI | Knowledge Base embeddings, Agent block |
| Anthropic | Agent block |
| Google | Agent block |
| Mistral | Knowledge Base OCR |
### Setup
1. Navigate to **Settings** → **BYOK** in your workspace
2. Click **Add Key** for your provider
3. Enter your API key and save
<Callout type="warn">
BYOK keys are encrypted at rest. Only organization admins and owners can manage keys.
</Callout>
When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys.
---
## Single Sign-On (SSO)
Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
### Supported Providers
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Any SAML 2.0 or OIDC provider
### Setup
1. Navigate to **Settings** → **SSO** in your workspace
2. Choose your identity provider
3. Configure the connection using your IdP's metadata
4. Enable SSO for your organization
<Callout type="info">
Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
</Callout>
---
## Self-Hosted
For self-hosted deployments, enterprise features can be enabled via environment variables:
| Variable | Description |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
<Callout type="warn">
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
</Callout>

View File

@@ -15,6 +15,7 @@
"permissions",
"sdks",
"self-hosting",
"./enterprise/index",
"./keyboard-shortcuts/index"
],
"defaultOpen": false

View File

@@ -33,6 +33,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
<Card title="RSS Feed" href="/triggers/rss">
Monitor RSS and Atom feeds for new content
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
Monitor team Gmail and Outlook inboxes
</Card>
</Cards>
## Quick Comparison
@@ -43,6 +46,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
| **Schedule** | Timer managed in schedule block |
| **Webhook** | On inbound HTTP request |
| **RSS Feed** | New item published to feed |
| **Email Polling Groups** | New email received in team Gmail or Outlook inboxes |
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
@@ -66,3 +70,24 @@ If your workflow has multiple triggers, the highest priority trigger will be exe
**External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing.
## Email Polling Groups
Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes with a single trigger. Requires a Team or Enterprise plan.
**Creating a Polling Group** (Admin/Owner)
1. Go to **Settings → Email Polling**
2. Click **Create** and choose Gmail or Outlook
3. Enter a name for the group
**Inviting Members**
1. Click **Add Members** on your polling group
2. Enter email addresses (comma or newline separated, or drag & drop a CSV)
3. Click **Send Invites**
Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization.
**Using in a Workflow**
When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow.

View File

@@ -0,0 +1,76 @@
---
title: Enterprise
description: Funciones enterprise para organizaciones con requisitos avanzados
de seguridad y cumplimiento
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión.
---
## Bring Your Own Key (BYOK)
Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim Studio.
### Proveedores compatibles
| Proveedor | Uso |
|----------|-------|
| OpenAI | Embeddings de base de conocimiento, bloque Agent |
| Anthropic | Bloque Agent |
| Google | Bloque Agent |
| Mistral | OCR de base de conocimiento |
### Configuración
1. Navega a **Configuración** → **BYOK** en tu espacio de trabajo
2. Haz clic en **Añadir clave** para tu proveedor
3. Introduce tu clave API y guarda
<Callout type="warn">
Las claves BYOK están cifradas en reposo. Solo los administradores y propietarios de la organización pueden gestionar las claves.
</Callout>
Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim Studio. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas.
---
## Single Sign-On (SSO)
Autenticación enterprise con soporte SAML 2.0 y OIDC para gestión centralizada de identidades.
### Proveedores compatibles
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Cualquier proveedor SAML 2.0 u OIDC
### Configuración
1. Navega a **Configuración** → **SSO** en tu espacio de trabajo
2. Elige tu proveedor de identidad
3. Configura la conexión usando los metadatos de tu IdP
4. Activa SSO para tu organización
<Callout type="info">
Una vez que SSO está activado, los miembros del equipo se autentican a través de tu proveedor de identidad en lugar de correo electrónico/contraseña.
</Callout>
---
## Self-Hosted
Para implementaciones self-hosted, las funciones enterprise se pueden activar mediante variables de entorno:
| Variable | Descripción |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico |
<Callout type="warn">
BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno.
</Callout>

View File

@@ -17,7 +17,7 @@ Los servidores MCP agrupan tus herramientas de flujo de trabajo. Créalos y gest
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Navega a **Configuración → Servidores MCP**
1. Navega a **Configuración → MCP implementados**
2. Haz clic en **Crear servidor**
3. Introduce un nombre y una descripción opcional
4. Copia la URL del servidor para usarla en tus clientes MCP
@@ -79,7 +79,7 @@ Incluye tu encabezado de clave API (`X-API-Key`) para acceso autenticado al usar
## Gestión del servidor
Desde la vista de detalle del servidor en **Configuración → Servidores MCP**, puedes:
Desde la vista de detalles del servidor en **Configuración → MCP implementados**, puedes:
- **Ver herramientas**: consulta todos los flujos de trabajo añadidos a un servidor
- **Copiar URL**: obtén la URL del servidor para clientes MCP

View File

@@ -26,8 +26,8 @@ Los servidores MCP proporcionan colecciones de herramientas que tus agentes pued
<Video src="mcp/settings-mcp-tools.mp4" width={700} height={450} />
</div>
1. Navega a los ajustes de tu espacio de trabajo
2. Ve a la sección **Servidores MCP**
1. Navega a la configuración de tu espacio de trabajo
2. Ve a la sección **MCP implementados**
3. Haz clic en **Añadir servidor MCP**
4. Introduce los detalles de configuración del servidor
5. Guarda la configuración

View File

@@ -22,7 +22,7 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
<Cards>
<Card title="Start" href="/triggers/start">
Punto de entrada unificado que admite ejecuciones del editor, despliegues de API y despliegues de chat
Punto de entrada unificado que admite ejecuciones en el editor, despliegues de API y despliegues de chat
</Card>
<Card title="Webhook" href="/triggers/webhook">
Recibe cargas útiles de webhooks externos
@@ -31,18 +31,22 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
Ejecución basada en cron o intervalos
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Monitorea feeds RSS y Atom para nuevo contenido
Monitorea feeds RSS y Atom para detectar contenido nuevo
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
Monitorea bandejas de entrada de Gmail y Outlook del equipo
</Card>
</Cards>
## Comparación rápida
| Disparador | Condición de inicio |
| Trigger | Condición de inicio |
|---------|-----------------|
| **Start** | Ejecuciones del editor, solicitudes de despliegue a API o mensajes de chat |
| **Start** | Ejecuciones en el editor, solicitudes de despliegue a API o mensajes de chat |
| **Schedule** | Temporizador gestionado en el bloque de programación |
| **Webhook** | Al recibir una solicitud HTTP entrante |
| **RSS Feed** | Nuevo elemento publicado en el feed |
| **Email Polling Groups** | Nuevo correo electrónico recibido en bandejas de entrada de Gmail o Outlook del equipo |
> El bloque Start siempre expone los campos `input`, `conversationId` y `files`. Añade campos personalizados al formato de entrada para datos estructurados adicionales.
@@ -65,3 +69,25 @@ Cuando haces clic en **Ejecutar** en el editor, Sim selecciona automáticamente
Si tu flujo de trabajo tiene múltiples disparadores, se ejecutará el disparador de mayor prioridad. Por ejemplo, si tienes tanto un bloque Start como un disparador Webhook, al hacer clic en Ejecutar se ejecutará el bloque Start.
**Disparadores externos con cargas útiles simuladas**: Cuando los disparadores externos (webhooks e integraciones) se ejecutan manualmente, Sim genera automáticamente cargas útiles simuladas basadas en la estructura de datos esperada del disparador. Esto asegura que los bloques posteriores puedan resolver las variables correctamente durante las pruebas.
## Grupos de sondeo de correo electrónico
Los grupos de sondeo te permiten monitorear las bandejas de entrada de Gmail o Outlook de varios miembros del equipo con un solo activador. Requiere un plan Team o Enterprise.
**Crear un grupo de sondeo** (administrador/propietario)
1. Ve a **Configuración → Sondeo de correo electrónico**
2. Haz clic en **Crear** y elige Gmail u Outlook
3. Ingresa un nombre para el grupo
**Invitar miembros**
1. Haz clic en **Agregar miembros** en tu grupo de sondeo
2. Ingresa direcciones de correo electrónico (separadas por comas o saltos de línea, o arrastra y suelta un CSV)
3. Haz clic en **Enviar invitaciones**
Los invitados reciben un correo electrónico con un enlace para conectar su cuenta. Una vez conectada, su bandeja de entrada se incluye automáticamente en el grupo de sondeo. Los invitados no necesitan ser miembros de tu organización Sim.
**Usar en un flujo de trabajo**
Al configurar un activador de correo electrónico, selecciona tu grupo de sondeo del menú desplegable de credenciales en lugar de una cuenta individual. El sistema crea webhooks para cada miembro y enruta todos los correos electrónicos a través de tu flujo de trabajo.

View File

@@ -0,0 +1,76 @@
---
title: Entreprise
description: Fonctionnalités entreprise pour les organisations ayant des
exigences avancées en matière de sécurité et de conformité
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion.
---
## Apportez votre propre clé (BYOK)
Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim Studio.
### Fournisseurs pris en charge
| Fournisseur | Utilisation |
|----------|-------|
| OpenAI | Embeddings de base de connaissances, bloc Agent |
| Anthropic | Bloc Agent |
| Google | Bloc Agent |
| Mistral | OCR de base de connaissances |
### Configuration
1. Accédez à **Paramètres** → **BYOK** dans votre espace de travail
2. Cliquez sur **Ajouter une clé** pour votre fournisseur
3. Saisissez votre clé API et enregistrez
<Callout type="warn">
Les clés BYOK sont chiffrées au repos. Seuls les administrateurs et propriétaires de l'organisation peuvent gérer les clés.
</Callout>
Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim Studio. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées.
---
## Authentification unique (SSO)
Authentification entreprise avec prise en charge de SAML 2.0 et OIDC pour une gestion centralisée des identités.
### Fournisseurs pris en charge
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- Tout fournisseur SAML 2.0 ou OIDC
### Configuration
1. Accédez à **Paramètres** → **SSO** dans votre espace de travail
2. Choisissez votre fournisseur d'identité
3. Configurez la connexion en utilisant les métadonnées de votre IdP
4. Activez le SSO pour votre organisation
<Callout type="info">
Une fois le SSO activé, les membres de l'équipe s'authentifient via votre fournisseur d'identité au lieu d'utiliser un email/mot de passe.
</Callout>
---
## Auto-hébergé
Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent être activées via des variables d'environnement :
| Variable | Description |
|----------|-------------|
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC |
| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail |
<Callout type="warn">
BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement.
</Callout>

View File

@@ -17,11 +17,11 @@ Les serveurs MCP regroupent vos outils de workflow. Créez-les et gérez-les dan
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. Accédez à **Paramètres → Serveurs MCP**
1. Accédez à **Paramètres → MCP déployés**
2. Cliquez sur **Créer un serveur**
3. Saisissez un nom et une description facultative
4. Copiez l'URL du serveur pour l'utiliser dans vos clients MCP
5. Consultez et gérez tous les outils ajoutés au serveur
5. Affichez et gérez tous les outils ajoutés au serveur
## Ajouter un workflow en tant qu'outil
@@ -79,7 +79,7 @@ Incluez votre en-tête de clé API (`X-API-Key`) pour un accès authentifié lor
## Gestion du serveur
Depuis la vue détaillée du serveur dans **Paramètres → Serveurs MCP**, vous pouvez :
Depuis la vue détaillée du serveur dans **Paramètres → MCP déployés**, vous pouvez :
- **Voir les outils** : voir tous les workflows ajoutés à un serveur
- **Copier l'URL** : obtenir l'URL du serveur pour les clients MCP

View File

@@ -28,7 +28,7 @@ Les serveurs MCP fournissent des collections d'outils que vos agents peuvent uti
</div>
1. Accédez aux paramètres de votre espace de travail
2. Allez à la section **Serveurs MCP**
2. Allez dans la section **MCP déployés**
3. Cliquez sur **Ajouter un serveur MCP**
4. Saisissez les détails de configuration du serveur
5. Enregistrez la configuration

View File

@@ -22,7 +22,7 @@ Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiem
<Cards>
<Card title="Start" href="/triggers/start">
Point d'entrée unifié qui prend en charge les exécutions de l'éditeur, les déploiements d'API et les déploiements de chat
Point d'entrée unifié qui prend en charge les exécutions dans l'éditeur, les déploiements API et les déploiements de chat
</Card>
<Card title="Webhook" href="/triggers/webhook">
Recevoir des charges utiles de webhook externes
@@ -31,18 +31,22 @@ Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiem
Exécution basée sur cron ou intervalle
</Card>
<Card title="RSS Feed" href="/triggers/rss">
Surveiller les flux RSS et Atom pour du nouveau contenu
Surveiller les flux RSS et Atom pour détecter du nouveau contenu
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
Surveiller les boîtes de réception Gmail et Outlook de l'équipe
</Card>
</Cards>
## Comparaison rapide
| Déclencheur | Condition de démarrage |
|---------|-----------------|
| **Start** | Exécutions de l'éditeur, requêtes de déploiement d'API ou messages de chat |
|-------------|------------------------|
| **Start** | Exécutions dans l'éditeur, requêtes de déploiement vers l'API ou messages de chat |
| **Schedule** | Minuteur géré dans le bloc de planification |
| **Webhook** | Sur requête HTTP entrante |
| **Webhook** | Lors d'une requête HTTP entrante |
| **RSS Feed** | Nouvel élément publié dans le flux |
| **Email Polling Groups** | Nouvel e-mail reçu dans les boîtes de réception Gmail ou Outlook de l'équipe |
> Le bloc Démarrer expose toujours les champs `input`, `conversationId` et `files`. Ajoutez des champs personnalisés au format d'entrée pour des données structurées supplémentaires.
@@ -65,3 +69,25 @@ Lorsque vous cliquez sur **Exécuter** dans l'éditeur, Sim sélectionne automat
Si votre flux de travail comporte plusieurs déclencheurs, le déclencheur de priorité la plus élevée sera exécuté. Par exemple, si vous avez à la fois un bloc Démarrer et un déclencheur Webhook, cliquer sur Exécuter exécutera le bloc Démarrer.
**Déclencheurs externes avec charges utiles simulées** : lorsque des déclencheurs externes (webhooks et intégrations) sont exécutés manuellement, Sim génère automatiquement des charges utiles simulées basées sur la structure de données attendue du déclencheur. Cela garantit que les blocs en aval peuvent résoudre correctement les variables pendant les tests.
## Groupes de surveillance d'e-mails
Les groupes de surveillance vous permettent de surveiller les boîtes de réception Gmail ou Outlook de plusieurs membres de l'équipe avec un seul déclencheur. Nécessite un forfait Team ou Enterprise.
**Créer un groupe de surveillance** (Admin/Propriétaire)
1. Accédez à **Paramètres → Surveillance d'e-mails**
2. Cliquez sur **Créer** et choisissez Gmail ou Outlook
3. Entrez un nom pour le groupe
**Inviter des membres**
1. Cliquez sur **Ajouter des membres** dans votre groupe de surveillance
2. Entrez les adresses e-mail (séparées par des virgules ou des sauts de ligne, ou glissez-déposez un fichier CSV)
3. Cliquez sur **Envoyer les invitations**
Les personnes invitées reçoivent un e-mail avec un lien pour connecter leur compte. Une fois connectée, leur boîte de réception est automatiquement incluse dans le groupe de surveillance. Les personnes invitées n'ont pas besoin d'être membres de votre organisation Sim.
**Utiliser dans un workflow**
Lors de la configuration d'un déclencheur d'e-mail, sélectionnez votre groupe de surveillance dans le menu déroulant des identifiants au lieu d'un compte individuel. Le système crée des webhooks pour chaque membre et achemine tous les e-mails via votre workflow.

View File

@@ -0,0 +1,75 @@
---
title: エンタープライズ
description: 高度なセキュリティとコンプライアンス要件を持つ組織向けのエンタープライズ機能
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。
---
## Bring Your Own Key (BYOK)
Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。
### 対応プロバイダー
| プロバイダー | 用途 |
|----------|-------|
| OpenAI | ナレッジベースの埋め込み、エージェントブロック |
| Anthropic | エージェントブロック |
| Google | エージェントブロック |
| Mistral | ナレッジベースOCR |
### セットアップ
1. ワークスペースの**設定** → **BYOK**に移動します
2. プロバイダーの**キーを追加**をクリックします
3. APIキーを入力して保存します
<Callout type="warn">
BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。
</Callout>
設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。
---
## シングルサインオン (SSO)
集中型IDマネジメントのためのSAML 2.0およびOIDCサポートを備えたエンタープライズ認証。
### 対応プロバイダー
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- SAML 2.0またはOIDCに対応する任意のプロバイダー
### セットアップ
1. ワークスペースの**設定** → **SSO**に移動します
2. IDプロバイダーを選択します
3. IdPのメタデータを使用して接続を設定します
4. 組織のSSOを有効にします
<Callout type="info">
SSOを有効にすると、チームメンバーはメール/パスワードの代わりにIDプロバイダーを通じて認証します。
</Callout>
---
## セルフホスト
セルフホストデプロイメントの場合、エンタープライズ機能は環境変数を介して有効にできます:
| 変数 | 説明 |
|----------|-------------|
| `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン |
| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ |
<Callout type="warn">
BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。
</Callout>

View File

@@ -16,11 +16,11 @@ MCPサーバーは、ワークフローツールをまとめてグループ化
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. **設定 → MCPサーバー**に移動
2. **サーバーを作成**をクリック
3. 名前と説明(任意)を入力
4. MCPクライアントで使用するためにサーバーURLをコピー
5. サーバーに追加されたすべてのツールを表示・管理
1. **設定 → デプロイ済みMCP**に移動します
2. **サーバーを作成**をクリックします
3. 名前とオプションの説明を入力します
4. MCPクライアントで使用するためにサーバーURLをコピーします
5. サーバーに追加されたすべてのツールを表示および管理します
## ワークフローをツールとして追加
@@ -78,7 +78,7 @@ mcp-remoteまたは他のHTTPベースのMCPトランスポートを使用する
## サーバー管理
**設定 → MCPサーバー**のサーバー詳細ビューから、以下の操作が可能です:
**設定 → デプロイ済みMCP**のサーバー詳細ビューから、次のことができます:
- **ツールを表示**: サーバーに追加されたすべてのワークフローを確認
- **URLをコピー**: MCPクライアント用のサーバーURLを取得

View File

@@ -27,10 +27,10 @@ MCPサーバーはエージェントが使用できるツールのコレクシ
</div>
1. ワークスペース設定に移動します
2. **MCPサーバー**セクションに進みます
2. **デプロイ済みMCP**セクションに移動します
3. **MCPサーバーを追加**をクリックします
4. サーバー構成の詳細を入力します
5. 構成を保存します
4. サーバー設定の詳細を入力します
5. 設定を保存します
<Callout type="info">
エージェントブロックのツールバーから直接MCPサーバーを構成することもできますクイックセットアップ

View File

@@ -22,16 +22,19 @@ import { Image } from '@/components/ui/image'
<Cards>
<Card title="Start" href="/triggers/start">
エディタ実行、APIデプロイメント、チャットデプロイメントをサポートする統合エントリーポイント
エディタ実行、APIデプロイ、チャットデプロイをサポートする統合エントリーポイント
</Card>
<Card title="Webhook" href="/triggers/webhook">
外部のwebhookペイロードを受信
外部Webhookペイロードを受信
</Card>
<Card title="Schedule" href="/triggers/schedule">
Cronまたは間隔ベースの実行
Cronまたはインターバルベースの実行
</Card>
<Card title="RSS Feed" href="/triggers/rss">
新しいコンテンツのRSSとAtomフィードを監視
RSSおよびAtomフィードの新しいコンテンツを監視
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
チームのGmailおよびOutlook受信トレイを監視
</Card>
</Cards>
@@ -39,10 +42,11 @@ import { Image } from '@/components/ui/image'
| トリガー | 開始条件 |
|---------|-----------------|
| **Start** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
| **Start** | エディタ実行、deploy-to-APIリクエスト、またはチャットメッセージ |
| **Schedule** | スケジュールブロックで管理されるタイマー |
| **Webhook** | 受信HTTPリクエスト時 |
| **Webhook** | インバウンドHTTPリクエスト時 |
| **RSS Feed** | フィードに新しいアイテムが公開された時 |
| **Email Polling Groups** | チームのGmailまたはOutlook受信トレイに新しいメールが受信された時 |
> スタートブロックは常に `input`、`conversationId`、および `files` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。
@@ -65,3 +69,25 @@ import { Image } from '@/components/ui/image'
ワークフローに複数のトリガーがある場合、最も優先度の高いトリガーが実行されます。例えば、スタートブロックとウェブフックトリガーの両方がある場合、実行をクリックするとスタートブロックが実行されます。
**モックペイロードを持つ外部トリガー**: 外部トリガーウェブフックと連携が手動で実行される場合、Simはトリガーの予想されるデータ構造に基づいてモックペイロードを自動生成します。これにより、テスト中に下流のブロックが変数を正しく解決できるようになります。
## Email Polling Groups
Polling Groupsを使用すると、単一のトリガーで複数のチームメンバーのGmailまたはOutlook受信トレイを監視できます。TeamまたはEnterpriseプランが必要です。
**Polling Groupの作成**(管理者/オーナー)
1. **設定 → Email Polling**に移動
2. **作成**をクリックし、GmailまたはOutlookを選択
3. グループの名前を入力
**メンバーの招待**
1. Polling Groupの**メンバーを追加**をクリック
2. メールアドレスを入力カンマまたは改行で区切る、またはCSVをドラッグ&ドロップ)
3. **招待を送信**をクリック
招待された人は、アカウントを接続するためのリンクが記載されたメールを受信します。接続されると、その受信トレイは自動的にPolling Groupに含まれます。招待された人は、Sim組織のメンバーである必要はありません。
**ワークフローでの使用**
メールトリガーを設定する際、個別のアカウントではなく、認証情報ドロップダウンからPolling Groupを選択します。システムは各メンバーのWebhookを作成し、すべてのメールをワークフローを通じてルーティングします。

View File

@@ -0,0 +1,75 @@
---
title: 企业版
description: 为具有高级安全性和合规性需求的组织提供企业级功能
---
import { Callout } from 'fumadocs-ui/components/callout'
Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。
---
## 自带密钥BYOK
使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。
### 支持的服务商
| Provider | Usage |
|----------|-------|
| OpenAI | 知识库嵌入、Agent 模块 |
| Anthropic | Agent 模块 |
| Google | Agent 模块 |
| Mistral | 知识库 OCR |
### 配置方法
1. 在您的工作区进入 **设置** → **BYOK**
2. 为您的服务商点击 **添加密钥**
3. 输入您的 API 密钥并保存
<Callout type="warn">
BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。
</Callout>
配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。
---
## 单点登录SSO
企业级身份认证,支持 SAML 2.0 和 OIDC实现集中式身份管理。
### 支持的服务商
- Okta
- Azure AD / Entra ID
- Google Workspace
- OneLogin
- 任何 SAML 2.0 或 OIDC 服务商
### 配置方法
1. 在您的工作区进入 **设置** → **SSO**
2. 选择您的身份提供商
3. 使用 IdP 元数据配置连接
4. 为您的组织启用 SSO
<Callout type="info">
启用 SSO 后,团队成员将通过您的身份提供商进行身份验证,而不再使用邮箱/密码。
</Callout>
---
## 自主部署
对于自主部署场景,可通过环境变量启用企业功能:
| 变量 | 描述 |
|----------|-------------|
| `SSO_ENABLED``NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 |
| `CREDENTIAL_SETS_ENABLED``NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 |
<Callout type="warn">
BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。
</Callout>

View File

@@ -16,11 +16,11 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
<Video src="mcp/mcp-server.mp4" width={700} height={450} />
</div>
1. 进入 **设置 → MCP 服务器**
1. 进入 **设置 → 已部署的 MCPs**
2. 点击 **创建服务器**
3. 输入名称和可选描述
4. 复制服务器 URL 以在的 MCP 客户端中使用
5. 查看并管理已添加到服务器的所有工具
4. 复制服务器 URL 以在的 MCP 客户端中使用
5. 查看并管理已添加到服务器的所有工具
## 添加工作流为工具
@@ -78,7 +78,7 @@ MCP 服务器用于将您的工作流工具进行分组。您可以在工作区
## 服务器管理
在 **设置 → MCP 服务器** 的服务器详情视图中,您可以:
在 **设置 → 已部署的 MCPs** 的服务器详情页,你可以:
- **查看工具**:查看添加到服务器的所有工作流
- **复制 URL**:获取 MCP 客户端的服务器 URL

View File

@@ -27,9 +27,9 @@ MCP 服务器提供工具集合,供您的代理使用。您可以在工作区
</div>
1. 进入您的工作区设置
2. 转到 **MCP 服务器** 部分
3. 点击 **添加 MCP 服务器**
4. 输入服务器配置详情
2. 前往 **Deployed MCPs** 部分
3. 点击 **Add MCP Server**
4. 输入服务器配置信息
5. 保存配置
<Callout type="info">

View File

@@ -21,17 +21,20 @@ import { Image } from '@/components/ui/image'
使用 Start 块处理从编辑器、部署到 API 或部署到聊天的所有操作。其他触发器可用于事件驱动的工作流:
<Cards>
<Card title="开始" href="/triggers/start">
支持编辑器运行、API 部署和聊天部署的统一入口
<Card title="Start" href="/triggers/start">
支持编辑器运行、API 部署和聊天部署的统一入口
</Card>
<Card title="Webhook" href="/triggers/webhook">
接收外部 webhook 负载
</Card>
<Card title="计划" href="/triggers/schedule">
基于 Cron 或间隔的执行
<Card title="Schedule" href="/triggers/schedule">
基于 cron 或间隔的执行
</Card>
<Card title="RSS " href="/triggers/rss">
监控 RSS 和 Atom 源的新内容
<Card title="RSS Feed" href="/triggers/rss">
监控 RSS 和 Atom 订阅源的新内容
</Card>
<Card title="Email Polling Groups" href="#email-polling-groups">
监控团队 Gmail 和 Outlook 收件箱
</Card>
</Cards>
@@ -39,10 +42,11 @@ import { Image } from '@/components/ui/image'
| 触发器 | 启动条件 |
|---------|-----------------|
| **开始** | 编辑器运行、部署到 API 请求或聊天消息 |
| **计划** | 在计划块中管理的时器 |
| **Start** | 编辑器运行、API 部署请求或聊天消息 |
| **Schedule** | 在 schedule 块中管理的时器 |
| **Webhook** | 收到入站 HTTP 请求时 |
| **RSS ** | 源中发布了新项目 |
| **RSS Feed** | 订阅源中有新内容发布时 |
| **Email Polling Groups** | 团队 Gmail 或 Outlook 收件箱收到新邮件时 |
> Start 块始终公开 `input`、`conversationId` 和 `files` 字段。通过向输入格式添加自定义字段来增加结构化数据。
@@ -65,3 +69,25 @@ import { Image } from '@/components/ui/image'
如果您的工作流有多个触发器,将执行优先级最高的触发器。例如,如果您同时有 Start 块和 Webhook 触发器,点击运行将执行 Start 块。
**带有模拟负载的外部触发器**:当手动执行外部触发器(如 webhooks 和集成Sim 会根据触发器的预期数据结构自动生成模拟负载。这确保了在测试过程中,下游模块可以正确解析变量。
## 邮件轮询组
轮询组可让你通过单一触发器监控多个团队成员的 Gmail 或 Outlook 收件箱。需要 Team 或 Enterprise 方案。
**创建轮询组**(管理员/所有者)
1. 前往 **设置 → 邮件轮询**
2. 点击 **创建**,选择 Gmail 或 Outlook
3. 输入组名
**邀请成员**
1. 在你的轮询组中点击 **添加成员**
2. 输入邮箱地址(用逗号或换行分隔,或拖拽 CSV 文件)
3. 点击 **发送邀请**
受邀者会收到一封带有连接账户链接的邮件。连接后,他们的收件箱会自动加入轮询组。受邀者无需成为你的 Sim 组织成员。
**在工作流中使用**
配置邮件触发器时,从凭据下拉菜单中选择你的轮询组,而不是单独账户。系统会为每位成员创建 webhook并将所有邮件通过你的工作流进行处理。

View File

@@ -4343,7 +4343,7 @@ checksums:
content/5: 6eee8c607e72b6c444d7b3ef07244f20
content/6: 747991e0e80e306dce1061ef7802db2a
content/7: 430153eacb29c66026cf71944df7be20
content/8: 5950966e19939b7a3a320d56ee4a674c
content/8: f9bdeac954d1d138c954c151db0403ec
content/9: 159cf7a6d62e64b0c5db27e73b8c1ff5
content/10: a723187777f9a848d4daa563e9dcbe17
content/11: b1c5f14e5290bcbbf5d590361ee7c053
@@ -5789,9 +5789,9 @@ checksums:
content/1: e71056df0f7b2eb3b2f271f21d0052cc
content/2: da2b445db16c149f56558a4ea876a5f0
content/3: cec18f48b2cd7974eb556880e6604f7f
content/4: b200402d6a01ab565fd56d113c530ef6
content/4: cff35e4208de8f6ef36a6eae79915fab
content/5: 4c3a5708af82c1ee42a12d14fd34e950
content/6: 64fbd5b16f4cff18ba976492a275c05e
content/6: 00a9f255e60b5979014694b0c2a3ba26
content/7: a28151eeb5ba3518b33809055b04f0f6
content/8: cffe5b901d78ebf2000d07dc7579533e
content/9: 73486253d24eeff7ac44dfd0c8868d87
@@ -5801,6 +5801,15 @@ checksums:
content/13: e5ca2445d3b69b062af5bf0a2988e760
content/14: 67e0b520d57e352689789eff5803ebbc
content/15: a1d7382600994068ca24dc03f46b7c73
content/16: 1895a0c773fddeb014c7aab468593b30
content/17: 5b478d664a0b1bc76f19516b2a6e2788
content/18: c97883b63e5e455cd2de51f0406f963f
content/19: 2ff6c01b8eebbdd653d864b105f53cde
content/20: 523b34e945343591d1df51a6ba6357dd
content/21: e6611cff00c91bd2327660aebf9418f4
content/22: 87e7e7df71f0883369e8abda30289c0f
content/23: b248d9eda347cfb122101a4e4b5eaa53
content/24: 2f003723d891d6c53c398b86c7397577
0bf172ef4ee9a2c94a2967d7d320b81b:
meta/title: 330265974a03ee22a09f42fa4ece25f6
meta/description: e3d54cbedf551315cf9e8749228c2d1c
@@ -50141,7 +50150,7 @@ checksums:
content/2: b082096b0c871b2a40418e479af6f158
content/3: 9c94aa34f44540b0632931a8244a6488
content/4: 14f33e16b5a98e4dbdda2a27aa0d7afb
content/5: d7b36732970b7649dd1aa1f1d0a34e74
content/5: 3ea8bad9314f442a69a87f313419ef1a
content/6: f554f833467a6dae5391372fc41dad53
content/7: 9cdb9189ecfcc4a6f567d3fd5fe342f0
content/8: 9a107692cb52c284c1cb022b516d700b
@@ -50158,7 +50167,7 @@ checksums:
content/19: a618fcff50c4856113428639359a922b
content/20: 5fd3a6d2dcd8aa18dbf0b784acaa271c
content/21: d118656dd565c4c22f3c0c3a7c7f3bee
content/22: f49b9be78f1e7a569e290acc1365d417
content/22: c161e7bcfba9cf6ef0ab8ef40ac0c17a
content/23: 0a70ebe6eb4c543c3810977ed46b69b0
content/24: ad8638a3473c909dbcb1e1d9f4f26381
content/25: 95343a9f81cd050d3713988c677c750f
@@ -50299,3 +50308,30 @@ checksums:
content/68: ba6b5020ed971cd7ffc7f0423650dfbf
content/69: b3f310d5ef115bea5a8b75bf25d7ea9a
content/70: 0362be478aa7ba4b6d1ebde0bd83e83a
f5bc5f89ed66818f4c485c554bf26eea:
meta/title: c70474271708e5b27392fde87462fa26
meta/description: 7b47db7fbb818c180b99354b912a72b3
content/0: 232be69c8f3053a40f695f9c9dcb3f2e
content/1: a4a62a6e782e18bd863546dfcf2aec1c
content/2: 51adf33450cab2ef392e93147386647c
content/3: ada515cf6e2e0f9d3f57f720f79699d3
content/4: d5e8b9f64d855675588845dc4124c491
content/5: 3acf1f0551f6097ca6159e66f5c8da1a
content/6: 6a6e277ded1a063ec2c2067abb519088
content/7: 6debcd334c3310480cbe6feab87f37b5
content/8: 0e3372052a2b3a1c43d853d6ed269d69
content/9: 90063613714128f4e61e9588e2d2c735
content/10: 182154179fe2a8b6b73fde0d04e0bf4c
content/11: 51adf33450cab2ef392e93147386647c
content/12: 73c3e8a5d36d6868fdb455fcb3d6074c
content/13: 30cd8f1d6197bce560a091ba19d0392a
content/14: 3acf1f0551f6097ca6159e66f5c8da1a
content/15: 997deef758698d207be9382c45301ad6
content/16: 6debcd334c3310480cbe6feab87f37b5
content/17: e26c8c2dffd70baef0253720c1511886
content/18: a99eba53979531f1c974cf653c346909
content/19: 51adf33450cab2ef392e93147386647c
content/20: ca3ec889fb218b8b130959ff04baa659
content/21: 306617201cf63b42f09bb72c9722e048
content/22: 4b48ba3f10b043f74b70edeb4ad87080
content/23: c8531bd570711abc1963d8b5dcf9deef

View File

@@ -109,11 +109,15 @@ function SignupFormContent({
setEmail(emailParam)
}
const redirectParam = searchParams.get('redirect')
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
if (redirectParam) {
setRedirectUrl(redirectParam)
if (redirectParam.startsWith('/invite/')) {
if (
redirectParam.startsWith('/invite/') ||
redirectParam.startsWith('/credential-account/')
) {
setIsInviteFlow(true)
}
}

View File

@@ -8,11 +8,18 @@ import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/ut
describe('OAuth Disconnect API Route', () => {
const mockGetSession = vi.fn()
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
}
const mockDb = {
delete: vi.fn().mockReturnThis(),
where: vi.fn(),
select: vi.fn().mockReturnValue(mockSelectChain),
}
const mockLogger = createMockLogger()
const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({})
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
@@ -33,6 +40,13 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/db/schema', () => ({
account: { userId: 'userId', providerId: 'providerId' },
credentialSetMember: {
id: 'id',
credentialSetId: 'credentialSetId',
userId: 'userId',
status: 'status',
},
credentialSet: { id: 'id', providerId: 'providerId' },
}))
vi.doMock('drizzle-orm', () => ({
@@ -45,6 +59,14 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.doMock('@/lib/webhooks/utils.server', () => ({
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
}))
})
afterEach(() => {

View File

@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { account, credentialSet, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, like, or } 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 { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
export const dynamic = 'force-dynamic'
@@ -74,6 +75,49 @@ export async function POST(request: NextRequest) {
)
}
// Sync webhooks for all credential sets the user is a member of
// This removes webhooks that were using the disconnected credential
const userMemberships = await db
.select({
id: credentialSetMember.id,
credentialSetId: credentialSetMember.credentialSetId,
providerId: credentialSet.providerId,
})
.from(credentialSetMember)
.innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
.where(
and(
eq(credentialSetMember.userId, session.user.id),
eq(credentialSetMember.status, 'active')
)
)
for (const membership of userMemberships) {
// Only sync if the credential set matches this provider
// Credential sets store OAuth provider IDs like 'google-email' or 'outlook'
const matchesProvider =
membership.providerId === provider ||
membership.providerId === providerId ||
membership.providerId?.startsWith(`${provider}-`)
if (matchesProvider) {
try {
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
logger.info(`[${requestId}] Synced webhooks after credential disconnect`, {
credentialSetId: membership.credentialSetId,
provider,
})
} catch (error) {
// Log but don't fail the disconnect - credential is already removed
logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, {
credentialSetId: membership.credentialSetId,
provider,
error,
})
}
}
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)

View File

@@ -138,7 +138,10 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Credential ID is required')
expect(data).toHaveProperty(
'error',
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
expect(mockLogger.warn).toHaveBeenCalled()
})

View File

@@ -4,7 +4,7 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -12,12 +12,17 @@ const logger = createLogger('OAuthTokenAPI')
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
const tokenRequestSchema = z.object({
credentialId: z
.string({ required_error: 'Credential ID is required' })
.min(1, 'Credential ID is required'),
workflowId: z.string().min(1, 'Workflow ID is required').nullish(),
})
const tokenRequestSchema = z
.object({
credentialId: z.string().min(1).optional(),
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
'Either credentialId or (credentialAccountUserId + providerId) is required'
)
const tokenQuerySchema = z.object({
credentialId: z
@@ -58,9 +63,37 @@ export async function POST(request: NextRequest) {
)
}
const { credentialId, workflowId } = parseResult.data
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
credentialAccountUserId,
providerId,
})
try {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) {
return NextResponse.json(
{
error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
},
{ status: 404 }
)
}
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get OAuth token'
logger.warn(`[${requestId}] OAuth token error: ${message}`)
return NextResponse.json({ error: message }, { status: 403 })
}
}
if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
@@ -70,7 +103,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Fetch the credential as the owner to enforce ownership scoping
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
if (!credential) {
@@ -78,7 +110,6 @@ export async function POST(request: NextRequest) {
}
try {
// Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
let instanceUrl: string | undefined
@@ -145,7 +176,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential from the database
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { account, workflow } from '@sim/db/schema'
import { account, credentialSetMember, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'
@@ -105,10 +105,10 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
idToken: account.idToken,
scope: account.scope,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
// Always use the most recently updated credential for this provider
.orderBy(desc(account.updatedAt))
.limit(1)
@@ -335,3 +335,108 @@ export async function refreshTokenIfNeeded(
throw error
}
}
export interface CredentialSetCredential {
userId: string
credentialId: string
accessToken: string
providerId: string
}
export async function getCredentialsForCredentialSet(
credentialSetId: string,
providerId: string
): Promise<CredentialSetCredential[]> {
logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`)
const members = await db
.select({ userId: credentialSetMember.userId })
.from(credentialSetMember)
.where(
and(
eq(credentialSetMember.credentialSetId, credentialSetId),
eq(credentialSetMember.status, 'active')
)
)
logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`)
if (members.length === 0) {
logger.warn(`No active members found for credential set ${credentialSetId}`)
return []
}
const userIds = members.map((m) => m.userId)
logger.debug(`Member user IDs: ${userIds.join(', ')}`)
const credentials = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
})
.from(account)
.where(and(inArray(account.userId, userIds), eq(account.providerId, providerId)))
logger.info(
`Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members`
)
const results: CredentialSetCredential[] = []
for (const cred of credentials) {
const now = new Date()
const tokenExpiry = cred.accessTokenExpiresAt
const shouldRefresh =
!!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now))
let accessToken = cred.accessToken
if (shouldRefresh && cred.refreshToken) {
try {
const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken)
if (refreshResult) {
accessToken = refreshResult.accessToken
const updateData: Record<string, unknown> = {
accessToken: refreshResult.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000),
updatedAt: new Date(),
}
if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) {
updateData.refreshToken = refreshResult.refreshToken
}
await db.update(account).set(updateData).where(eq(account.id, cred.id))
logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`)
}
} catch (error) {
logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, {
error: error instanceof Error ? error.message : String(error),
})
continue
}
}
if (accessToken) {
results.push({
userId: cred.userId,
credentialId: cred.id,
accessToken,
providerId,
})
}
}
logger.info(
`Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}`
)
return results
}

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
@@ -63,10 +64,22 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
export async function POST(request: NextRequest) {
try {
// SSO plugin must be enabled in Better Auth
if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
// Check plan access (enterprise) or env var override
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const hasAccess = await hasSSOAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
}
const rawBody = await request.json()
const parseResult = ssoRegistrationSchema.safeParse(rawBody)

View File

@@ -212,6 +212,18 @@ export async function POST(request: NextRequest) {
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.chatDeployed({
chatId: id,
workflowId,
authType,
hasOutputConfigs: outputConfigs.length > 0,
})
} catch (_e) {
// Silently fail
}
return createSuccessResponse({
id,
chatUrl,

View File

@@ -0,0 +1,156 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('CredentialSetInviteResend')
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
const [set] = await db
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
name: credentialSet.name,
providerId: credentialSet.providerId,
})
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (!set) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
.limit(1)
if (!membership) return null
return { set, role: membership.role }
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id, invitationId } = await params
try {
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const [invitation] = await db
.select()
.from(credentialSetInvitation)
.where(
and(
eq(credentialSetInvitation.id, invitationId),
eq(credentialSetInvitation.credentialSetId, id)
)
)
.limit(1)
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Only pending invitations can be resent' }, { status: 400 })
}
// Update expiration
const newExpiresAt = new Date()
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
await db
.update(credentialSetInvitation)
.set({ expiresAt: newExpiresAt })
.where(eq(credentialSetInvitation.id, invitationId))
const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}`
// Send email if email address exists
if (invitation.email) {
try {
const [inviter] = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, result.set.organizationId))
.limit(1)
const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email'
const emailHtml = await renderPollingGroupInvitationEmail({
inviterName: inviter?.name || 'A team member',
organizationName: org?.name || 'your organization',
pollingGroupName: result.set.name,
provider,
inviteLink: inviteUrl,
})
const emailResult = await sendEmail({
to: invitation.email,
subject: getEmailSubject('polling-group-invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.warn('Failed to resend invitation email', {
email: invitation.email,
error: emailResult.message,
})
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
} catch (emailError) {
logger.error('Error sending invitation email', emailError)
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
}
logger.info('Resent credential set invitation', {
credentialSetId: id,
invitationId,
userId: session.user.id,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error resending invitation', error)
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
}
}

View File

@@ -0,0 +1,243 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetInvitation, member, organization, 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 { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('CredentialSetInvite')
const createInviteSchema = z.object({
email: z.string().email().optional(),
})
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
const [set] = await db
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
name: credentialSet.name,
providerId: credentialSet.providerId,
})
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (!set) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
.limit(1)
if (!membership) return null
return { set, role: membership.role }
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
const invitations = await db
.select()
.from(credentialSetInvitation)
.where(eq(credentialSetInvitation.credentialSetId, id))
return NextResponse.json({ invitations })
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
const { email } = createInviteSchema.parse(body)
const token = crypto.randomUUID()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
const invitation = {
id: crypto.randomUUID(),
credentialSetId: id,
email: email || null,
token,
invitedBy: session.user.id,
status: 'pending' as const,
expiresAt,
createdAt: new Date(),
}
await db.insert(credentialSetInvitation).values(invitation)
const inviteUrl = `${getBaseUrl()}/credential-account/${token}`
// Send email if email address was provided
if (email) {
try {
// Get inviter name
const [inviter] = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
// Get organization name
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, result.set.organizationId))
.limit(1)
const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email'
const emailHtml = await renderPollingGroupInvitationEmail({
inviterName: inviter?.name || 'A team member',
organizationName: org?.name || 'your organization',
pollingGroupName: result.set.name,
provider,
inviteLink: inviteUrl,
})
const emailResult = await sendEmail({
to: email,
subject: getEmailSubject('polling-group-invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.warn('Failed to send invitation email', {
email,
error: emailResult.message,
})
}
} catch (emailError) {
logger.error('Error sending invitation email', emailError)
// Don't fail the invitation creation if email fails
}
}
logger.info('Created credential set invitation', {
credentialSetId: id,
invitationId: invitation.id,
userId: session.user.id,
emailSent: !!email,
})
return NextResponse.json({
invitation: {
...invitation,
inviteUrl,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Error creating invitation', error)
return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const { searchParams } = new URL(req.url)
const invitationId = searchParams.get('invitationId')
if (!invitationId) {
return NextResponse.json({ error: 'invitationId is required' }, { status: 400 })
}
try {
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
await db
.update(credentialSetInvitation)
.set({ status: 'cancelled' })
.where(
and(
eq(credentialSetInvitation.id, invitationId),
eq(credentialSetInvitation.credentialSetId, id)
)
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error cancelling invitation', error)
return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 })
}
}

View File

@@ -0,0 +1,185 @@
import { db } from '@sim/db'
import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMembers')
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
const [set] = await db
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
providerId: credentialSet.providerId,
})
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (!set) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
.limit(1)
if (!membership) return null
return { set, role: membership.role }
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
const members = await db
.select({
id: credentialSetMember.id,
userId: credentialSetMember.userId,
status: credentialSetMember.status,
joinedAt: credentialSetMember.joinedAt,
createdAt: credentialSetMember.createdAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(credentialSetMember)
.leftJoin(user, eq(credentialSetMember.userId, user.id))
.where(eq(credentialSetMember.credentialSetId, id))
// Get credentials for all active members filtered by the polling group's provider
const activeMembers = members.filter((m) => m.status === 'active')
const memberUserIds = activeMembers.map((m) => m.userId)
let credentials: { userId: string; providerId: string; accountId: string }[] = []
if (memberUserIds.length > 0 && result.set.providerId) {
credentials = await db
.select({
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
})
.from(account)
.where(
and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId))
)
}
// Group credentials by userId
const credentialsByUser = credentials.reduce(
(acc, cred) => {
if (!acc[cred.userId]) {
acc[cred.userId] = []
}
acc[cred.userId].push({
providerId: cred.providerId,
accountId: cred.accountId,
})
return acc
},
{} as Record<string, { providerId: string; accountId: string }[]>
)
// Attach credentials to members
const membersWithCredentials = members.map((m) => ({
...m,
credentials: credentialsByUser[m.userId] || [],
}))
return NextResponse.json({ members: membersWithCredentials })
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const { searchParams } = new URL(req.url)
const memberId = searchParams.get('memberId')
if (!memberId) {
return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
}
try {
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const [memberToRemove] = await db
.select()
.from(credentialSetMember)
.where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id)))
.limit(1)
if (!memberToRemove) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
const requestId = crypto.randomUUID().slice(0, 8)
// Use transaction to ensure member deletion + webhook sync are atomic
await db.transaction(async (tx) => {
await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))
const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx)
logger.info('Synced webhooks after member removed', {
credentialSetId: id,
...syncResult,
})
})
logger.info('Removed member from credential set', {
credentialSetId: id,
memberId,
userId: session.user.id,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error removing member from credential set', error)
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
}
}

View File

@@ -0,0 +1,183 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetMember, member } 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 { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSet')
const updateCredentialSetSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
})
async function getCredentialSetWithAccess(credentialSetId: string, userId: string) {
const [set] = await db
.select({
id: credentialSet.id,
organizationId: credentialSet.organizationId,
name: credentialSet.name,
description: credentialSet.description,
providerId: credentialSet.providerId,
createdBy: credentialSet.createdBy,
createdAt: credentialSet.createdAt,
updatedAt: credentialSet.updatedAt,
})
.from(credentialSet)
.where(eq(credentialSet.id, credentialSetId))
.limit(1)
if (!set) return null
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId)))
.limit(1)
if (!membership) return null
return { set, role: membership.role }
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
return NextResponse.json({ credentialSet: result.set })
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
const body = await req.json()
const updates = updateCredentialSetSchema.parse(body)
if (updates.name) {
const existingSet = await db
.select({ id: credentialSet.id })
.from(credentialSet)
.where(
and(
eq(credentialSet.organizationId, result.set.organizationId),
eq(credentialSet.name, updates.name)
)
)
.limit(1)
if (existingSet.length > 0 && existingSet[0].id !== id) {
return NextResponse.json(
{ error: 'A credential set with this name already exists' },
{ status: 409 }
)
}
}
await db
.update(credentialSet)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(credentialSet.id, id))
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
return NextResponse.json({ credentialSet: updated })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Error updating credential set', error)
return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { id } = await params
try {
const result = await getCredentialSetWithAccess(id, session.user.id)
if (!result) {
return NextResponse.json({ error: 'Credential set not found' }, { status: 404 })
}
if (result.role !== 'admin' && result.role !== 'owner') {
return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 })
}
await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id))
await db.delete(credentialSet).where(eq(credentialSet.id, id))
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting credential set', error)
return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 })
}
}

View File

@@ -0,0 +1,53 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, gt, isNull, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialSetInvitations')
export async function GET() {
const session = await getSession()
if (!session?.user?.id || !session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const invitations = await db
.select({
invitationId: credentialSetInvitation.id,
token: credentialSetInvitation.token,
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
createdAt: credentialSetInvitation.createdAt,
credentialSetId: credentialSet.id,
credentialSetName: credentialSet.name,
providerId: credentialSet.providerId,
organizationId: organization.id,
organizationName: organization.name,
invitedByName: user.name,
invitedByEmail: user.email,
})
.from(credentialSetInvitation)
.innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
.innerJoin(organization, eq(credentialSet.organizationId, organization.id))
.leftJoin(user, eq(credentialSetInvitation.invitedBy, user.id))
.where(
and(
or(
eq(credentialSetInvitation.email, session.user.email),
isNull(credentialSetInvitation.email)
),
eq(credentialSetInvitation.status, 'pending'),
gt(credentialSetInvitation.expiresAt, new Date())
)
)
return NextResponse.json({ invitations })
} catch (error) {
logger.error('Error fetching credential set invitations', error)
return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 })
}
}

View File

@@ -0,0 +1,196 @@
import { db } from '@sim/db'
import {
credentialSet,
credentialSetInvitation,
credentialSetMember,
organization,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetInviteToken')
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params
const [invitation] = await db
.select({
id: credentialSetInvitation.id,
credentialSetId: credentialSetInvitation.credentialSetId,
email: credentialSetInvitation.email,
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
credentialSetName: credentialSet.name,
providerId: credentialSet.providerId,
organizationId: credentialSet.organizationId,
organizationName: organization.name,
})
.from(credentialSetInvitation)
.innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
.innerJoin(organization, eq(credentialSet.organizationId, organization.id))
.where(eq(credentialSetInvitation.token, token))
.limit(1)
if (!invitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 })
}
if (new Date() > invitation.expiresAt) {
await db
.update(credentialSetInvitation)
.set({ status: 'expired' })
.where(eq(credentialSetInvitation.id, invitation.id))
return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
}
return NextResponse.json({
invitation: {
credentialSetName: invitation.credentialSetName,
organizationName: invitation.organizationName,
providerId: invitation.providerId,
email: invitation.email,
},
})
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
try {
const [invitationData] = await db
.select({
id: credentialSetInvitation.id,
credentialSetId: credentialSetInvitation.credentialSetId,
email: credentialSetInvitation.email,
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
invitedBy: credentialSetInvitation.invitedBy,
providerId: credentialSet.providerId,
})
.from(credentialSetInvitation)
.innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id))
.where(eq(credentialSetInvitation.token, token))
.limit(1)
if (!invitationData) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
const invitation = invitationData
if (invitation.status !== 'pending') {
return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 })
}
if (new Date() > invitation.expiresAt) {
await db
.update(credentialSetInvitation)
.set({ status: 'expired' })
.where(eq(credentialSetInvitation.id, invitation.id))
return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 })
}
const existingMember = await db
.select()
.from(credentialSetMember)
.where(
and(
eq(credentialSetMember.credentialSetId, invitation.credentialSetId),
eq(credentialSetMember.userId, session.user.id)
)
)
.limit(1)
if (existingMember.length > 0) {
return NextResponse.json(
{ error: 'Already a member of this credential set' },
{ status: 409 }
)
}
const now = new Date()
const requestId = crypto.randomUUID().slice(0, 8)
// Use transaction to ensure membership + invitation update + webhook sync are atomic
await db.transaction(async (tx) => {
await tx.insert(credentialSetMember).values({
id: crypto.randomUUID(),
credentialSetId: invitation.credentialSetId,
userId: session.user.id,
status: 'active',
joinedAt: now,
invitedBy: invitation.invitedBy,
createdAt: now,
updatedAt: now,
})
await tx
.update(credentialSetInvitation)
.set({
status: 'accepted',
acceptedAt: now,
acceptedByUserId: session.user.id,
})
.where(eq(credentialSetInvitation.id, invitation.id))
// Clean up all other pending invitations for the same credential set and email
// This prevents duplicate invites from showing up after accepting one
if (invitation.email) {
await tx
.update(credentialSetInvitation)
.set({
status: 'accepted',
acceptedAt: now,
acceptedByUserId: session.user.id,
})
.where(
and(
eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId),
eq(credentialSetInvitation.email, invitation.email),
eq(credentialSetInvitation.status, 'pending')
)
)
}
// Sync webhooks within the transaction
const syncResult = await syncAllWebhooksForCredentialSet(
invitation.credentialSetId,
requestId,
tx
)
logger.info('Synced webhooks after member joined', {
credentialSetId: invitation.credentialSetId,
...syncResult,
})
})
logger.info('Accepted credential set invitation', {
invitationId: invitation.id,
credentialSetId: invitation.credentialSetId,
userId: session.user.id,
})
return NextResponse.json({
success: true,
credentialSetId: invitation.credentialSetId,
providerId: invitation.providerId,
})
} catch (error) {
logger.error('Error accepting invitation', error)
return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 })
}
}

View File

@@ -0,0 +1,115 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetMember, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMemberships')
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: credentialSetMember.id,
status: credentialSetMember.status,
joinedAt: credentialSetMember.joinedAt,
credentialSetId: credentialSet.id,
credentialSetName: credentialSet.name,
credentialSetDescription: credentialSet.description,
providerId: credentialSet.providerId,
organizationId: organization.id,
organizationName: organization.name,
})
.from(credentialSetMember)
.innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
.innerJoin(organization, eq(credentialSet.organizationId, organization.id))
.where(eq(credentialSetMember.userId, session.user.id))
return NextResponse.json({ memberships })
} catch (error) {
logger.error('Error fetching credential set memberships', error)
return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 })
}
}
/**
* Leave a credential set (self-revocation).
* Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up.
*/
export async function DELETE(req: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const credentialSetId = searchParams.get('credentialSetId')
if (!credentialSetId) {
return NextResponse.json({ error: 'credentialSetId is required' }, { status: 400 })
}
try {
const requestId = crypto.randomUUID().slice(0, 8)
// Use transaction to ensure revocation + webhook sync are atomic
await db.transaction(async (tx) => {
// Find and verify membership
const [membership] = await tx
.select()
.from(credentialSetMember)
.where(
and(
eq(credentialSetMember.credentialSetId, credentialSetId),
eq(credentialSetMember.userId, session.user.id)
)
)
.limit(1)
if (!membership) {
throw new Error('Not a member of this credential set')
}
if (membership.status === 'revoked') {
throw new Error('Already left this credential set')
}
// Set status to 'revoked' - this immediately blocks credential from being used
await tx
.update(credentialSetMember)
.set({
status: 'revoked',
updatedAt: new Date(),
})
.where(eq(credentialSetMember.id, membership.id))
// Sync webhooks to remove this user's credential webhooks
const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId, tx)
logger.info('Synced webhooks after member left', {
credentialSetId,
userId: session.user.id,
...syncResult,
})
})
logger.info('User left credential set', {
credentialSetId,
userId: session.user.id,
})
return NextResponse.json({ success: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to leave credential set'
logger.error('Error leaving credential set', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,176 @@
import { db } from '@sim/db'
import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSets')
const createCredentialSetSchema = z.object({
organizationId: z.string().min(1),
name: z.string().trim().min(1).max(100),
description: z.string().max(500).optional(),
providerId: z.enum(['google-email', 'outlook']),
})
export async function GET(req: Request) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
const { searchParams } = new URL(req.url)
const organizationId = searchParams.get('organizationId')
if (!organizationId) {
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
}
const membership = await db
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.limit(1)
if (membership.length === 0) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const sets = await db
.select({
id: credentialSet.id,
name: credentialSet.name,
description: credentialSet.description,
providerId: credentialSet.providerId,
createdBy: credentialSet.createdBy,
createdAt: credentialSet.createdAt,
updatedAt: credentialSet.updatedAt,
creatorName: user.name,
creatorEmail: user.email,
})
.from(credentialSet)
.leftJoin(user, eq(credentialSet.createdBy, user.id))
.where(eq(credentialSet.organizationId, organizationId))
.orderBy(desc(credentialSet.createdAt))
const setsWithCounts = await Promise.all(
sets.map(async (set) => {
const [memberCount] = await db
.select({ count: count() })
.from(credentialSetMember)
.where(
and(
eq(credentialSetMember.credentialSetId, set.id),
eq(credentialSetMember.status, 'active')
)
)
return {
...set,
memberCount: memberCount?.count ?? 0,
}
})
)
return NextResponse.json({ credentialSets: setsWithCounts })
}
export async function POST(req: Request) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check plan access (team/enterprise) or env var override
const hasAccess = await hasCredentialSetsAccess(session.user.id)
if (!hasAccess) {
return NextResponse.json(
{ error: 'Credential sets require a Team or Enterprise plan' },
{ status: 403 }
)
}
try {
const body = await req.json()
const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)
const membership = await db
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId)))
.limit(1)
const role = membership[0]?.role
if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) {
return NextResponse.json(
{ error: 'Admin or owner permissions required to create credential sets' },
{ status: 403 }
)
}
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (orgExists.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const existingSet = await db
.select({ id: credentialSet.id })
.from(credentialSet)
.where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name)))
.limit(1)
if (existingSet.length > 0) {
return NextResponse.json(
{ error: 'A credential set with this name already exists' },
{ status: 409 }
)
}
const now = new Date()
const newCredentialSet = {
id: crypto.randomUUID(),
organizationId,
name,
description: description || null,
providerId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
}
await db.insert(credentialSet).values(newCredentialSet)
logger.info('Created credential set', {
credentialSetId: newCredentialSet.id,
organizationId,
userId: session.user.id,
})
return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Error creating credential set', error)
return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 })
}
}

View File

@@ -198,15 +198,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
`[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents`
)
// Track bulk document upload
try {
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
trackPlatformEvent('platform.knowledge_base.documents_uploaded', {
'knowledge_base.id': knowledgeBaseId,
'documents.count': createdDocuments.length,
'documents.upload_type': 'bulk',
'processing.chunk_size': validatedData.processingOptions.chunkSize,
'processing.recipe': validatedData.processingOptions.recipe,
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: createdDocuments.length,
uploadType: 'bulk',
chunkSize: validatedData.processingOptions.chunkSize,
recipe: validatedData.processingOptions.recipe,
})
} catch (_e) {
// Silently fail
@@ -262,15 +261,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
userId
)
// Track single document upload
try {
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
trackPlatformEvent('platform.knowledge_base.documents_uploaded', {
'knowledge_base.id': knowledgeBaseId,
'documents.count': 1,
'documents.upload_type': 'single',
'document.mime_type': validatedData.mimeType,
'document.file_size': validatedData.fileSize,
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
mimeType: validatedData.mimeType,
fileSize: validatedData.fileSize,
})
} catch (_e) {
// Silently fail

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import {
deleteKnowledgeBase,
@@ -183,6 +184,14 @@ export async function DELETE(
await deleteKnowledgeBase(id, requestId)
try {
PlatformEvents.knowledgeBaseDeleted({
knowledgeBaseId: id,
})
} catch {
// Telemetry should not fail the operation
}
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${session.user.id}`)
return NextResponse.json({

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service'
@@ -94,6 +95,16 @@ export async function POST(req: NextRequest) {
const newKnowledgeBase = await createKnowledgeBase(createData, requestId)
try {
PlatformEvents.knowledgeBaseCreated({
knowledgeBaseId: newKnowledgeBase.id,
name: validatedData.name,
workspaceId: validatedData.workspaceId,
})
} catch {
// Telemetry should not fail the operation
}
logger.info(
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
)

View File

@@ -5,6 +5,7 @@
*
* @vitest-environment node
*/
import { createEnvMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
@@ -26,13 +27,7 @@ vi.mock('drizzle-orm', () => ({
mockKnowledgeSchemas()
vi.mock('@/lib/core/config/env', () => ({
env: {
OPENAI_API_KEY: 'test-api-key',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' }))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn(() => 'test-request-id'),

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
@@ -294,6 +295,16 @@ export async function POST(request: NextRequest) {
const documentIds = results.map((result) => result.documentId)
const documentNameMap = await getDocumentNamesByIds(documentIds)
try {
PlatformEvents.knowledgeBaseSearched({
knowledgeBaseId: accessibleKbIds[0],
resultsCount: results.length,
workspaceId: workspaceId || undefined,
})
} catch {
// Telemetry should not fail the operation
}
return NextResponse.json({
success: true,
data: {

View File

@@ -4,6 +4,7 @@
*
* @vitest-environment node
*/
import { createEnvMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('drizzle-orm')
@@ -30,12 +31,7 @@ vi.stubGlobal(
})
)
vi.mock('@/lib/core/config/env', () => ({
env: {},
getEnv: (key: string) => process.env[key],
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/core/config/env', () => createEnvMock())
import {
generateSearchEmbedding,

View File

@@ -6,6 +6,7 @@
* This file contains unit tests for the knowledge base utility functions,
* including access checks, document processing, and embedding generation.
*/
import { createEnvMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('drizzle-orm', () => ({
@@ -15,12 +16,7 @@ vi.mock('drizzle-orm', () => ({
sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }),
}))
vi.mock('@/lib/core/config/env', () => ({
env: { OPENAI_API_KEY: 'test-key' },
getEnv: (key: string) => process.env[key],
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-key' }))
vi.mock('@/lib/knowledge/documents/utils', () => ({
retryWithExponentialBackoff: (fn: any) => fn(),

View File

@@ -140,12 +140,12 @@ export const POST = withMcpAuth('write')(
)
try {
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
trackPlatformEvent('platform.mcp.server_added', {
'mcp.server_id': serverId,
'mcp.server_name': body.name,
'mcp.transport': body.transport,
'workspace.id': workspaceId,
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.mcpServerAdded({
serverId,
serverName: body.name,
transport: body.transport,
workspaceId,
})
} catch (_e) {
// Silently fail

View File

@@ -194,12 +194,12 @@ export const POST = withMcpAuth('read')(
logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
try {
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
trackPlatformEvent('platform.mcp.tool_executed', {
'mcp.server_id': serverId,
'mcp.tool_name': toolName,
'mcp.execution_status': 'success',
'workspace.id': workspaceId,
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.mcpToolExecuted({
serverId,
toolName,
status: 'success',
workspaceId,
})
} catch {
// Telemetry failure is non-critical

View File

@@ -15,8 +15,11 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation')
@@ -69,6 +72,102 @@ export async function GET(
}
}
// Resend invitation
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Verify user is admin/owner
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
const orgInvitation = await db
.select()
.from(invitation)
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
.then((rows) => rows[0])
if (!orgInvitation) {
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
}
if (orgInvitation.status !== 'pending') {
return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
}
const org = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.then((rows) => rows[0])
const inviter = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
// Update expiration date
const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
await db
.update(invitation)
.set({ expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId))
// Send email
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
org?.name || 'organization',
`${getBaseUrl()}/invite/${invitationId}`
)
const emailResult = await sendEmail({
to: orgInvitation.email,
subject: getEmailSubject('invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (!emailResult.success) {
logger.error('Failed to resend invitation email', {
email: orgInvitation.email,
error: emailResult.message,
})
return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 })
}
logger.info('Organization invitation resent', {
organizationId,
invitationId,
resentBy: session.user.id,
email: orgInvitation.email,
})
return NextResponse.json({
success: true,
message: 'Invitation resent successfully',
})
} catch (error) {
logger.error('Error resending organization invitation:', error)
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
}
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }

View File

@@ -41,6 +41,9 @@ export async function POST(request: NextRequest) {
vertexProject,
vertexLocation,
vertexCredential,
bedrockAccessKeyId,
bedrockSecretKey,
bedrockRegion,
responseFormat,
workflowId,
workspaceId,
@@ -67,6 +70,9 @@ export async function POST(request: NextRequest) {
hasVertexProject: !!vertexProject,
hasVertexLocation: !!vertexLocation,
hasVertexCredential: !!vertexCredential,
hasBedrockAccessKeyId: !!bedrockAccessKeyId,
hasBedrockSecretKey: !!bedrockSecretKey,
hasBedrockRegion: !!bedrockRegion,
hasResponseFormat: !!responseFormat,
workflowId,
stream: !!stream,
@@ -116,6 +122,9 @@ export async function POST(request: NextRequest) {
azureApiVersion,
vertexProject,
vertexLocation,
bedrockAccessKeyId,
bedrockSecretKey,
bedrockRegion,
responseFormat,
workflowId,
workspaceId,

View File

@@ -168,18 +168,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}`
)
// Track template usage
try {
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
const { PlatformEvents } = await import('@/lib/core/telemetry')
const templateState = templateData.state as any
trackPlatformEvent('platform.template.used', {
'template.id': id,
'template.name': templateData.name,
'workflow.created_id': newWorkflowId,
'workflow.blocks_count': templateState?.blocks
? Object.keys(templateState.blocks).length
: 0,
'workspace.id': workspaceId,
PlatformEvents.templateUsed({
templateId: id,
templateName: templateData.name,
newWorkflowId,
blocksCount: templateState?.blocks ? Object.keys(templateState.blocks).length : 0,
workspaceId,
})
} catch (_e) {
// Silently fail

View File

@@ -0,0 +1,199 @@
/**
* Admin BYOK Keys API
*
* GET /api/v1/admin/byok
* List all BYOK keys with optional filtering.
*
* Query Parameters:
* - organizationId?: string - Filter by organization ID (finds all workspaces billed to this org)
* - workspaceId?: string - Filter by specific workspace ID
*
* Response: { data: AdminBYOKKey[], pagination: PaginationMeta }
*
* DELETE /api/v1/admin/byok
* Delete BYOK keys for an organization or workspace.
* Used when an enterprise plan churns to clean up BYOK keys.
*
* Query Parameters:
* - organizationId: string - Delete all BYOK keys for workspaces billed to this org
* - workspaceId?: string - Delete keys for a specific workspace only (optional)
*
* Response: { success: true, deletedCount: number, workspacesAffected: string[] }
*/
import { db } from '@sim/db'
import { user, workspace, workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminBYOKAPI')
export interface AdminBYOKKey {
id: string
workspaceId: string
workspaceName: string
organizationId: string
providerId: string
createdAt: string
createdByUserId: string | null
createdByEmail: string | null
}
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const workspaceId = url.searchParams.get('workspaceId')
try {
let workspaceIds: string[] = []
if (workspaceId) {
workspaceIds = [workspaceId]
} else if (organizationId) {
const workspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.billedAccountUserId, organizationId))
workspaceIds = workspaces.map((w) => w.id)
}
const query = db
.select({
id: workspaceBYOKKeys.id,
workspaceId: workspaceBYOKKeys.workspaceId,
workspaceName: workspace.name,
organizationId: workspace.billedAccountUserId,
providerId: workspaceBYOKKeys.providerId,
createdAt: workspaceBYOKKeys.createdAt,
createdByUserId: workspaceBYOKKeys.createdBy,
createdByEmail: user.email,
})
.from(workspaceBYOKKeys)
.innerJoin(workspace, eq(workspaceBYOKKeys.workspaceId, workspace.id))
.leftJoin(user, eq(workspaceBYOKKeys.createdBy, user.id))
let keys
if (workspaceIds.length > 0) {
keys = await query.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
} else {
keys = await query
}
const formattedKeys: AdminBYOKKey[] = keys.map((k) => ({
id: k.id,
workspaceId: k.workspaceId,
workspaceName: k.workspaceName,
organizationId: k.organizationId,
providerId: k.providerId,
createdAt: k.createdAt.toISOString(),
createdByUserId: k.createdByUserId,
createdByEmail: k.createdByEmail,
}))
logger.info('Admin API: Listed BYOK keys', {
organizationId,
workspaceId,
count: formattedKeys.length,
})
return singleResponse({
data: formattedKeys,
pagination: {
total: formattedKeys.length,
limit: formattedKeys.length,
offset: 0,
hasMore: false,
},
})
} catch (error) {
logger.error('Admin API: Failed to list BYOK keys', { error, organizationId, workspaceId })
return internalErrorResponse('Failed to list BYOK keys')
}
})
export const DELETE = withAdminAuth(async (request) => {
const url = new URL(request.url)
const organizationId = url.searchParams.get('organizationId')
const workspaceId = url.searchParams.get('workspaceId')
const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
if (!organizationId && !workspaceId) {
return badRequestResponse('Either organizationId or workspaceId is required')
}
try {
let workspaceIds: string[] = []
if (workspaceId) {
workspaceIds = [workspaceId]
} else if (organizationId) {
const workspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.billedAccountUserId, organizationId))
workspaceIds = workspaces.map((w) => w.id)
}
if (workspaceIds.length === 0) {
logger.info('Admin API: No workspaces found for BYOK cleanup', {
organizationId,
workspaceId,
})
return singleResponse({
success: true,
deletedCount: 0,
workspacesAffected: [],
message: 'No workspaces found for the given organization/workspace ID',
})
}
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(workspaceBYOKKeys)
.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
const totalToDelete = Number(countResult[0]?.count ?? 0)
if (totalToDelete === 0) {
logger.info('Admin API: No BYOK keys to delete', {
organizationId,
workspaceId,
workspaceIds,
})
return singleResponse({
success: true,
deletedCount: 0,
workspacesAffected: [],
message: 'No BYOK keys found for the specified workspaces',
})
}
await db.delete(workspaceBYOKKeys).where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
logger.info('Admin API: Deleted BYOK keys', {
organizationId,
workspaceId,
workspaceIds,
deletedCount: totalToDelete,
reason,
})
return singleResponse({
success: true,
deletedCount: totalToDelete,
workspacesAffected: workspaceIds,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to delete BYOK keys', { error, organizationId, workspaceId })
return internalErrorResponse('Failed to delete BYOK keys')
}
})

View File

@@ -51,6 +51,10 @@
* GET /api/v1/admin/subscriptions - List all subscriptions
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*
* BYOK Keys:
* GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
* DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'

View File

@@ -1,10 +1,11 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -184,16 +185,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
hasFailedCountUpdate: failedCount !== undefined,
})
// Update the webhook
// Merge providerConfig to preserve credential-related fields
let finalProviderConfig = webhooks[0].webhook.providerConfig
if (providerConfig !== undefined) {
const existingConfig = (webhooks[0].webhook.providerConfig as Record<string, unknown>) || {}
finalProviderConfig = {
...resolvedProviderConfig,
credentialId: existingConfig.credentialId,
credentialSetId: existingConfig.credentialSetId,
userId: existingConfig.userId,
historyId: existingConfig.historyId,
lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
setupCompleted: existingConfig.setupCompleted,
externalId: existingConfig.externalId,
}
}
const updatedWebhook = await db
.update(webhook)
.set({
path: path !== undefined ? path : webhooks[0].webhook.path,
provider: provider !== undefined ? provider : webhooks[0].webhook.provider,
providerConfig:
providerConfig !== undefined
? resolvedProviderConfig
: webhooks[0].webhook.providerConfig,
providerConfig: finalProviderConfig,
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
updatedAt: new Date(),
@@ -276,13 +289,67 @@ export async function DELETE(
}
const foundWebhook = webhookData.webhook
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId)
await db.delete(webhook).where(eq(webhook.id, id))
const providerConfig = foundWebhook.providerConfig as Record<string, unknown> | null
const credentialSetId = providerConfig?.credentialSetId as string | undefined
const blockId = providerConfig?.blockId as string | undefined
if (credentialSetId && blockId) {
const allCredentialSetWebhooks = await db
.select()
.from(webhook)
.where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId)))
const webhooksToDelete = allCredentialSetWebhooks.filter((w) => {
const config = w.providerConfig as Record<string, unknown> | null
return config?.credentialSetId === credentialSetId
})
for (const w of webhooksToDelete) {
await cleanupExternalWebhook(w, webhookData.workflow, requestId)
}
const idsToDelete = webhooksToDelete.map((w) => w.id)
for (const wId of idsToDelete) {
await db.delete(webhook).where(eq(webhook.id, wId))
}
try {
for (const wId of idsToDelete) {
PlatformEvents.webhookDeleted({
webhookId: wId,
workflowId: webhookData.workflow.id,
})
}
} catch {
// Telemetry should not fail the operation
}
logger.info(
`[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`,
{
credentialSetId,
blockId,
deletedIds: idsToDelete,
}
)
} else {
await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId)
await db.delete(webhook).where(eq(webhook.id, id))
try {
PlatformEvents.webhookDeleted({
webhookId: id,
workflowId: webhookData.workflow.id,
})
} catch {
// Telemetry should not fail the operation
}
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
}
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting webhook`, {

View File

@@ -5,6 +5,7 @@ import { and, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -262,6 +263,157 @@ export async function POST(request: NextRequest) {
workflowRecord.workspaceId || undefined
)
// --- Credential Set Handling ---
// For credential sets, we fan out to create one webhook per credential at save time.
// This applies to all OAuth-based triggers, not just polling ones.
// Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields
const rawCredentialId = (resolvedProviderConfig?.credentialId ||
resolvedProviderConfig?.triggerCredentials) as string | undefined
const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined
if (directCredentialSetId || rawCredentialId) {
const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants')
const credentialSetId =
directCredentialSetId ||
(rawCredentialId && isCredentialSetValue(rawCredentialId)
? extractCredentialSetId(rawCredentialId)
: null)
if (credentialSetId) {
logger.info(
`[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}`
)
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
const { syncWebhooksForCredentialSet, configureGmailPolling, configureOutlookPolling } =
await import('@/lib/webhooks/utils.server')
// Map provider to OAuth provider ID
const oauthProviderId = getProviderIdFromServiceId(provider)
const {
credentialId: _cId,
triggerCredentials: _tCred,
credentialSetId: _csId,
...baseProviderConfig
} = resolvedProviderConfig
try {
const syncResult = await syncWebhooksForCredentialSet({
workflowId,
blockId,
provider,
basePath: finalPath,
credentialSetId,
oauthProviderId,
providerConfig: baseProviderConfig,
requestId,
})
if (syncResult.webhooks.length === 0) {
logger.error(
`[${requestId}] No webhooks created for credential set - no valid credentials found`
)
return NextResponse.json(
{
error: `No valid credentials found in credential set for ${provider}`,
details: 'Please ensure team members have connected their accounts',
},
{ status: 400 }
)
}
// Configure each new webhook (for providers that need configuration)
const pollingProviders = ['gmail', 'outlook']
const needsConfiguration = pollingProviders.includes(provider)
if (needsConfiguration) {
const configureFunc =
provider === 'gmail' ? configureGmailPolling : configureOutlookPolling
const configureErrors: string[] = []
for (const wh of syncResult.webhooks) {
if (wh.isNew) {
// Fetch the webhook data for configuration
const webhookRows = await db
.select()
.from(webhook)
.where(eq(webhook.id, wh.id))
.limit(1)
if (webhookRows.length > 0) {
const success = await configureFunc(webhookRows[0], requestId)
if (!success) {
configureErrors.push(
`Failed to configure webhook for credential ${wh.credentialId}`
)
logger.warn(
`[${requestId}] Failed to configure ${provider} polling for webhook ${wh.id}`
)
}
}
}
}
if (
configureErrors.length > 0 &&
configureErrors.length === syncResult.webhooks.length
) {
// All configurations failed - roll back
logger.error(`[${requestId}] All webhook configurations failed, rolling back`)
for (const wh of syncResult.webhooks) {
await db.delete(webhook).where(eq(webhook.id, wh.id))
}
return NextResponse.json(
{
error: `Failed to configure ${provider} polling`,
details: 'Please check account permissions and try again',
},
{ status: 500 }
)
}
}
logger.info(
`[${requestId}] Successfully synced ${syncResult.webhooks.length} webhooks for credential set ${credentialSetId}`
)
// Return the first webhook as the "primary" for the UI
// The UI will query by credentialSetId to get all of them
const primaryWebhookRows = await db
.select()
.from(webhook)
.where(eq(webhook.id, syncResult.webhooks[0].id))
.limit(1)
return NextResponse.json(
{
webhook: primaryWebhookRows[0],
credentialSetInfo: {
credentialSetId,
totalWebhooks: syncResult.webhooks.length,
created: syncResult.created,
updated: syncResult.updated,
deleted: syncResult.deleted,
},
},
{ status: syncResult.created > 0 ? 201 : 200 }
)
} catch (err) {
logger.error(`[${requestId}] Error syncing webhooks for credential set`, err)
return NextResponse.json(
{
error: `Failed to configure ${provider} webhook`,
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
}
// --- End Credential Set Handling ---
// Create external subscriptions before saving to DB to prevent orphaned records
let externalSubscriptionId: string | undefined
let externalSubscriptionCreated = false
@@ -422,6 +574,10 @@ export async function POST(request: NextRequest) {
blockId,
provider,
providerConfig: resolvedProviderConfig,
credentialSetId:
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
updatedAt: new Date(),
})
@@ -445,6 +601,10 @@ export async function POST(request: NextRequest) {
path: finalPath,
provider,
providerConfig: resolvedProviderConfig,
credentialSetId:
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
@@ -584,7 +744,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
const grainResult = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
@@ -594,11 +754,12 @@ export async function POST(request: NextRequest) {
requestId
)
if (grainHookId) {
// Update the webhook record with the external Grain hook ID
if (grainResult) {
// Update the webhook record with the external Grain hook ID and event types for filtering
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
externalId: grainResult.id,
eventTypes: grainResult.eventTypes,
}
await db
.update(webhook)
@@ -610,7 +771,8 @@ export async function POST(request: NextRequest) {
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
grainHookId: grainResult.id,
eventTypes: grainResult.eventTypes,
webhookId: savedWebhook.id,
})
}
@@ -631,6 +793,19 @@ export async function POST(request: NextRequest) {
}
// --- End Grain specific logic ---
if (!targetWebhookId && savedWebhook) {
try {
PlatformEvents.webhookCreated({
webhookId: savedWebhook.id,
workflowId: workflowId,
provider: provider || 'generic',
workspaceId: workflowRecord.workspaceId || undefined,
})
} catch {
// Telemetry should not fail the operation
}
}
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
@@ -1003,10 +1178,10 @@ async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
): Promise<{ id: string; eventTypes: string[] } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
@@ -1018,12 +1193,53 @@ async function createGrainWebhookSubscription(
)
}
// Map trigger IDs to Grain API hook_type (only 2 options: recording_added, upload_status)
const hookTypeMap: Record<string, string> = {
grain_webhook: 'recording_added',
grain_recording_created: 'recording_added',
grain_recording_updated: 'recording_added',
grain_highlight_created: 'recording_added',
grain_highlight_updated: 'recording_added',
grain_story_created: 'recording_added',
grain_upload_status: 'upload_status',
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_created'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_created'],
grain_upload_status: ['upload_status'],
}
const hookType = hookTypeMap[triggerId] ?? 'recording_added'
const eventTypes = eventTypeMap[triggerId] ?? []
if (!hookTypeMap[triggerId]) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`,
{
webhookId: webhookData.id,
}
)
}
logger.info(`[${requestId}] Creating Grain webhook`, {
triggerId,
hookType,
eventTypes,
webhookId: webhookData.id,
})
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
hook_type: hookType,
}
// Build include object based on configuration
@@ -1053,8 +1269,10 @@ async function createGrainWebhookSubscription(
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error) {
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
logger.warn('[App] Grain response body:', responseBody)
const errorMessage =
responseBody.errors?.detail ||
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
@@ -1082,10 +1300,11 @@ async function createGrainWebhookSubscription(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
eventTypes,
}
)
return responseBody.id
return { id: responseBody.id, eventTypes }
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,

View File

@@ -157,6 +157,112 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/webhooks/processor', () => ({
findAllWebhooksForPath: vi.fn().mockImplementation(async (options: { path: string }) => {
// Filter webhooks by path from globalMockData
const matchingWebhooks = globalMockData.webhooks.filter(
(wh) => wh.path === options.path && wh.isActive
)
if (matchingWebhooks.length === 0) {
return []
}
// Return array of {webhook, workflow} objects
return matchingWebhooks.map((wh) => {
const matchingWorkflow = globalMockData.workflows.find((w) => w.id === wh.workflowId) || {
id: wh.workflowId || 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
}
return {
webhook: wh,
workflow: matchingWorkflow,
}
})
}),
parseWebhookBody: vi.fn().mockImplementation(async (request: any) => {
try {
const cloned = request.clone()
const rawBody = await cloned.text()
const body = rawBody ? JSON.parse(rawBody) : {}
return { body, rawBody }
} catch {
return { body: {}, rawBody: '' }
}
}),
handleProviderChallenges: vi.fn().mockResolvedValue(null),
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
verifyProviderAuth: vi
.fn()
.mockImplementation(
async (
foundWebhook: any,
_foundWorkflow: any,
request: any,
_rawBody: string,
_requestId: string
) => {
// Implement generic webhook auth verification for tests
if (foundWebhook.provider === 'generic') {
const providerConfig = foundWebhook.providerConfig || {}
if (providerConfig.requireAuth) {
const configToken = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName
if (configToken) {
let isTokenValid = false
if (secretHeaderName) {
// Custom header auth
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
if (headerValue === configToken) {
isTokenValid = true
}
} else {
// Bearer token auth
const authHeader = request.headers.get('authorization')
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7)
if (token === configToken) {
isTokenValid = true
}
}
}
if (!isTokenValid) {
const { NextResponse } = await import('next/server')
return new NextResponse('Unauthorized - Invalid authentication token', {
status: 401,
})
}
} else {
// Auth required but no token configured
const { NextResponse } = await import('next/server')
return new NextResponse('Unauthorized - Authentication required but not configured', {
status: 401,
})
}
}
}
return null
}
),
checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
const { NextResponse } = require('next/server')
return NextResponse.json({ error }, { status })
}),
shouldSkipWebhookEvent: vi.fn().mockReturnValue(false),
handlePreDeploymentVerification: vi.fn().mockReturnValue(null),
queueWebhookExecution: vi.fn().mockImplementation(async () => {
// Call processWebhookMock so tests can verify it was called
processWebhookMock()
const { NextResponse } = await import('next/server')
return NextResponse.json({ message: 'Webhook processed' })
}),
}))
vi.mock('drizzle-orm/postgres-js', () => ({
drizzle: vi.fn().mockReturnValue({}),
}))
@@ -165,6 +271,10 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
import { POST } from '@/app/api/webhooks/trigger/[path]/route'

View File

@@ -3,11 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
findWebhookAndWorkflow,
findAllWebhooksForPath,
formatProviderErrorResponse,
handlePreDeploymentVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
queueWebhookExecution,
shouldSkipWebhookEvent,
verifyProviderAuth,
} from '@/lib/webhooks/processor'
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
@@ -22,19 +25,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const requestId = generateRequestId()
const { path } = await params
// Handle Microsoft Graph subscription validation
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
// Handle other GET-based verifications if needed
// Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.)
const challengeResponse = await handleProviderChallenges({}, request, requestId, path)
if (challengeResponse) {
return challengeResponse
@@ -50,26 +41,10 @@ export async function POST(
const requestId = generateRequestId()
const { path } = await params
// Log ALL incoming webhook requests for debugging
logger.info(`[${requestId}] Incoming webhook request`, {
path,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
})
// Handle Microsoft Graph subscription validation (some environments send POST with validationToken)
try {
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation (POST) for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
} catch {
// ignore URL parsing errors; proceed to normal handling
// Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
if (earlyChallenge) {
return earlyChallenge
}
const parseResult = await parseWebhookBody(request, requestId)
@@ -86,118 +61,118 @@ export async function POST(
return challengeResponse
}
const findResult = await findWebhookAndWorkflow({ requestId, path })
// Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path)
const webhooksForPath = await findAllWebhooksForPath({ requestId, path })
if (!findResult) {
if (webhooksForPath.length === 0) {
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}
const { webhook: foundWebhook, workflow: foundWorkflow } = findResult
// Process each webhook
// For credential sets with shared paths, each webhook represents a different credential
const responses: NextResponse[] = []
// Log HubSpot webhook details for debugging
if (foundWebhook.provider === 'hubspot') {
const events = Array.isArray(body) ? body : [body]
const firstEvent = events[0]
logger.info(`[${requestId}] HubSpot webhook received`, {
path,
subscriptionType: firstEvent?.subscriptionType,
objectId: firstEvent?.objectId,
portalId: firstEvent?.portalId,
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
triggerId: foundWebhook.providerConfig?.triggerId,
eventCount: events.length,
})
}
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
request,
rawBody,
requestId
)
if (authError) {
return authError
}
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessError) {
return preprocessError
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
})
if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
text: 'An unexpected error occurred during preprocessing',
},
{ status: 500 }
)
}
return NextResponse.json(
{ error: 'An unexpected error occurred during preprocessing' },
{ status: 500 }
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
request,
rawBody,
requestId
)
}
if (authError) {
// For multi-webhook, log and continue to next webhook
if (webhooksForPath.length > 1) {
logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
continue
}
return authError
}
if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
// For Grain, if block doesn't exist in deployment, treat as verification request
// Grain validates webhook URLs during creation, and the block may not be deployed yet
if (foundWebhook.provider === 'grain') {
logger.info(
`[${requestId}] Grain webhook verification - block not in deployment, returning 200 OK`
)
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
// Reachability test should return immediately for the first webhook
return reachabilityResponse
}
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessError) {
if (webhooksForPath.length > 1) {
logger.warn(
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
)
continue
}
return preprocessError
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
})
if (webhooksForPath.length > 1) {
continue
}
logger.info(
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
return formatProviderErrorResponse(
foundWebhook,
'An unexpected error occurred during preprocessing',
500
)
return new NextResponse('Trigger block not found in deployment', { status: 404 })
}
}
if (foundWebhook.provider === 'stripe') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const eventTypes = providerConfig.eventTypes
if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
const preDeploymentResponse = handlePreDeploymentVerification(foundWebhook, requestId)
if (preDeploymentResponse) {
return preDeploymentResponse
}
if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) {
const eventType = body?.type
if (eventType && !eventTypes.includes(eventType)) {
logger.info(
`[${requestId}] Stripe event type '${eventType}' not in allowed list, skipping execution`
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`
)
return new NextResponse('Event type filtered', { status: 200 })
if (webhooksForPath.length > 1) {
continue
}
return new NextResponse('Trigger block not found in deployment', { status: 404 })
}
}
if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) {
continue
}
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
testMode: false,
executionTarget: 'deployed',
})
responses.push(response)
}
return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
testMode: false,
executionTarget: 'deployed',
// Return the last successful response, or a combined response for multiple webhooks
if (responses.length === 0) {
return new NextResponse('No webhooks processed successfully', { status: 500 })
}
if (responses.length === 1) {
return responses[0]
}
// For multiple webhooks, return success if at least one succeeded
logger.info(
`[${requestId}] Processed ${responses.length} webhooks for path: ${path} (credential set fan-out)`
)
return NextResponse.json({
success: true,
webhooksProcessed: responses.length,
})
}

View File

@@ -217,10 +217,8 @@ export async function DELETE(
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
try {
const { trackPlatformEvent } = await import('@/lib/core/telemetry')
trackPlatformEvent('platform.workflow.undeployed', {
'workflow.id': id,
})
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowUndeployed({ workflowId: id })
} catch (_e) {
// Silently fail
}

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
@@ -46,6 +47,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
})
try {
PlatformEvents.workflowDuplicated({
sourceWorkflowId,
newWorkflowId: result.id,
workspaceId,
})
} catch {
// Telemetry should not fail the operation
}
const elapsed = Date.now() - startTime
logger.info(
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`

View File

@@ -8,6 +8,7 @@ import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-ke
import { getSession } from '@/lib/auth'
import { verifyInternalToken } from '@/lib/auth/internal'
import { env } from '@/lib/core/config/env'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getWorkflowAccessContext, getWorkflowById } from '@/lib/workflows/utils'
@@ -335,6 +336,15 @@ export async function DELETE(
await db.delete(workflow).where(eq(workflow.id, workflowId))
try {
PlatformEvents.workflowDeleted({
workflowId,
workspaceId: workflowData.workspaceId || undefined,
})
} catch {
// Telemetry should not fail the operation
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`)

View File

@@ -317,6 +317,8 @@ interface WebhookMetadata {
providerConfig: Record<string, any>
}
const CREDENTIAL_SET_PREFIX = 'credentialSet:'
function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerId =
getSubBlockValue<string>(block, 'triggerId') ||
@@ -328,9 +330,17 @@ function buildWebhookMetadata(block: BlockState): WebhookMetadata | null {
const triggerDef = triggerId ? getTrigger(triggerId) : undefined
const provider = triggerDef?.provider || null
// Handle credential sets vs individual credentials
const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX)
const credentialSetId = isCredentialSet
? triggerCredentials!.slice(CREDENTIAL_SET_PREFIX.length)
: undefined
const credentialId = isCredentialSet ? undefined : triggerCredentials
const providerConfig = {
...(typeof triggerConfig === 'object' ? triggerConfig : {}),
...(triggerCredentials ? { credentialId: triggerCredentials } : {}),
...(credentialId ? { credentialId } : {}),
...(credentialSetId ? { credentialSetId } : {}),
...(triggerId ? { triggerId } : {}),
}
@@ -347,6 +357,54 @@ async function upsertWebhookRecord(
webhookId: string,
metadata: WebhookMetadata
): Promise<void> {
const providerConfig = metadata.providerConfig as Record<string, unknown>
const credentialSetId = providerConfig?.credentialSetId as string | undefined
// For credential sets, delegate to the sync function which handles fan-out
if (credentialSetId && metadata.provider) {
const { syncWebhooksForCredentialSet } = await import('@/lib/webhooks/utils.server')
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
const oauthProviderId = getProviderIdFromServiceId(metadata.provider)
const requestId = crypto.randomUUID().slice(0, 8)
// Extract base config (without credential-specific fields)
const {
credentialId: _cId,
credentialSetId: _csId,
userId: _uId,
...baseConfig
} = providerConfig
try {
await syncWebhooksForCredentialSet({
workflowId,
blockId: block.id,
provider: metadata.provider,
basePath: metadata.triggerPath,
credentialSetId,
oauthProviderId,
providerConfig: baseConfig as Record<string, any>,
requestId,
})
logger.info('Synced credential set webhooks during workflow save', {
workflowId,
blockId: block.id,
credentialSetId,
})
} catch (error) {
logger.error('Failed to sync credential set webhooks during workflow save', {
workflowId,
blockId: block.id,
credentialSetId,
error,
})
}
return
}
// For individual credentials, use the existing single webhook logic
const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1)
if (existing) {
@@ -381,6 +439,7 @@ async function upsertWebhookRecord(
path: metadata.triggerPath,
provider: metadata.provider,
providerConfig: metadata.providerConfig,
credentialSetId: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -119,12 +119,12 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
import('@/lib/core/telemetry')
.then(({ trackPlatformEvent }) => {
trackPlatformEvent('platform.workflow.created', {
'workflow.id': workflowId,
'workflow.name': name,
'workflow.has_workspace': !!workspaceId,
'workflow.has_folder': !!folderId,
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {

View File

@@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -147,6 +148,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
createdAt: apiKey.createdAt,
})
try {
PlatformEvents.apiKeyGenerated({
userId: userId,
keyName: name,
})
} catch {
// Telemetry should not fail the operation
}
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
return NextResponse.json({
@@ -198,6 +208,17 @@ export async function DELETE(
)
)
try {
for (const keyId of keys) {
PlatformEvents.apiKeyRevoked({
userId: userId,
keyId: keyId,
})
}
} catch {
// Telemetry should not fail the operation
}
logger.info(
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
)

View File

@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -56,6 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let byokEnabled = true
if (isHosted) {
byokEnabled = await isEnterpriseOrgAdminOrOwner(userId)
}
if (!byokEnabled) {
return NextResponse.json({ keys: [], byokEnabled: false })
}
const byokKeys = await db
.select({
id: workspaceBYOKKeys.id,
@@ -97,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
)
return NextResponse.json({ keys: formattedKeys })
return NextResponse.json({ keys: formattedKeys, byokEnabled: true })
} catch (error: unknown) {
logger.error(`[${requestId}] BYOK keys GET error`, error)
return NextResponse.json(
@@ -120,6 +131,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
if (isHosted) {
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
if (!canManageBYOK) {
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
return NextResponse.json(
{
error:
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
},
{ status: 403 }
)
}
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(
@@ -220,6 +245,20 @@ export async function DELETE(
const userId = session.user.id
if (isHosted) {
const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
if (!canManageBYOK) {
logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
return NextResponse.json(
{
error:
'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
},
{ status: 403 }
)
}
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(

View File

@@ -14,6 +14,7 @@ import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
@@ -81,7 +82,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 })
}
// Validate permission type
const validPermissions: PermissionType[] = ['admin', 'write', 'read']
if (!validPermissions.includes(permission)) {
return NextResponse.json(
@@ -90,7 +90,6 @@ export async function POST(req: NextRequest) {
)
}
// Check if user has admin permissions for this workspace
const userPermission = await db
.select()
.from(permissions)
@@ -111,7 +110,6 @@ export async function POST(req: NextRequest) {
)
}
// Get the workspace details for the email
const workspaceDetails = await db
.select()
.from(workspace)
@@ -122,8 +120,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
// Check if the user is already a member
// First find if a user with this email exists
const existingUser = await db
.select()
.from(user)
@@ -131,7 +127,6 @@ export async function POST(req: NextRequest) {
.then((rows) => rows[0])
if (existingUser) {
// Check if the user already has permissions for this workspace
const existingPermission = await db
.select()
.from(permissions)
@@ -155,7 +150,6 @@ export async function POST(req: NextRequest) {
}
}
// Check if there's already a pending invitation
const existingInvitation = await db
.select()
.from(workspaceInvitation)
@@ -178,12 +172,10 @@ export async function POST(req: NextRequest) {
)
}
// Generate a unique token and set expiry date (1 week from now)
const token = randomUUID()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
// Create the invitation
const invitationData = {
id: randomUUID(),
workspaceId,
@@ -198,10 +190,19 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
}
// Create invitation
await db.insert(workspaceInvitation).values(invitationData)
// Send the invitation email
try {
PlatformEvents.workspaceMemberInvited({
workspaceId,
invitedBy: session.user.id,
inviteeEmail: email,
role: permission,
})
} catch {
// Telemetry should not fail the operation
}
await sendInvitationEmail({
to: email,
inviterName: session.user.name || session.user.email || 'A user',
@@ -217,7 +218,6 @@ export async function POST(req: NextRequest) {
}
}
// Helper function to send invitation email using the Resend API
async function sendInvitationEmail({
to,
inviterName,
@@ -233,7 +233,6 @@ async function sendInvitationEmail({
}) {
try {
const baseUrl = getBaseUrl()
// Use invitation ID in path, token in query parameter for security
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}`
const emailHtml = await render(
@@ -263,6 +262,5 @@ async function sendInvitationEmail({
}
} catch (error) {
logger.error('Error sending invitation email:', error)
// Continue even if email fails - the invitation is still created
}
}

View File

@@ -5,6 +5,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -22,7 +23,6 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get all workspaces where the user has permissions
const userWorkspaces = await db
.select({
workspace: workspace,
@@ -34,19 +34,15 @@ export async function GET() {
.orderBy(desc(workspace.createdAt))
if (userWorkspaces.length === 0) {
// Create a default workspace for the user
const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name)
// Migrate existing workflows to the default workspace
await migrateExistingWorkflows(session.user.id, defaultWorkspace.id)
return NextResponse.json({ workspaces: [defaultWorkspace] })
}
// If user has workspaces but might have orphaned workflows, migrate them
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
// Format the response with permission information
const workspacesWithPermissions = userWorkspaces.map(
({ workspace: workspaceDetails, permissionType }) => ({
...workspaceDetails,
@@ -78,24 +74,19 @@ export async function POST(req: Request) {
}
}
// Helper function to create a default workspace
async function createDefaultWorkspace(userId: string, userName?: string | null) {
// Extract first name only by splitting on spaces and taking the first part
const firstName = userName?.split(' ')[0] || null
const workspaceName = firstName ? `${firstName}'s Workspace` : 'My Workspace'
return createWorkspace(userId, workspaceName)
}
// Helper function to create a workspace
async function createWorkspace(userId: string, name: string) {
const workspaceId = crypto.randomUUID()
const workflowId = crypto.randomUUID()
const now = new Date()
// Create the workspace and initial workflow in a transaction
try {
await db.transaction(async (tx) => {
// Create the workspace
await tx.insert(workspace).values({
id: workspaceId,
name,
@@ -135,8 +126,6 @@ async function createWorkspace(userId: string, name: string) {
variables: {},
})
// No blocks are inserted - empty canvas
logger.info(
`Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
)
@@ -153,7 +142,16 @@ async function createWorkspace(userId: string, name: string) {
throw error
}
// Return the workspace data directly instead of querying again
try {
PlatformEvents.workspaceCreated({
workspaceId,
userId,
name,
})
} catch {
// Telemetry should not fail the operation
}
return {
id: workspaceId,
name,
@@ -166,9 +164,7 @@ async function createWorkspace(userId: string, name: string) {
}
}
// Helper function to migrate existing workflows to a workspace
async function migrateExistingWorkflows(userId: string, workspaceId: string) {
// Find all workflows that have no workspace ID
const orphanedWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
@@ -182,7 +178,6 @@ async function migrateExistingWorkflows(userId: string, workspaceId: string) {
`Migrating ${orphanedWorkflows.length} workflows to workspace ${workspaceId} for user ${userId}`
)
// Bulk update all orphaned workflows at once
await db
.update(workflow)
.set({
@@ -192,16 +187,13 @@ async function migrateExistingWorkflows(userId: string, workspaceId: string) {
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
}
// Helper function to ensure all workflows have a workspace
async function ensureWorkflowsHaveWorkspace(userId: string, defaultWorkspaceId: string) {
// First check if there are any orphaned workflows
const orphanedWorkflows = await db
.select()
.from(workflow)
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
if (orphanedWorkflows.length > 0) {
// Directly update any workflows that don't have a workspace ID in a single query
await db
.update(workflow)
.set({

View File

@@ -175,7 +175,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollButton(distanceFromBottom > 100)
// Track if user is manually scrolling during streaming
if (isStreamingResponse && !isUserScrollingRef.current) {
setUserHasScrolled(true)
}
@@ -191,13 +190,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll])
// Reset user scroll tracking when streaming starts
useEffect(() => {
if (isStreamingResponse) {
// Reset userHasScrolled when streaming starts
setUserHasScrolled(false)
// Give a small delay to distinguish between programmatic scroll and user scroll
isUserScrollingRef.current = true
setTimeout(() => {
isUserScrollingRef.current = false
@@ -215,7 +211,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
})
if (!response.ok) {
// Check if auth is required
if (response.status === 401) {
const errorData = await response.json()
@@ -236,7 +231,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
throw new Error(`Failed to load chat configuration: ${response.status}`)
}
// Reset auth required state when authentication is successful
setAuthRequired(null)
const data = await response.json()
@@ -260,7 +254,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}
// Fetch chat config on mount and generate new conversation ID
useEffect(() => {
fetchChatConfig()
setConversationId(uuidv4())
@@ -285,7 +278,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}, 800)
}
// Handle sending a message
const handleSendMessage = async (
messageParam?: string,
isVoiceInput = false,
@@ -308,7 +300,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
filesCount: files?.length,
})
// Reset userHasScrolled when sending a new message
setUserHasScrolled(false)
const userMessage: ChatMessage = {
@@ -325,24 +316,20 @@ export default function ChatClient({ identifier }: { identifier: string }) {
})),
}
// Add the user's message to the chat
setMessages((prev) => [...prev, userMessage])
setInputValue('')
setIsLoading(true)
// Scroll to show only the user's message and loading indicator
setTimeout(() => {
scrollToMessage(userMessage.id, true)
}, 100)
// Create abort controller for request cancellation
const abortController = new AbortController()
const timeoutId = setTimeout(() => {
abortController.abort()
}, CHAT_REQUEST_TIMEOUT_MS)
try {
// Send structured payload to maintain chat context
const payload: any = {
input:
typeof userMessage.content === 'string'
@@ -351,7 +338,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
conversationId,
}
// Add files if present (convert to base64 for JSON transmission)
if (files && files.length > 0) {
payload.files = await Promise.all(
files.map(async (file) => ({
@@ -379,7 +365,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
signal: abortController.signal,
})
// Clear timeout since request succeeded
clearTimeout(timeoutId)
if (!response.ok) {
@@ -392,7 +377,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
throw new Error('Response body is missing')
}
// Use the streaming hook with audio support
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
const audioHandler = shouldPlayAudio
? createAudioStreamHandler(
@@ -421,7 +405,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
)
} catch (error: any) {
// Clear timeout in case of error
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
@@ -442,7 +425,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}
// Stop audio when component unmounts or when streaming is stopped
useEffect(() => {
return () => {
stopAudio()
@@ -452,28 +434,23 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}, [stopAudio])
// Voice interruption - stop audio when user starts speaking
const handleVoiceInterruption = useCallback(() => {
stopAudio()
// Stop any ongoing streaming response
if (isStreamingResponse) {
stopStreaming(setMessages)
}
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
// Handle voice mode activation
const handleVoiceStart = useCallback(() => {
setIsVoiceFirstMode(true)
}, [])
// Handle exiting voice mode
const handleExitVoiceMode = useCallback(() => {
setIsVoiceFirstMode(false)
stopAudio() // Stop any playing audio when exiting
stopAudio()
}, [stopAudio])
// Handle voice transcript from voice-first interface
const handleVoiceTranscript = useCallback(
(transcript: string) => {
logger.info('Received voice transcript:', transcript)
@@ -482,14 +459,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
[handleSendMessage]
)
// If error, show error message using the extracted component
if (error) {
return <ChatErrorState error={error} starCount={starCount} />
}
// If authentication is required, use the extracted components
if (authRequired) {
// Get title and description from the URL params or use defaults
const title = new URLSearchParams(window.location.search).get('title') || 'chat'
const primaryColor =
new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
@@ -526,12 +500,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
}
}
// Loading state while fetching config using the extracted component
if (!chatConfig) {
return <ChatLoadingState />
}
// Voice-first mode interface
if (isVoiceFirstMode) {
return (
<VoiceInterface
@@ -551,7 +523,6 @@ export default function ChatClient({ identifier }: { identifier: string }) {
)
}
// Standard text-based chat interface
return (
<div className='fixed inset-0 z-[100] flex flex-col bg-white text-foreground'>
{/* Header component */}

View File

@@ -0,0 +1,269 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Mail } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { GmailIcon, OutlookIcon } from '@/components/icons'
import { client, useSession } from '@/lib/auth/auth-client'
import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
interface InvitationInfo {
credentialSetName: string
organizationName: string
providerId: string | null
email: string | null
}
type AcceptedState = 'connecting' | 'already-connected'
export default function CredentialAccountInvitePage() {
const params = useParams()
const router = useRouter()
const token = params.token as string
const { data: session, isPending: sessionLoading } = useSession()
const [invitation, setInvitation] = useState<InvitationInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [accepting, setAccepting] = useState(false)
const [acceptedState, setAcceptedState] = useState<AcceptedState | null>(null)
useEffect(() => {
async function fetchInvitation() {
try {
const res = await fetch(`/api/credential-sets/invite/${token}`)
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Failed to load invitation')
return
}
const data = await res.json()
setInvitation(data.invitation)
} catch {
setError('Failed to load invitation')
} finally {
setLoading(false)
}
}
fetchInvitation()
}, [token])
const handleAccept = useCallback(async () => {
if (!session?.user?.id) {
// Include invite_flow=true so the login page preserves callbackUrl when linking to signup
const callbackUrl = encodeURIComponent(`/credential-account/${token}`)
router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`)
return
}
setAccepting(true)
try {
const res = await fetch(`/api/credential-sets/invite/${token}`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Failed to accept invitation')
return
}
const data = await res.json()
const credentialSetProviderId = data.providerId || invitation?.providerId
// Check if user already has this provider connected
let isAlreadyConnected = false
if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
try {
const connectionsRes = await fetch('/api/auth/oauth/connections')
if (connectionsRes.ok) {
const connectionsData = await connectionsRes.json()
const connections = connectionsData.connections || []
isAlreadyConnected = connections.some(
(conn: { provider: string; accounts?: { id: string }[] }) =>
conn.provider === credentialSetProviderId &&
conn.accounts &&
conn.accounts.length > 0
)
}
} catch {
// If we can't check connections, proceed with OAuth flow
}
}
if (isAlreadyConnected) {
// Already connected - redirect to workspace
setAcceptedState('already-connected')
setTimeout(() => {
router.push('/workspace')
}, 2000)
} else if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) {
// Not connected - start OAuth flow
setAcceptedState('connecting')
// Small delay to show success message before redirect
setTimeout(async () => {
try {
await client.oauth2.link({
providerId: credentialSetProviderId,
callbackURL: `${window.location.origin}/workspace`,
})
} catch (oauthError) {
// OAuth redirect will happen, this catch is for any pre-redirect errors
console.error('OAuth initiation error:', oauthError)
// If OAuth fails, redirect to workspace where they can connect manually
router.push('/workspace')
}
}, 1500)
} else {
// No provider specified - just redirect to workspace
router.push('/workspace')
}
} catch {
setError('Failed to accept invitation')
} finally {
setAccepting(false)
}
}, [session?.user?.id, token, router, invitation?.providerId])
const providerName = invitation?.providerId
? getProviderDisplayName(invitation.providerId)
: 'email'
const ProviderIcon =
invitation?.providerId === 'outlook'
? OutlookIcon
: invitation?.providerId === 'google-email'
? GmailIcon
: Mail
const providerWithIcon = (
<span className='inline-flex items-baseline gap-1'>
<ProviderIcon className='inline-block h-4 w-4 translate-y-[2px]' />
{providerName}
</span>
)
const getCallbackUrl = () => `/credential-account/${token}`
if (loading || sessionLoading) {
return (
<InviteLayout>
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
</InviteLayout>
)
}
if (error) {
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Unable to load invitation'
description={error}
icon='error'
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
},
]}
/>
</InviteLayout>
)
}
if (acceptedState === 'already-connected') {
return (
<InviteLayout>
<InviteStatusCard
type='success'
title="You're all set!"
description={`You've joined ${invitation?.credentialSetName}. Your ${providerName} account is already connected. Redirecting to workspace...`}
icon='success'
/>
</InviteLayout>
)
}
if (acceptedState === 'connecting') {
return (
<InviteLayout>
<InviteStatusCard
type='loading'
title={`Connecting to ${providerName}...`}
description={`You've joined ${invitation?.credentialSetName}. You'll be redirected to connect your ${providerName} account.`}
/>
</InviteLayout>
)
}
// Not logged in
if (!session?.user) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
return (
<InviteLayout>
<InviteStatusCard
type='login'
title='Join Email Polling Group'
description={`You've been invited to join ${invitation?.credentialSetName} by ${invitation?.organizationName}. Sign in or create an account to accept this invitation.`}
icon='mail'
actions={[
{
label: 'Sign in',
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'Create an account',
onClick: () =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
variant: 'outline' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Logged in - show invitation
return (
<InviteLayout>
<InviteStatusCard
type='invitation'
title='Join Email Polling Group'
description={
<>
You've been invited to join {invitation?.credentialSetName} by{' '}
{invitation?.organizationName}.
{invitation?.providerId && (
<> You'll be asked to connect your {providerWithIcon} account after accepting.</>
)}
</>
}
icon='mail'
actions={[
{
label: `Accept & Connect ${providerName}`,
onClick: handleAccept,
disabled: accepting,
loading: accepting,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}

View File

@@ -36,7 +36,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'

View File

@@ -4,7 +4,7 @@ import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

@@ -1,7 +1,7 @@
export { Dashboard } from './dashboard'
export { LogDetails } from './log-details'
export { ExecutionSnapshot } from './log-details/components/execution-snapshot'
export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans'
export { LogRowContextMenu } from './log-row-context-menu'
export { LogsList } from './logs-list'

View File

@@ -0,0 +1,224 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
BlockDetailsSidebar,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface TraceSpan {
blockId?: string
input?: unknown
output?: unknown
status?: string
duration?: number
children?: TraceSpan[]
}
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
}
interface MigratedWorkflowState extends WorkflowState {
_migrated: true
_note?: string
}
function isMigratedWorkflowState(state: WorkflowState): state is MigratedWorkflowState {
return (state as MigratedWorkflowState)._migrated === true
}
interface ExecutionSnapshotProps {
executionId: string
traceSpans?: TraceSpan[]
className?: string
height?: string | number
width?: string | number
isModal?: boolean
isOpen?: boolean
onClose?: () => void
}
export function ExecutionSnapshot({
executionId,
traceSpans,
className,
height = '100%',
width = '100%',
isModal = false,
isOpen = false,
onClose = () => {},
}: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (spans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of spans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: span.status || 'unknown',
durationMs: span.duration || 0,
}
}
}
return blockExecutionMap
}, [traceSpans])
useEffect(() => {
setPinnedBlockId(null)
}, [executionId])
const workflowState = data?.workflowState as WorkflowState | undefined
const renderContent = () => {
if (isLoading) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading execution snapshot...</span>
</div>
</div>
)
}
if (error) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load execution snapshot: {error.message}</span>
</div>
</div>
)
}
if (!data || !workflowState) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading execution snapshot...</span>
</div>
</div>
)
}
if (isMigratedWorkflowState(workflowState)) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time
is not available.
</div>
<div className='text-[12px] text-[var(--text-tertiary)]'>Note: {workflowState._note}</div>
</div>
)
}
return (
<div
style={{ height, width }}
className={cn(
'flex overflow-hidden rounded-[4px] border border-[var(--border)]',
className
)}
>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId((prev) => (prev === blockId ? null : blockId))
}}
cursorStyle='pointer'
executedBlocks={blockExecutions}
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<BlockDetailsSidebar
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
isExecutionMode
/>
)}
</div>
)
}
if (isModal) {
return (
<Modal
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setPinnedBlockId(null)
onClose()
}
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>{renderContent()}</ModalBody>
</ModalContent>
</Modal>
)
}
return renderContent()
}

View File

@@ -0,0 +1 @@
export { ExecutionSnapshot } from './execution-snapshot'

View File

@@ -1,657 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
AlertCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
DollarSign,
Hash,
Loader2,
Maximize2,
X,
Zap,
} from 'lucide-react'
import { Badge, Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('FrozenCanvas')
function ExpandableDataSection({ title, data }: { title: string; data: any }) {
const [isExpanded, setIsExpanded] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const jsonString = JSON.stringify(data, null, 2)
const isLargeData = jsonString.length > 500 || jsonString.split('\n').length > 10
return (
<>
<div>
<div className='mb-[6px] flex items-center justify-between'>
<h4 className='font-medium text-[13px] text-[var(--text-primary)]'>{title}</h4>
<div className='flex items-center gap-[4px]'>
{isLargeData && (
<button
onClick={() => setIsModalOpen(true)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
title='Expand in modal'
type='button'
>
<Maximize2 className='h-[14px] w-[14px]' />
</button>
)}
<button
onClick={() => setIsExpanded(!isExpanded)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
{isExpanded ? (
<ChevronUp className='h-[14px] w-[14px]' />
) : (
<ChevronDown className='h-[14px] w-[14px]' />
)}
</button>
</div>
</div>
<div
className={cn(
'overflow-y-auto rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] font-mono text-[12px] transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-32'
)}
>
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
</div>
{isModalOpen && (
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
<div className='mx-[16px] flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<div className='flex items-center justify-between border-[var(--border)] border-b p-[16px]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>{title}</h3>
<button
onClick={() => setIsModalOpen(false)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex-1 overflow-auto p-[16px]'>
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)]'>
{jsonString}
</pre>
</div>
</div>
</div>
)}
</>
)
}
function formatExecutionData(executionData: any) {
const {
inputData,
outputData,
cost,
tokens,
durationMs,
status,
blockName,
blockType,
errorMessage,
errorStackTrace,
} = executionData
return {
blockName: blockName || 'Unknown Block',
blockType: blockType || 'unknown',
status,
duration: durationMs ? `${durationMs}ms` : 'N/A',
input: redactApiKeys(inputData || {}),
output: redactApiKeys(outputData || {}),
errorMessage,
errorStackTrace,
cost: cost
? {
input: cost.input || 0,
output: cost.output || 0,
total: cost.total || 0,
}
: null,
tokens: tokens
? {
input: tokens.input || tokens.prompt || 0,
output: tokens.output || tokens.completion || 0,
total: tokens.total || 0,
}
: null,
}
}
function getCurrentIterationData(blockExecutionData: any) {
if (blockExecutionData.iterations && Array.isArray(blockExecutionData.iterations)) {
const currentIndex = blockExecutionData.currentIteration ?? 0
return {
executionData: blockExecutionData.iterations[currentIndex],
currentIteration: currentIndex,
totalIterations: blockExecutionData.totalIterations ?? blockExecutionData.iterations.length,
hasMultipleIterations: blockExecutionData.iterations.length > 1,
}
}
return {
executionData: blockExecutionData,
currentIteration: 0,
totalIterations: 1,
hasMultipleIterations: false,
}
}
function PinnedLogs({
executionData,
blockId,
workflowState,
onClose,
}: {
executionData: any | null
blockId: string
workflowState: any
onClose: () => void
}) {
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
useEffect(() => {
setCurrentIterationIndex(0)
}, [executionData])
if (!executionData) {
const blockInfo = workflowState?.blocks?.[blockId]
const formatted = {
blockName: blockInfo?.name || 'Unknown Block',
blockType: blockInfo?.type || 'unknown',
status: 'not_executed',
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex items-center gap-[8px]'>
<Badge variant='gray-secondary'>{formatted.blockType}</Badge>
<Badge variant='outline'>not executed</Badge>
</div>
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)]'>
This block was not executed because the workflow failed before reaching it.
</div>
</div>
</CardContent>
</Card>
)
}
const iterationInfo = getCurrentIterationData({
...executionData,
currentIteration: currentIterationIndex,
})
const formatted = formatExecutionData(iterationInfo.executionData)
const totalIterations = executionData.iterations?.length || 1
const goToPreviousIteration = () => {
if (currentIterationIndex > 0) {
setCurrentIterationIndex(currentIterationIndex - 1)
}
}
const goToNextIteration = () => {
if (currentIterationIndex < totalIterations - 1) {
setCurrentIterationIndex(currentIterationIndex + 1)
}
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
<Zap className='h-[16px] w-[16px]' />
{formatted.blockName}
</CardTitle>
<button
onClick={onClose}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
type='button'
>
<X className='h-[16px] w-[16px]' />
</button>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<Badge variant={formatted.status === 'success' ? 'default' : 'red'}>
{formatted.blockType}
</Badge>
<Badge variant='outline'>{formatted.status}</Badge>
</div>
{iterationInfo.hasMultipleIterations && (
<div className='flex items-center gap-[4px]'>
<button
onClick={goToPreviousIteration}
disabled={currentIterationIndex === 0}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
type='button'
>
<ChevronLeft className='h-[14px] w-[14px]' />
</button>
<span className='px-[8px] text-[12px] text-[var(--text-tertiary)]'>
{iterationInfo.totalIterations !== undefined
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
: `${currentIterationIndex + 1}`}
</span>
<button
onClick={goToNextIteration}
disabled={currentIterationIndex === totalIterations - 1}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
type='button'
>
<ChevronRight className='h-[14px] w-[14px]' />
</button>
</div>
)}
</div>
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex items-center gap-[8px]'>
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>{formatted.duration}</span>
</div>
{formatted.cost && formatted.cost.total > 0 && (
<div className='flex items-center gap-[8px]'>
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>
${formatted.cost.total.toFixed(5)}
</span>
</div>
)}
{formatted.tokens && formatted.tokens.total > 0 && (
<div className='flex items-center gap-[8px]'>
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
<span className='text-[13px] text-[var(--text-primary)]'>
{formatted.tokens.total} tokens
</span>
</div>
)}
</div>
<ExpandableDataSection title='Input' data={formatted.input} />
<ExpandableDataSection title='Output' data={formatted.output} />
{formatted.cost && formatted.cost.total > 0 && (
<div>
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
Cost Breakdown
</h4>
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Input:</span>
<span>${formatted.cost.input.toFixed(5)}</span>
</div>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Output:</span>
<span>${formatted.cost.output.toFixed(5)}</span>
</div>
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
<span>Total:</span>
<span>${formatted.cost.total.toFixed(5)}</span>
</div>
</div>
</div>
)}
{formatted.tokens && formatted.tokens.total > 0 && (
<div>
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
Token Usage
</h4>
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Input:</span>
<span>{formatted.tokens.input}</span>
</div>
<div className='flex justify-between text-[var(--text-primary)]'>
<span>Output:</span>
<span>{formatted.tokens.output}</span>
</div>
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
<span>Total:</span>
<span>{formatted.tokens.total}</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)
}
interface FrozenCanvasData {
executionId: string
workflowId: string
workflowState: WorkflowState
executionMetadata: {
trigger: string
startedAt: string
endedAt?: string
totalDurationMs?: number
cost: {
total: number | null
input: number | null
output: number | null
}
totalTokens: number | null
}
}
interface FrozenCanvasProps {
executionId: string
traceSpans?: any[]
className?: string
height?: string | number
width?: string | number
isModal?: boolean
isOpen?: boolean
onClose?: () => void
}
export function FrozenCanvas({
executionId,
traceSpans,
className,
height = '100%',
width = '100%',
isModal = false,
isOpen = false,
onClose,
}: FrozenCanvasProps) {
const [data, setData] = useState<FrozenCanvasData | null>(null)
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
// Process traceSpans to create blockExecutions map
useEffect(() => {
if (traceSpans && Array.isArray(traceSpans)) {
const blockExecutionMap: Record<string, any> = {}
logger.debug('Processing trace spans for frozen canvas:', { traceSpans })
// Recursively collect all spans with blockId from the trace spans tree
const collectBlockSpans = (spans: any[]): any[] => {
const blockSpans: any[] = []
for (const span of spans) {
// If this span has a blockId, it's a block execution
if (span.blockId) {
blockSpans.push(span)
}
// Recursively check children
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(traceSpans)
logger.debug('Collected all block spans:', allBlockSpans)
// Group spans by blockId
const traceSpansByBlockId = allBlockSpans.reduce((acc: any, span: any) => {
if (span.blockId) {
if (!acc[span.blockId]) {
acc[span.blockId] = []
}
acc[span.blockId].push(span)
}
return acc
}, {})
logger.debug('Grouped trace spans by blockId:', traceSpansByBlockId)
for (const [blockId, spans] of Object.entries(traceSpansByBlockId)) {
const spanArray = spans as any[]
const iterations = spanArray.map((span: any) => {
// Extract error information from span output if status is error
let errorMessage = null
let errorStackTrace = null
if (span.status === 'error' && span.output) {
// Error information can be in different formats in the output
if (typeof span.output === 'string') {
errorMessage = span.output
} else if (span.output.error) {
errorMessage = span.output.error
errorStackTrace = span.output.stackTrace || span.output.stack
} else if (span.output.message) {
errorMessage = span.output.message
errorStackTrace = span.output.stackTrace || span.output.stack
} else {
// Fallback: stringify the entire output for error cases
errorMessage = JSON.stringify(span.output)
}
}
return {
id: span.id,
blockId: span.blockId,
blockName: span.name,
blockType: span.type,
status: span.status,
startedAt: span.startTime,
endedAt: span.endTime,
durationMs: span.duration,
inputData: span.input,
outputData: span.output,
errorMessage,
errorStackTrace,
cost: span.cost || {
input: null,
output: null,
total: null,
},
tokens: span.tokens || {
input: null,
output: null,
total: null,
},
modelUsed: span.model || null,
metadata: {},
}
})
blockExecutionMap[blockId] = {
iterations,
currentIteration: 0,
totalIterations: iterations.length,
}
}
setBlockExecutions(blockExecutionMap)
}
}, [traceSpans])
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/logs/execution/${executionId}`)
if (!response.ok) {
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
}
const result = await response.json()
setData(result)
logger.debug(`Loaded frozen canvas data for execution: ${executionId}`)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
logger.error('Failed to fetch frozen canvas data:', err)
setError(errorMessage)
} finally {
setLoading(false)
}
}
fetchData()
}, [executionId])
const renderContent = () => {
if (loading) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
<Loader2 className='h-[16px] w-[16px] animate-spin' />
<span className='text-[13px]'>Loading frozen canvas...</span>
</div>
</div>
)
}
if (error) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
<AlertCircle className='h-[16px] w-[16px]' />
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
</div>
</div>
)
}
if (!data) {
return (
<div
className={cn('flex items-center justify-center', className)}
style={{ height, width }}
>
<div className='text-[13px] text-[var(--text-secondary)]'>No data available</div>
</div>
)
}
const isMigratedLog = (data.workflowState as any)?._migrated === true
if (isMigratedLog) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
style={{ height, width }}
>
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
<AlertCircle className='h-[20px] w-[20px]' />
<span className='font-medium text-[15px]'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
This log was migrated from the old logging system. The workflow state at execution time
is not available.
</div>
<div className='text-[12px] text-[var(--text-tertiary)]'>
Note: {(data.workflowState as any)?._note}
</div>
</div>
)
}
return (
<>
<div
style={{ height, width }}
className={cn('frozen-canvas-mode h-full w-full', className)}
>
<WorkflowPreview
workflowState={data.workflowState}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId(blockId)
}}
/>
</div>
{pinnedBlockId && (
<PinnedLogs
executionData={blockExecutions[pinnedBlockId] || null}
blockId={pinnedBlockId}
workflowState={data.workflowState}
onClose={() => setPinnedBlockId(null)}
/>
)}
</>
)
}
if (isModal) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='xl' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='min-h-0 flex-1'>
<div className='flex h-full flex-col'>
<div className='min-h-0 flex-1 overflow-hidden rounded-[4px] border border-[var(--border)]'>
{renderContent()}
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}
return renderContent()
}

View File

@@ -1 +0,0 @@
export { FrozenCanvas } from './frozen-canvas'

View File

@@ -5,7 +5,11 @@ import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
import {
ExecutionSnapshot,
FileCards,
TraceSpans,
} from '@/app/workspace/[workspaceId]/logs/components'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import {
formatDate,
@@ -49,7 +53,7 @@ export const LogDetails = memo(function LogDetails({
hasNext = false,
hasPrev = false,
}: LogDetailsProps) {
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
@@ -266,7 +270,7 @@ export const LogDetails = memo(function LogDetails({
Workflow State
</span>
<button
onClick={() => setIsFrozenCanvasOpen(true)}
onClick={() => setIsExecutionSnapshotOpen(true)}
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
@@ -363,12 +367,12 @@ export const LogDetails = memo(function LogDetails({
{/* Frozen Canvas Modal */}
{log?.executionId && (
<FrozenCanvas
<ExecutionSnapshot
executionId={log.executionId}
traceSpans={log.executionData?.traceSpans}
isModal
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
isOpen={isExecutionSnapshotOpen}
onClose={() => setIsExecutionSnapshotOpen(false)}
/>
)}
</div>

View File

@@ -18,6 +18,7 @@ interface LogRowContextMenuProps {
log: WorkflowLog | null
onCopyExecutionId: () => void
onOpenWorkflow: () => void
onOpenPreview: () => void
onToggleWorkflowFilter: () => void
onClearAllFilters: () => void
isFilteredByThisWorkflow: boolean
@@ -36,6 +37,7 @@ export function LogRowContextMenu({
log,
onCopyExecutionId,
onOpenWorkflow,
onOpenPreview,
onToggleWorkflowFilter,
onClearAllFilters,
isFilteredByThisWorkflow,
@@ -78,6 +80,15 @@ export function LogRowContextMenu({
>
Open Workflow
</PopoverItem>
<PopoverItem
disabled={!hasExecutionId}
onClick={() => {
onOpenPreview()
onClose()
}}
>
Open Preview
</PopoverItem>
{/* Filter actions */}
<PopoverDivider />

View File

@@ -18,6 +18,7 @@ import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import {
Dashboard,
ExecutionSnapshot,
LogDetails,
LogRowContextMenu,
LogsList,
@@ -59,8 +60,7 @@ export default function Logs() {
setWorkspaceId(workspaceId)
}, [workspaceId, setWorkspaceId])
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
const [selectedLogId, setSelectedLogId] = useState<string | null>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
@@ -90,6 +90,12 @@ export default function Logs() {
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
const logFilters = useMemo(
() => ({
timeRange,
@@ -129,19 +135,23 @@ export default function Logs() {
refetchInterval: isLive ? 5000 : false,
})
const logDetailQuery = useLogDetail(selectedLog?.id)
const mergedSelectedLog = useMemo(() => {
if (!selectedLog) return null
if (!logDetailQuery.data) return selectedLog
return { ...selectedLog, ...logDetailQuery.data }
}, [selectedLog, logDetailQuery.data])
const logs = useMemo(() => {
if (!logsQuery.data?.pages) return []
return logsQuery.data.pages.flatMap((page) => page.logs)
}, [logsQuery.data?.pages])
const selectedLogIndex = useMemo(
() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1),
[logs, selectedLogId]
)
const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null
const selectedLog = useMemo(() => {
if (!selectedLogFromList) return null
if (!activeLogQuery.data || isPreviewOpen) return selectedLogFromList
return { ...selectedLogFromList, ...activeLogQuery.data }
}, [selectedLogFromList, activeLogQuery.data, isPreviewOpen])
useFolders(workspaceId)
useEffect(() => {
@@ -150,89 +160,40 @@ export default function Logs() {
}
}, [debouncedSearchQuery, setStoreSearchQuery])
const prevSelectedLogRef = useRef<WorkflowLog | null>(null)
useEffect(() => {
if (!selectedLog?.id || logs.length === 0) return
const updatedLog = logs.find((l) => l.id === selectedLog.id)
if (!updatedLog) return
const prevLog = prevSelectedLogRef.current
const hasStatusChange =
prevLog?.id === updatedLog.id &&
(updatedLog.duration !== prevLog.duration || updatedLog.status !== prevLog.status)
if (updatedLog !== selectedLog) {
setSelectedLog(updatedLog)
prevSelectedLogRef.current = updatedLog
}
const newIndex = logs.findIndex((l) => l.id === selectedLog.id)
if (newIndex !== selectedLogIndex) {
setSelectedLogIndex(newIndex)
}
if (hasStatusChange) {
logDetailQuery.refetch()
}
}, [logs, selectedLog?.id, selectedLogIndex, logDetailQuery])
useEffect(() => {
if (!isLive || !selectedLog?.id) return
const interval = setInterval(() => {
logDetailQuery.refetch()
}, 5000)
if (!isLive || !selectedLogId) return
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
return () => clearInterval(interval)
}, [isLive, selectedLog?.id, logDetailQuery])
}, [isLive, selectedLogId, activeLogQuery])
const handleLogClick = useCallback(
(log: WorkflowLog) => {
if (selectedLog?.id === log.id && isSidebarOpen) {
if (selectedLogId === log.id && isSidebarOpen) {
setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
setSelectedLogId(null)
return
}
setSelectedLog(log)
prevSelectedLogRef.current = log
const index = logs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setSelectedLogId(log.id)
setIsSidebarOpen(true)
},
[selectedLog?.id, isSidebarOpen, logs]
[selectedLogId, isSidebarOpen]
)
const handleNavigateNext = useCallback(() => {
if (selectedLogIndex < logs.length - 1) {
const nextIndex = selectedLogIndex + 1
setSelectedLogIndex(nextIndex)
const nextLog = logs[nextIndex]
setSelectedLog(nextLog)
prevSelectedLogRef.current = nextLog
setSelectedLogId(logs[selectedLogIndex + 1].id)
}
}, [selectedLogIndex, logs])
const handleNavigatePrev = useCallback(() => {
if (selectedLogIndex > 0) {
const prevIndex = selectedLogIndex - 1
setSelectedLogIndex(prevIndex)
const prevLog = logs[prevIndex]
setSelectedLog(prevLog)
prevSelectedLogRef.current = prevLog
setSelectedLogId(logs[selectedLogIndex - 1].id)
}
}, [selectedLogIndex, logs])
const handleCloseSidebar = useCallback(() => {
setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
prevSelectedLogRef.current = null
setSelectedLogId(null)
}, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
@@ -271,6 +232,13 @@ export default function Logs() {
setSearchQuery('')
}, [resetFilters, setSearchQuery])
const handleOpenPreview = useCallback(() => {
if (contextMenuLog?.id) {
setPreviewLogId(contextMenuLog.id)
setIsPreviewOpen(true)
}
}, [contextMenuLog])
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const isFilteredByThisWorkflow = Boolean(
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
@@ -298,10 +266,10 @@ export default function Logs() {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
if (selectedLog?.id) {
logDetailQuery.refetch()
if (selectedLogId) {
activeLogQuery.refetch()
}
}, [logsQuery, logDetailQuery, selectedLog?.id])
}, [logsQuery, activeLogQuery, selectedLogId])
const handleToggleLive = useCallback(() => {
const newIsLive = !isLive
@@ -393,9 +361,7 @@ export default function Logs() {
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
setSelectedLogIndex(0)
setSelectedLog(logs[0])
prevSelectedLogRef.current = logs[0]
setSelectedLogId(logs[0].id)
return
}
@@ -409,7 +375,7 @@ export default function Logs() {
handleNavigateNext()
}
if (e.key === 'Enter' && selectedLog) {
if (e.key === 'Enter' && selectedLogId) {
e.preventDefault()
setIsSidebarOpen(!isSidebarOpen)
}
@@ -417,7 +383,7 @@ export default function Logs() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [logs, selectedLogIndex, isSidebarOpen, selectedLog, handleNavigateNext, handleNavigatePrev])
}, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev])
const isDashboardView = viewMode === 'dashboard'
@@ -509,7 +475,7 @@ export default function Logs() {
) : (
<LogsList
logs={logs}
selectedLogId={selectedLog?.id ?? null}
selectedLogId={selectedLogId}
onLogClick={handleLogClick}
onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef}
@@ -524,7 +490,7 @@ export default function Logs() {
{/* Log Details - rendered inside table container */}
<LogDetails
log={mergedSelectedLog}
log={selectedLog}
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
@@ -550,11 +516,25 @@ export default function Logs() {
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow}
onOpenPreview={handleOpenPreview}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
hasActiveFilters={filtersActive}
/>
{isPreviewOpen && activeLogQuery.data?.executionId && (
<ExecutionSnapshot
executionId={activeLogQuery.data.executionId}
traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal
isOpen={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false)
setPreviewLogId(null)
}}
/>
)}
</div>
)
}

View File

@@ -1,16 +1,13 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import { useStarTemplate } from '@/hooks/queries/templates'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
interface TemplateCardProps {
id: string
title: string

View File

@@ -473,6 +473,7 @@ export function Chat() {
/**
* Processes streaming response from workflow execution
* Reads the stream chunk by chunk and updates the message content in real-time
* When the final event arrives, extracts any additional selected outputs (model, tokens, toolCalls)
* @param stream - ReadableStream containing the workflow execution response
* @param responseMessageId - ID of the message to update with streamed content
*/
@@ -529,6 +530,35 @@ export function Chat() {
return
}
if (
selectedOutputs.length > 0 &&
'logs' in result &&
Array.isArray(result.logs) &&
activeWorkflowId
) {
const additionalOutputs: string[] = []
for (const outputId of selectedOutputs) {
const blockId = extractBlockIdFromOutputId(outputId)
const path = extractPathFromOutputId(outputId, blockId)
if (path === 'content') continue
const outputValue = extractOutputFromLogs(result.logs as BlockLog[], outputId)
if (outputValue !== undefined) {
const formattedValue =
typeof outputValue === 'string' ? outputValue : JSON.stringify(outputValue)
if (formattedValue) {
additionalOutputs.push(`**${path}:** ${formattedValue}`)
}
}
}
if (additionalOutputs.length > 0) {
appendMessageContent(responseMessageId, `\n\n${additionalOutputs.join('\n\n')}`)
}
}
finalizeMessageStream(responseMessageId)
} else if (contentChunk) {
accumulatedContent += contentChunk
@@ -552,7 +582,7 @@ export function Chat() {
focusInput(100)
}
},
[appendMessageContent, finalizeMessageStream, focusInput]
[appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId]
)
/**
@@ -564,7 +594,6 @@ export function Chat() {
if (!result || !activeWorkflowId) return
if (typeof result !== 'object') return
// Handle streaming response
if ('stream' in result && result.stream instanceof ReadableStream) {
const responseMessageId = crypto.randomUUID()
addMessage({
@@ -578,7 +607,6 @@ export function Chat() {
return
}
// Handle success with logs
if ('success' in result && result.success && 'logs' in result && Array.isArray(result.logs)) {
selectedOutputs
.map((outputId) => extractOutputFromLogs(result.logs as BlockLog[], outputId))
@@ -596,7 +624,6 @@ export function Chat() {
return
}
// Handle error response
if ('success' in result && !result.success) {
const errorMessage =
'error' in result && typeof result.error === 'string'
@@ -622,7 +649,6 @@ export function Chat() {
const sentMessage = chatMessage.trim()
// Update prompt history (only if new unique message)
if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) {
setPromptHistory((prev) => [...prev, sentMessage])
}
@@ -631,10 +657,8 @@ export function Chat() {
const conversationId = getConversationId(activeWorkflowId)
try {
// Process file attachments
const attachmentsWithData = await processFileAttachments(chatFiles)
// Add user message
const messageContent =
sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '')
addMessage({
@@ -644,7 +668,6 @@ export function Chat() {
attachments: attachmentsWithData,
})
// Prepare workflow input
const workflowInput: {
input: string
conversationId: string
@@ -667,13 +690,11 @@ export function Chat() {
}
}
// Clear input and files
setChatMessage('')
clearFiles()
clearErrors()
focusInput(10)
// Execute workflow
const result = await handleRunWorkflow(workflowInput)
handleWorkflowResponse(result)
} catch (error) {

View File

@@ -3,8 +3,8 @@
import { memo, useMemo } from 'react'
import { useViewport } from 'reactflow'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/lib/workspaces/colors'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
import { useSocket } from '@/app/workspace/providers/socket-provider'
interface CursorPoint {

View File

@@ -1,7 +1,8 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Maximize2 } from 'lucide-react'
import {
Button,
Label,
@@ -10,10 +11,15 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
BlockDetailsSidebar,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { Versions } from './components'
@@ -49,48 +55,26 @@ export function GeneralDeploy({
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
const versionCacheRef = useRef<Map<number, WorkflowState>>(new Map())
const [, forceUpdate] = useState({})
const selectedVersionInfo = versions.find((v) => v.version === selectedVersion)
const versionToPromoteInfo = versions.find((v) => v.version === versionToPromote)
const versionToLoadInfo = versions.find((v) => v.version === versionToLoad)
const cachedSelectedState =
selectedVersion !== null ? versionCacheRef.current.get(selectedVersion) : null
const { data: selectedVersionState } = useDeploymentVersionState(workflowId, selectedVersion)
const fetchSelectedVersionState = useCallback(
async (version: number) => {
if (!workflowId) return
if (versionCacheRef.current.has(version)) return
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`)
if (res.ok) {
const data = await res.json()
if (data.deployedState) {
versionCacheRef.current.set(version, data.deployedState)
forceUpdate({})
}
}
} catch (error) {
logger.error('Error fetching version state:', error)
}
},
[workflowId]
)
const revertMutation = useRevertToVersion()
useEffect(() => {
if (selectedVersion !== null) {
fetchSelectedVersionState(selectedVersion)
setPreviewMode('selected')
} else {
setPreviewMode('active')
}
}, [selectedVersion, fetchSelectedVersionState])
}, [selectedVersion])
const handleSelectVersion = useCallback((version: number | null) => {
setSelectedVersion(version)
@@ -109,20 +93,12 @@ export function GeneralDeploy({
const confirmLoadDeployment = async () => {
if (!workflowId || versionToLoad === null) return
// Close modal immediately for snappy UX
setShowLoadDialog(false)
const version = versionToLoad
setVersionToLoad(null)
try {
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}/revert`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to load deployment')
}
await revertMutation.mutateAsync({ workflowId, version })
onLoadDeploymentComplete()
} catch (error) {
logger.error('Failed to load deployment:', error)
@@ -132,7 +108,6 @@ export function GeneralDeploy({
const confirmPromoteToLive = async () => {
if (versionToPromote === null) return
// Close modal immediately for snappy UX
setShowPromoteDialog(false)
const version = versionToPromote
setVersionToPromote(null)
@@ -145,15 +120,14 @@ export function GeneralDeploy({
}
const workflowToShow = useMemo(() => {
if (previewMode === 'selected' && cachedSelectedState) {
return cachedSelectedState
if (previewMode === 'selected' && selectedVersionState) {
return selectedVersionState
}
return deployedState
}, [previewMode, cachedSelectedState, deployedState])
}, [previewMode, selectedVersionState, deployedState])
const showToggle = selectedVersion !== null && deployedState
// Only show skeleton on initial load when we have no deployed data
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
@@ -219,15 +193,31 @@ export function GeneralDeploy({
}}
>
{workflowToShow ? (
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
<>
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
size='sm'
onClick={() => setShowExpandedPreview(true)}
className='absolute top-[8px] right-[8px] z-10'
>
<Maximize2 className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Expand preview</Tooltip.Content>
</Tooltip.Root>
</>
) : (
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
Deploy your workflow to see a preview
@@ -304,6 +294,51 @@ export function GeneralDeploy({
</ModalFooter>
</ModalContent>
</Modal>
{workflowToShow && (
<Modal
open={showExpandedPreview}
onOpenChange={(open) => {
if (!open) {
setExpandedSelectedBlockId(null)
}
setShowExpandedPreview(open)
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>
{previewMode === 'selected' && selectedVersionInfo
? selectedVersionInfo.name || `v${selectedVersion}`
: 'Live Workflow'}
</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>
<div className='flex h-full w-full overflow-hidden'>
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(
expandedSelectedBlockId === blockId ? null : blockId
)
}}
cursorStyle='pointer'
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<BlockDetailsSidebar
block={workflowToShow.blocks[expandedSelectedBlockId]}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}
</div>
</ModalBody>
</ModalContent>
</Modal>
)}
</>
)
}

View File

@@ -18,7 +18,7 @@ import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import {
useCreateTemplate,
useDeleteTemplate,

View File

@@ -2,8 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ExternalLink } from 'lucide-react'
import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -15,7 +17,11 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -45,6 +51,19 @@ export function CredentialSelector({
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId || ''
const supportsCredentialSets = subBlock.supportsCredentialSets || false
const { data: organizationsData } = useOrganizations()
const { data: subscriptionData } = useSubscriptionData()
const activeOrganization = organizationsData?.activeOrganization
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
const canUseCredentialSets = supportsCredentialSets && hasTeamPlan && !!activeOrganization?.id
const { data: credentialSets = [] } = useCredentialSets(
activeOrganization?.id,
canUseCredentialSets
)
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const hasDependencies = dependsOn.length > 0
@@ -52,7 +71,12 @@ export function CredentialSelector({
const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied)
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
const selectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : ''
const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET.PREFIX)
const selectedId = isCredentialSetSelected ? '' : rawSelectedId
const selectedCredentialSetId = isCredentialSetSelected
? rawSelectedId.slice(CREDENTIAL_SET.PREFIX.length)
: ''
const effectiveProviderId = useMemo(
() => getProviderIdFromServiceId(serviceId) as OAuthProvider,
@@ -87,11 +111,20 @@ export function CredentialSelector({
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
)
const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet)
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, isForeign])
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
useEffect(() => {
if (!isEditing) {
@@ -148,6 +181,15 @@ export function CredentialSelector({
[isPreview, setStoreValue]
)
const handleCredentialSetSelect = useCallback(
(credentialSetId: string) => {
if (isPreview) return
setStoreValue(`${CREDENTIAL_SET.PREFIX}${credentialSetId}`)
setIsEditing(false)
},
[isPreview, setStoreValue]
)
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
@@ -176,7 +218,56 @@ export function CredentialSelector({
.join(' ')
}, [])
const comboboxOptions = useMemo(() => {
const { comboboxOptions, comboboxGroups } = useMemo(() => {
const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId)
// Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility
const matchesProvider = (csProviderId: string | null) => {
if (!csProviderId || !pollingProviderId) return false
if (csProviderId === pollingProviderId) return true
// Handle legacy 'gmail' mapping to 'google-email'
if (pollingProviderId === 'google-email' && csProviderId === 'gmail') return true
return false
}
const filteredCredentialSets = pollingProviderId
? credentialSets.filter((cs) => matchesProvider(cs.providerId))
: []
if (canUseCredentialSets && filteredCredentialSets.length > 0) {
const groups = []
groups.push({
section: 'Polling Groups',
items: filteredCredentialSets.map((cs) => ({
label: cs.name,
value: `${CREDENTIAL_SET.PREFIX}${cs.id}`,
})),
})
const credentialItems = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
}))
if (credentialItems.length > 0) {
groups.push({
section: 'Personal Credential',
items: credentialItems,
})
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
return { comboboxOptions: [], comboboxGroups: groups }
}
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
@@ -189,14 +280,32 @@ export function CredentialSelector({
})
}
return options
}, [credentials, provider, getProviderName])
return { comboboxOptions: options, comboboxGroups: undefined }
}, [
credentials,
provider,
effectiveProviderId,
getProviderName,
canUseCredentialSets,
credentialSets,
])
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!inputValue) return null
if (isCredentialSetSelected && selectedCredentialSet) {
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
<Users className='h-3 w-3' />
</div>
<span className='truncate'>{inputValue}</span>
</div>
)
}
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
@@ -205,7 +314,13 @@ export function CredentialSelector({
<span className='truncate'>{inputValue}</span>
</div>
)
}, [getProviderIcon, inputValue, selectedCredentialProvider])
}, [
getProviderIcon,
inputValue,
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
])
const handleComboboxChange = useCallback(
(value: string) => {
@@ -214,6 +329,16 @@ export function CredentialSelector({
return
}
if (value.startsWith(CREDENTIAL_SET.PREFIX)) {
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
if (matchedSet) {
setInputValue(matchedSet.name)
handleCredentialSetSelect(credentialSetId)
return
}
}
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
setInputValue(matchedCred.name)
@@ -224,15 +349,16 @@ export function CredentialSelector({
setIsEditing(true)
setInputValue(value)
},
[credentials, handleAddCredential, handleSelect]
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
)
return (
<div>
<Combobox
options={comboboxOptions}
groups={comboboxGroups}
value={inputValue}
selectedValue={selectedId}
selectedValue={rawSelectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={
@@ -240,10 +366,10 @@ export function CredentialSelector({
}
disabled={effectiveDisabled}
editable={true}
filterOptions={true}
filterOptions={!isForeign && !isForeignCredentialSet}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
/>
{needsUpdate && (

View File

@@ -332,7 +332,10 @@ export function LongInput({
/>
<div
ref={overlayRef}
className='pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm'
className={cn(
'pointer-events-none absolute inset-0 box-border overflow-auto whitespace-pre-wrap break-words border border-transparent bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm',
(isPreview || disabled) && 'opacity-50'
)}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',

View File

@@ -374,7 +374,8 @@ export function ShortInput({
ref={overlayRef}
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
showCopyButton ? 'pr-14' : 'pr-3'
showCopyButton ? 'pr-14' : 'pr-3',
(isPreview || disabled) && 'opacity-50'
)}
>
<div className='min-w-fit whitespace-pre'>{formattedText}</div>

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { Slider } from '@/components/emcn/components/slider/slider'
import { cn } from '@/lib/core/utils/cn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface SliderInputProps {
@@ -58,15 +59,17 @@ export function SliderInput({
const percentage = ((normalizedValue - min) / (max - min)) * 100
const isDisabled = isPreview || disabled
return (
<div className='relative pt-2 pb-[22px]'>
<div className={cn('relative pt-2 pb-[22px]', isDisabled && 'opacity-50')}>
<Slider
value={[normalizedValue]}
min={min}
max={max}
step={integer ? 1 : step}
onValueChange={handleValueChange}
disabled={isPreview || disabled}
disabled={isDisabled}
/>
<div
className='absolute top-6 text-muted-foreground text-sm'

View File

@@ -10,6 +10,7 @@ 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 { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -95,7 +96,7 @@ export function ToolCredentialSelector({
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
if (isForeign) return 'Saved by collaborator'
if (isForeign) return CREDENTIAL.FOREIGN_LABEL
return ''
}, [selectedCredential, isForeign])
@@ -210,7 +211,7 @@ export function ToolCredentialSelector({
placeholder={label}
disabled={disabled}
editable={true}
filterOptions={true}
filterOptions={!isForeign}
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''}

View File

@@ -949,7 +949,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</div>
)}
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
{!data.isPreview && (
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
)}
{shouldShowDefaultHandles && <Connections blockId={id} />}

View File

@@ -55,9 +55,11 @@ const WorkflowEdgeComponent = ({
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
const edgeRunStatus = lastRunEdges.get(id)
const previewExecutionStatus = (
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
)?.executionStatus
const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
// Memoize diff status calculation to avoid recomputing on every render
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
if (data?.isDeleted) return 'deleted'
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
@@ -84,21 +86,39 @@ const WorkflowEdgeComponent = ({
targetHandle,
])
// Memoize edge style to prevent object recreation
const edgeStyle = useMemo(() => {
let color = 'var(--workflow-edge)'
if (edgeDiffStatus === 'deleted') color = 'var(--text-error)'
else if (isErrorEdge) color = 'var(--text-error)'
else if (edgeDiffStatus === 'new') color = 'var(--brand-tertiary)'
else if (edgeRunStatus === 'success') color = 'var(--border-success)'
else if (edgeRunStatus === 'error') color = 'var(--text-error)'
let opacity = 1
if (edgeDiffStatus === 'deleted') {
color = 'var(--text-error)'
opacity = 0.7
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary)'
} else if (edgeRunStatus === 'success') {
color = 'var(--border-success)'
} else if (edgeRunStatus === 'error') {
color = 'var(--text-error)'
}
if (isSelected) {
opacity = 0.5
}
return {
...(style ?? {}),
strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2,
strokeWidth: edgeDiffStatus
? 3
: edgeRunStatus === 'success' || edgeRunStatus === 'error'
? 2.5
: isSelected
? 2.5
: 2,
stroke: color,
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
opacity: edgeDiffStatus === 'deleted' ? 0.7 : isSelected ? 0.5 : 1,
opacity,
}
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
@@ -137,7 +157,6 @@ const WorkflowEdgeComponent = ({
e.stopPropagation()
if (data?.onDelete) {
// Pass this specific edge's ID to the delete function
data.onDelete(id)
}
}}

View File

@@ -885,6 +885,7 @@ export function useWorkflowExecution() {
const activeBlocksSet = new Set<string>()
const streamedContent = new Map<string, string>()
const accumulatedBlockLogs: BlockLog[] = []
// Execute the workflow
try {
@@ -933,14 +934,30 @@ export function useWorkflowExecution() {
// Edges already tracked in onBlockStarted, no need to track again
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
// Accumulate block log for the execution result
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
})
// Add to console
addConsole({
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt: new Date(Date.now() - data.durationMs).toISOString(),
endedAt: new Date().toISOString(),
startedAt,
endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
@@ -967,6 +984,24 @@ export function useWorkflowExecution() {
// Track failed block execution in run path
setBlockRunStatus(data.blockId, 'error')
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
// Accumulate block error log for the execution result
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
})
// Add error to console
addConsole({
input: data.input || {},
@@ -974,8 +1009,8 @@ export function useWorkflowExecution() {
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt: new Date(Date.now() - data.durationMs).toISOString(),
endedAt: new Date().toISOString(),
startedAt,
endedAt,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId: executionId || uuidv4(),
@@ -1029,7 +1064,7 @@ export function useWorkflowExecution() {
startTime: data.startTime,
endTime: data.endTime,
},
logs: [],
logs: accumulatedBlockLogs,
}
},
@@ -1041,7 +1076,7 @@ export function useWorkflowExecution() {
metadata: {
duration: data.duration,
},
logs: [],
logs: accumulatedBlockLogs,
}
// Only add workflow-level error if no blocks have executed yet

View File

@@ -31,7 +31,6 @@ export async function executeWorkflowWithFullLogging(
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = useExecutionStore.getState()
const workflowEdges = useWorkflowStore.getState().edges
// Track active blocks for pulsing animation
const activeBlocksSet = new Set<string>()
const payload: any = {
@@ -59,7 +58,6 @@ export async function executeWorkflowWithFullLogging(
throw new Error('No response body')
}
// Parse SSE stream
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
@@ -89,11 +87,9 @@ export async function executeWorkflowWithFullLogging(
switch (event.type) {
case 'block:started': {
// Add block to active set for pulsing animation
activeBlocksSet.add(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
// Track edges that led to this block as soon as execution starts
const incomingEdges = workflowEdges.filter(
(edge) => edge.target === event.data.blockId
)
@@ -104,11 +100,9 @@ export async function executeWorkflowWithFullLogging(
}
case 'block:completed':
// Remove block from active set
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
// Track successful block execution in run path
setBlockRunStatus(event.data.blockId, 'success')
addConsole({
@@ -134,11 +128,9 @@ export async function executeWorkflowWithFullLogging(
break
case 'block:error':
// Remove block from active set
activeBlocksSet.delete(event.data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
// Track failed block execution in run path
setBlockRunStatus(event.data.blockId, 'error')
addConsole({
@@ -183,7 +175,6 @@ export async function executeWorkflowWithFullLogging(
}
} finally {
reader.releaseLock()
// Clear active blocks when execution ends
setActiveBlocks(new Set())
}

View File

@@ -1337,6 +1337,11 @@ const WorkflowContent = React.memo(() => {
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
targetParentId: null,
})
addBlock(
id,
type,
@@ -1349,7 +1354,7 @@ const WorkflowContent = React.memo(() => {
},
undefined,
undefined,
undefined
autoConnectEdge
)
return
@@ -1368,6 +1373,12 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
enableTriggerMode,
targetParentId: null,
})
addBlock(
id,
type,
@@ -1376,7 +1387,7 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
undefined,
undefined,
autoConnectEdge,
enableTriggerMode
)
}
@@ -1395,6 +1406,7 @@ const WorkflowContent = React.memo(() => {
addBlock,
effectivePermissions.canEdit,
checkTriggerConstraints,
tryCreateAutoConnectEdge,
])
/**

View File

@@ -0,0 +1,680 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { ChevronDown as ChevronDownIcon, X } from 'lucide-react'
import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { getBlock } from '@/blocks'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import { navigatePath } from '@/executor/variables/resolvers/reference'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Evaluate whether a subblock's condition is met based on current values.
*/
function evaluateCondition(
condition: SubBlockConfig['condition'],
subBlockValues: Record<string, { value: unknown } | unknown>
): boolean {
if (!condition) return true
const actualCondition = typeof condition === 'function' ? condition() : condition
const fieldValueObj = subBlockValues[actualCondition.field]
const fieldValue =
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
? (fieldValueObj as { value: unknown }).value
: fieldValueObj
const conditionValues = Array.isArray(actualCondition.value)
? actualCondition.value
: [actualCondition.value]
let isMatch = conditionValues.some((v) => v === fieldValue)
if (actualCondition.not) {
isMatch = !isMatch
}
if (actualCondition.and && isMatch) {
const andFieldValueObj = subBlockValues[actualCondition.and.field]
const andFieldValue =
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
? (andFieldValueObj as { value: unknown }).value
: andFieldValueObj
const andConditionValues = Array.isArray(actualCondition.and.value)
? actualCondition.and.value
: [actualCondition.and.value]
let andMatch = andConditionValues.some((v) => v === andFieldValue)
if (actualCondition.and.not) {
andMatch = !andMatch
}
isMatch = isMatch && andMatch
}
return isMatch
}
/**
* Format a value for display as JSON string
*/
function formatValueAsJson(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '—'
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
return String(value)
}
interface ResolvedConnection {
blockId: string
blockName: string
blockType: string
fields: Array<{ path: string; value: string; tag: string }>
}
/**
* Extract all variable references from nested subblock values
*/
function extractAllReferencesFromSubBlocks(subBlockValues: Record<string, unknown>): string[] {
const refs = new Set<string>()
const processValue = (value: unknown) => {
if (typeof value === 'string') {
const extracted = extractReferencePrefixes(value)
extracted.forEach((ref) => refs.add(ref.raw))
} else if (Array.isArray(value)) {
value.forEach(processValue)
} else if (value && typeof value === 'object') {
if ('value' in value) {
processValue((value as { value: unknown }).value)
} else {
Object.values(value).forEach(processValue)
}
}
}
Object.values(subBlockValues).forEach(processValue)
return Array.from(refs)
}
/**
* Format a value for inline display (single line, truncated)
*/
function formatInlineValue(value: unknown): string {
if (value === null || value === undefined) return 'null'
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
return String(value)
}
interface ExecutionDataSectionProps {
title: string
data: unknown
isError?: boolean
}
/**
* Collapsible section for execution data (input/output)
* Uses Code.Viewer for proper syntax highlighting matching the logs UI
*/
function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const jsonString = useMemo(() => {
if (!data) return ''
return formatValueAsJson(data)
}, [data])
const isEmpty = jsonString === '—' || jsonString === ''
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsExpanded(!isExpanded)
}
}}
role='button'
tabIndex={0}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${title.toLowerCase()}`}
>
<span
className={cn(
'font-medium text-[12px] transition-colors',
isError
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{title}
</span>
<ChevronDown
className={cn(
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
)}
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</div>
{isExpanded && (
<>
{isEmpty ? (
<div className='rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
<span className='text-[12px] text-[var(--text-tertiary)]'>No data</span>
</div>
) : (
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
/>
)}
</>
)}
</div>
)
}
/**
* Section showing resolved variable references - styled like the connections section in editor
*/
function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set())
useEffect(() => {
setExpandedBlocks(new Set(connections.map((c) => c.blockId)))
}, [connections])
if (connections.length === 0) return null
const toggleBlock = (blockId: string) => {
setExpandedBlocks((prev) => {
const next = new Set(prev)
if (next.has(blockId)) {
next.delete(blockId)
} else {
next.add(blockId)
}
return next
})
}
return (
<div className='flex flex-shrink-0 flex-col border-[var(--border)] border-t'>
{/* Header with Chevron */}
<div
className='flex flex-shrink-0 cursor-pointer items-center gap-[8px] px-[10px] pt-[5px] pb-[5px]'
onClick={() => setIsCollapsed(!isCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setIsCollapsed(!isCollapsed)
}
}}
role='button'
tabIndex={0}
aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'}
>
<ChevronDownIcon
className={cn('h-[14px] w-[14px] transition-transform', !isCollapsed && 'rotate-180')}
/>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>Connections</div>
</div>
{/* Content - styled like ConnectionBlocks */}
{!isCollapsed && (
<div className='space-y-[2px] px-[6px] pb-[8px]'>
{connections.map((connection) => {
const blockConfig = getBlock(connection.blockType)
const Icon = blockConfig?.icon
const bgColor = blockConfig?.bgColor || '#6B7280'
const isExpanded = expandedBlocks.has(connection.blockId)
const hasFields = connection.fields.length > 0
return (
<div key={connection.blockId} className='mb-[2px] last:mb-0'>
{/* Block header - styled like ConnectionItem */}
<div
className={cn(
'group flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
hasFields && 'cursor-pointer'
)}
onClick={() => hasFields && toggleBlock(connection.blockId)}
>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{Icon && (
<Icon
className={cn(
'text-white transition-transform duration-200',
hasFields && 'group-hover:scale-110',
'!h-[9px] !w-[9px]'
)}
/>
)}
</div>
<span
className={cn(
'truncate font-medium',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)}
>
{connection.blockName}
</span>
{hasFields && (
<ChevronDownIcon
className={cn(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
/>
)}
</div>
{/* Fields - styled like FieldItem but showing resolved values */}
{isExpanded && hasFields && (
<div className='relative mt-[2px] ml-[12px] space-y-[2px] pl-[10px]'>
<div className='pointer-events-none absolute top-[4px] bottom-[4px] left-0 w-px bg-[var(--border)]' />
{connection.fields.map((field) => (
<div
key={field.tag}
className='group flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]'
>
<span
className={cn(
'flex-shrink-0 font-medium',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)}
>
{field.path}
</span>
<span className='min-w-0 flex-1 truncate text-[var(--text-tertiary)]'>
{field.value}
</span>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
/**
* Icon component for rendering block icons
*/
function IconComponent({
icon: Icon,
className,
}: {
icon: BlockIcon | undefined
className?: string
}) {
if (!Icon) return null
return <Icon className={className} />
}
interface ExecutionData {
input?: unknown
output?: unknown
status?: string
durationMs?: number
}
interface BlockDetailsSidebarProps {
block: BlockState
executionData?: ExecutionData
/** All block execution data for resolving variable references */
allBlockExecutions?: Record<string, ExecutionData>
/** All workflow blocks for mapping block names to IDs */
workflowBlocks?: Record<string, BlockState>
/** When true, shows "Not Executed" badge if no executionData is provided */
isExecutionMode?: boolean
/** Optional close handler - if not provided, no close button is shown */
onClose?: () => void
}
/**
* Format duration for display
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Readonly sidebar panel showing block configuration using SubBlock components.
*/
function BlockDetailsSidebarContent({
block,
executionData,
allBlockExecutions,
workflowBlocks,
isExecutionMode = false,
onClose,
}: BlockDetailsSidebarProps) {
const blockConfig = getBlock(block.type) as BlockConfig | undefined
const subBlockValues = block.subBlocks || {}
const blockNameToId = useMemo(() => {
const map = new Map<string, string>()
if (workflowBlocks) {
for (const [blockId, blockData] of Object.entries(workflowBlocks)) {
if (blockData.name) {
map.set(normalizeName(blockData.name), blockId)
}
}
}
return map
}, [workflowBlocks])
const resolveReference = useMemo(() => {
return (reference: string): unknown => {
if (!allBlockExecutions || !workflowBlocks) return undefined
if (!reference.startsWith('<') || !reference.endsWith('>')) return undefined
const inner = reference.slice(1, -1) // Remove < and >
const parts = inner.split('.')
if (parts.length < 1) return undefined
const [blockName, ...pathParts] = parts
const normalizedBlockName = normalizeName(blockName)
const blockId = blockNameToId.get(normalizedBlockName)
if (!blockId) return undefined
const blockExecution = allBlockExecutions[blockId]
if (!blockExecution?.output) return undefined
if (pathParts.length === 0) {
return blockExecution.output
}
return navigatePath(blockExecution.output, pathParts)
}
}, [allBlockExecutions, workflowBlocks, blockNameToId])
// Group resolved variables by source block for display
const resolvedConnections = useMemo((): ResolvedConnection[] => {
if (!allBlockExecutions || !workflowBlocks) return []
const allRefs = extractAllReferencesFromSubBlocks(subBlockValues)
const seen = new Set<string>()
const blockMap = new Map<string, ResolvedConnection>()
for (const ref of allRefs) {
if (seen.has(ref)) continue
// Parse reference: <blockName.path.to.value>
const inner = ref.slice(1, -1)
const parts = inner.split('.')
if (parts.length < 1) continue
const [blockName, ...pathParts] = parts
const normalizedBlockName = normalizeName(blockName)
const blockId = blockNameToId.get(normalizedBlockName)
if (!blockId) continue
const sourceBlock = workflowBlocks[blockId]
if (!sourceBlock) continue
const resolvedValue = resolveReference(ref)
if (resolvedValue === undefined) continue
seen.add(ref)
// Get or create block entry
if (!blockMap.has(blockId)) {
blockMap.set(blockId, {
blockId,
blockName: sourceBlock.name || blockName,
blockType: sourceBlock.type,
fields: [],
})
}
const connection = blockMap.get(blockId)!
connection.fields.push({
path: pathParts.join('.') || 'output',
value: formatInlineValue(resolvedValue),
tag: ref,
})
}
return Array.from(blockMap.values())
}, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference])
if (!blockConfig) {
return (
<div className='flex h-full w-80 flex-col overflow-hidden rounded-r-[8px] border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='flex items-center gap-[8px] bg-[var(--surface-4)] px-[12px] py-[8px]'>
<div className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px] bg-[var(--surface-3)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || 'Unknown Block'}
</span>
</div>
<div className='p-[12px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>Block configuration not found.</p>
</div>
</div>
)
}
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
if (subBlock.mode === 'trigger') return false
if (subBlock.condition) {
return evaluateCondition(subBlock.condition, subBlockValues)
}
return true
})
const statusVariant =
executionData?.status === 'error'
? 'red'
: executionData?.status === 'success'
? 'green'
: 'gray'
return (
<div className='flex h-full w-80 flex-col overflow-hidden rounded-r-[8px] border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like editor */}
<div className='flex flex-shrink-0 items-center gap-[8px] bg-[var(--surface-4)] px-[12px] py-[8px]'>
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: blockConfig.bgColor }}
>
<IconComponent
icon={blockConfig.icon}
className='h-[12px] w-[12px] text-[var(--white)]'
/>
</div>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || blockConfig.name}
</span>
{block.enabled === false && (
<Badge variant='red' size='sm'>
Disabled
</Badge>
)}
{onClose && (
<Button variant='ghost' className='!p-[4px] flex-shrink-0' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
)}
</div>
{/* Scrollable content */}
<div className='flex-1 overflow-y-auto'>
{/* Not Executed Banner - shown when in execution mode but block wasn't executed */}
{isExecutionMode && !executionData && (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
<div className='flex items-center justify-between'>
<Badge variant='gray-secondary' size='sm' dot>
Not Executed
</Badge>
</div>
</div>
)}
{/* Execution Input/Output (if provided) */}
{executionData &&
(executionData.input !== undefined || executionData.output !== undefined) ? (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden border-[var(--border)] border-b px-[12px] py-[10px]'>
{/* Execution Status & Duration Header */}
{(executionData.status || executionData.durationMs !== undefined) && (
<div className='flex items-center justify-between'>
{executionData.status && (
<Badge variant={statusVariant} size='sm' dot>
<span className='capitalize'>{executionData.status}</span>
</Badge>
)}
{executionData.durationMs !== undefined && (
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(executionData.durationMs)}
</span>
)}
</div>
)}
{/* Divider between Status/Duration and Input/Output */}
{(executionData.status || executionData.durationMs !== undefined) &&
(executionData.input !== undefined || executionData.output !== undefined) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Input Section */}
{executionData.input !== undefined && (
<ExecutionDataSection title='Input' data={executionData.input} />
)}
{/* Divider between Input and Output */}
{executionData.input !== undefined && executionData.output !== undefined && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{/* Output Section */}
{executionData.output !== undefined && (
<ExecutionDataSection
title={executionData.status === 'error' ? 'Error' : 'Output'}
data={executionData.output}
isError={executionData.status === 'error'}
/>
)}
</div>
) : null}
{/* Subblock Values - Using SubBlock components in preview mode */}
<div className='readonly-preview px-[8px] py-[8px]'>
{/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
<style>{`
.readonly-preview,
.readonly-preview * {
cursor: default !important;
}
.readonly-preview [disabled],
.readonly-preview [data-disabled],
.readonly-preview input,
.readonly-preview textarea,
.readonly-preview [role="combobox"],
.readonly-preview [role="slider"],
.readonly-preview [role="switch"],
.readonly-preview [role="checkbox"] {
opacity: 1 !important;
pointer-events: none;
}
.readonly-preview .opacity-50 {
opacity: 1 !important;
}
`}</style>
{visibleSubBlocks.length > 0 ? (
<div className='flex flex-col'>
{visibleSubBlocks.map((subBlockConfig, index) => (
<div key={subBlockConfig.id} className='subblock-row'>
<SubBlock
blockId={block.id}
config={subBlockConfig}
isPreview={true}
subBlockValues={subBlockValues}
disabled={true}
/>
{index < visibleSubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>
))}
</div>
) : (
<div className='py-[16px] text-center'>
<p className='text-[13px] text-[var(--text-secondary)]'>
No configurable fields for this block.
</p>
</div>
)}
</div>
</div>
{/* Resolved Variables Section - Pinned at bottom, outside scrollable area */}
{resolvedConnections.length > 0 && (
<ResolvedConnectionsSection connections={resolvedConnections} />
)}
</div>
)
}
/**
* Block details sidebar wrapped in ReactFlowProvider for hook compatibility.
*/
export function BlockDetailsSidebar(props: BlockDetailsSidebarProps) {
return (
<ReactFlowProvider>
<BlockDetailsSidebarContent {...props} />
</ReactFlowProvider>
)
}

View File

@@ -3,7 +3,7 @@
import { memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlock } from '@/blocks/registry'
import { getBlock } from '@/blocks'
interface WorkflowPreviewBlockData {
type: string
@@ -29,10 +29,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
const IconComponent = blockConfig.icon
// Hide input handle for triggers, starters, or blocks in trigger mode
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
// Get visible subblocks from config (no fetching, just config structure)
const visibleSubBlocks = useMemo(() => {
if (!blockConfig.subBlocks) return []
@@ -48,7 +46,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const hasSubBlocks = visibleSubBlocks.length > 0
const showErrorRow = !isStarterOrTrigger
// Handle styles based on orientation
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'

View File

@@ -26,11 +26,9 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
// Handle IDs matching the actual subflow component
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
// Handle styles matching the workflow-block component
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =

View File

@@ -0,0 +1,2 @@
export { BlockDetailsSidebar } from './components/block-details-sidebar'
export { WorkflowPreview } from './preview'

View File

@@ -18,13 +18,16 @@ import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview-subflow'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import { getBlock } from '@/blocks'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewProps {
workflowState: WorkflowState
showSubBlocks?: boolean
@@ -40,6 +43,8 @@ interface WorkflowPreviewProps {
lightweight?: boolean
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string }>
}
/**
@@ -105,10 +110,9 @@ export function WorkflowPreview({
onNodeClick,
lightweight = false,
cursorStyle = 'grab',
executedBlocks,
}: WorkflowPreviewProps) {
// Use lightweight node types for better performance in template cards
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
// Check if the workflow state is valid
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
@@ -178,9 +182,7 @@ export function WorkflowPreview({
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
// Lightweight mode: create minimal node data for performance
if (lightweight) {
// Handle loops and parallels as subflow nodes
if (block.type === 'loop' || block.type === 'parallel') {
nodeArray.push({
id: blockId,
@@ -197,7 +199,6 @@ export function WorkflowPreview({
return
}
// Regular blocks
nodeArray.push({
id: blockId,
type: 'workflowBlock',
@@ -214,10 +215,9 @@ export function WorkflowPreview({
return
}
// Full mode: create detailed node data for interactive previews
if (block.type === 'loop') {
nodeArray.push({
id: block.id,
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
@@ -238,7 +238,7 @@ export function WorkflowPreview({
if (block.type === 'parallel') {
nodeArray.push({
id: block.id,
id: blockId,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
@@ -265,11 +265,31 @@ export function WorkflowPreview({
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
nodeArray.push({
id: blockId,
type: nodeType,
position: absolutePosition,
draggable: false,
className:
executionStatus && executionStatus !== 'not-executed'
? `execution-${executionStatus}`
: undefined,
data: {
type: block.type,
config: blockConfig,
@@ -278,43 +298,9 @@ export function WorkflowPreview({
canEdit: false,
isPreview: true,
subBlockValues: block.subBlocks ?? {},
executionStatus,
},
})
if (block.type === 'loop') {
const childBlocks = Object.entries(workflowState.blocks || {}).filter(
([_, childBlock]) => childBlock.data?.parentId === blockId
)
childBlocks.forEach(([childId, childBlock]) => {
const childConfig = getBlock(childBlock.type)
if (childConfig) {
const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({
id: childId,
type: childNodeType,
position: {
x: block.position.x + 50,
y: block.position.y + (childBlock.position?.y || 100),
},
data: {
type: childBlock.type,
config: childConfig,
name: childBlock.name,
blockState: childBlock,
showSubBlocks,
isChild: true,
parentId: blockId,
canEdit: false,
isPreview: true,
},
draggable: false,
})
}
})
}
})
return nodeArray
@@ -326,21 +312,42 @@ export function WorkflowPreview({
workflowState.blocks,
isValidWorkflowState,
lightweight,
executedBlocks,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
return (workflowState.edges || []).map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
}))
}, [edgesStructure, workflowState.edges, isValidWorkflowState])
return (workflowState.edges || []).map((edge) => {
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const sourceExecuted = executedBlocks[edge.source]
const targetExecuted = executedBlocks[edge.target]
if (sourceExecuted && targetExecuted) {
if (targetExecuted.status === 'error') {
executionStatus = 'error'
} else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: executionStatus ? { executionStatus } : undefined,
}
})
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
// Handle migrated logs that don't have complete workflow state
if (!isValidWorkflowState) {
return (
<div
@@ -363,13 +370,19 @@ export function WorkflowPreview({
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', className)}
>
{cursorStyle && (
<style>{`
.preview-mode .react-flow__pane {
cursor: ${cursorStyle} !important;
}
`}</style>
)}
<style>{`
${cursorStyle ? `.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }` : ''}
/* Execution status styling for nodes */
.preview-mode .react-flow__node.execution-success {
border-radius: 8px;
box-shadow: 0 0 0 4px var(--border-success);
}
.preview-mode .react-flow__node.execution-error {
border-radius: 8px;
box-shadow: 0 0 0 4px var(--text-error);
}
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { Crown, Eye, EyeOff } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -81,7 +81,9 @@ export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { data: keys = [], isLoading } = useBYOKKeys(workspaceId)
const { data, isLoading } = useBYOKKeys(workspaceId)
const keys = data?.keys ?? []
const byokEnabled = data?.byokEnabled ?? true
const upsertKey = useUpsertBYOKKey()
const deleteKey = useDeleteBYOKKey()
@@ -96,6 +98,31 @@ export function BYOK() {
return keys.find((k) => k.providerId === providerId)
}
// Show enterprise-only gate if BYOK is not enabled
if (!isLoading && !byokEnabled) {
return (
<div className='flex h-full flex-col items-center justify-center gap-[16px] py-[32px]'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-6)]'>
<Crown className='h-[24px] w-[24px] text-[var(--amber-9)]' />
</div>
<div className='flex flex-col items-center gap-[8px] text-center'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>Enterprise Feature</h3>
<p className='max-w-[320px] text-[13px] text-[var(--text-secondary)]'>
Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
use your own API keys and eliminate the 2x cost multiplier.
</p>
</div>
<Button
variant='primary'
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
onClick={() => window.open('https://sim.ai/enterprise', '_blank')}
>
Contact Sales
</Button>
</div>
)
}
const handleSave = async () => {
if (!editingProvider || !apiKeyInput.trim()) return

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