diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index d67a8a3628..8bf0d74f10 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -1,60 +1,57 @@ --- -description: Testing patterns with Vitest +description: Testing patterns with Vitest and @sim/testing globs: ["apps/sim/**/*.test.ts", "apps/sim/**/*.test.tsx"] --- # Testing Patterns -Use Vitest. Test files live next to source: `feature.ts` → `feature.test.ts` +Use Vitest. Test files: `feature.ts` → `feature.test.ts` ## Structure ```typescript /** - * Tests for [feature name] - * * @vitest-environment node */ +import { databaseMock, loggerMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' -// 1. Mocks BEFORE imports -vi.mock('@sim/db', () => ({ db: { select: vi.fn() } })) +vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/logger', () => loggerMock) -// 2. Imports AFTER mocks -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { createSession, loggerMock } from '@sim/testing' import { myFunction } from '@/lib/feature' describe('myFunction', () => { beforeEach(() => vi.clearAllMocks()) - - it('should do something', () => { - expect(myFunction()).toBe(expected) - }) - - it.concurrent('runs in parallel', () => { ... }) + it.concurrent('isolated tests run in parallel', () => { ... }) }) ``` ## @sim/testing Package -```typescript -// Factories - create test data -import { createBlock, createWorkflow, createSession } from '@sim/testing' +Always prefer over local mocks. -// Mocks - pre-configured mocks -import { loggerMock, databaseMock, fetchMock } from '@sim/testing' - -// Builders - fluent API for complex objects -import { ExecutionBuilder, WorkflowBuilder } from '@sim/testing' -``` +| Category | Utilities | +|----------|-----------| +| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | ## Rules 1. `@vitest-environment node` directive at file top -2. **Mocks before imports** - `vi.mock()` calls must come first -3. Use `@sim/testing` factories over manual test data -4. `it.concurrent` for independent tests (faster) +2. `vi.mock()` calls before importing mocked modules +3. `@sim/testing` utilities over local mocks +4. `it.concurrent` for isolated tests (no shared mutable state) 5. `beforeEach(() => vi.clearAllMocks())` to reset state -6. Group related tests with nested `describe` blocks -7. Test file naming: `*.test.ts` (not `*.spec.ts`) + +## Hoisted Mocks + +For mutable mock references: + +```typescript +const mockFn = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/module', () => ({ myFunction: mockFn })) +mockFn.mockResolvedValue({ data: 'test' }) +``` diff --git a/CLAUDE.md b/CLAUDE.md index 8ef5434d21..c9621ff178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,13 +173,13 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts` /** * @vitest-environment node */ +import { databaseMock, loggerMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' -// Mocks BEFORE imports -vi.mock('@sim/db', () => ({ db: { select: vi.fn() } })) +vi.mock('@sim/db', () => databaseMock) +vi.mock('@sim/logger', () => loggerMock) -// Imports AFTER mocks -import { describe, expect, it, vi } from 'vitest' -import { createSession, loggerMock } from '@sim/testing' +import { myFunction } from '@/lib/feature' describe('feature', () => { beforeEach(() => vi.clearAllMocks()) @@ -187,7 +187,7 @@ describe('feature', () => { }) ``` -Use `@sim/testing` factories over manual test data. +Use `@sim/testing` mocks/factories over local test data. See `.cursor/rules/sim-testing.mdc` for details. ## Utils Rules diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 192905bead..de0ab92021 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4575,3 +4575,22 @@ export function FirefliesIcon(props: SVGProps) { ) } + +export function BedrockIcon(props: SVGProps) { + return ( + + + + + + + + + + + ) +} diff --git a/apps/docs/content/docs/de/execution/costs.mdx b/apps/docs/content/docs/de/execution/costs.mdx index 1f5da14764..743d43d93a 100644 --- a/apps/docs/content/docs/de/execution/costs.mdx +++ b/apps/docs/content/docs/de/execution/costs.mdx @@ -49,40 +49,40 @@ Die Modellaufschlüsselung zeigt: - **Gehostete Modelle** - Sim stellt API-Schlüssel mit einem 2-fachen Preismultiplikator bereit: + **Hosted Models** - Sim bietet API-Schlüssel mit einem 1,4-fachen Preismultiplikator für Agent-Blöcke: **OpenAI** - | Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) | + | Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) | |-------|---------------------------|----------------------------| - | GPT-5.1 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ | - | GPT-5 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ | - | GPT-5 Mini | 0,25 $ / 2,00 $ | 0,50 $ / 4,00 $ | - | GPT-5 Nano | 0,05 $ / 0,40 $ | 0,10 $ / 0,80 $ | - | GPT-4o | 2,50 $ / 10,00 $ | 5,00 $ / 20,00 $ | - | GPT-4.1 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ | - | GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,80 $ / 3,20 $ | - | GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,20 $ / 0,80 $ | - | o1 | 15,00 $ / 60,00 $ | 30,00 $ / 120,00 $ | - | o3 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ | - | o4 Mini | 1,10 $ / 4,40 $ | 2,20 $ / 8,80 $ | + | GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 | + | GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 | + | GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 | + | GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 | + | GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 | + | GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 | + | o1 | $15.00 / $60.00 | $21.00 / $84.00 | + | o3 | $2.00 / $8.00 | $2.80 / $11.20 | + | o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 | **Anthropic** - | Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) | + | Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) | |-------|---------------------------|----------------------------| - | Claude Opus 4.5 | 5,00 $ / 25,00 $ | 10,00 $ / 50,00 $ | - | Claude Opus 4.1 | 15,00 $ / 75,00 $ | 30,00 $ / 150,00 $ | - | Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ | - | Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ | - | Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 2,00 $ / 10,00 $ | + | Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 | + | Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 | + | Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 | **Google** - | Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) | + | Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) | |-------|---------------------------|----------------------------| - | Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 4,00 $ / 24,00 $ | - | Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ | - | Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,60 $ / 5,00 $ | + | Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 | + | Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 | + | Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 | - *Der 2x-Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.* + *Der 1,4-fache Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.* diff --git a/apps/docs/content/docs/en/blocks/router.mdx b/apps/docs/content/docs/en/blocks/router.mdx index e0f916fc83..44bac918e7 100644 --- a/apps/docs/content/docs/en/blocks/router.mdx +++ b/apps/docs/content/docs/en/blocks/router.mdx @@ -6,12 +6,12 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' -The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent. +The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent. Each route you define creates a separate output port, allowing you to connect different paths to different downstream blocks.
Router Block with Multiple Paths`**: Summary of the routing prompt -- **``**: Chosen destination block +- **``**: The context that was analyzed +- **``**: The ID of the selected route +- **``**: Details of the chosen destination block - **``**: Token usage statistics - **``**: Estimated routing cost - **``**: Model used for decision-making @@ -75,26 +78,36 @@ Your API key for the selected LLM provider. This is securely stored and used for ## Example Use Cases **Customer Support Triage** - Route tickets to specialized departments + ``` -Input (Ticket) → Router → Agent (Engineering) or Agent (Finance) +Input (Ticket) → Router + ├── [Sales Route] → Agent (Sales Team) + ├── [Technical Route] → Agent (Engineering) + └── [Billing Route] → Agent (Finance) ``` **Content Classification** - Classify and route user-generated content + ``` -Input (Feedback) → Router → Workflow (Product) or Workflow (Technical) +Input (Feedback) → Router + ├── [Product Feedback] → Workflow (Product Team) + └── [Bug Report] → Workflow (Technical Team) ``` **Lead Qualification** - Route leads based on qualification criteria + ``` -Input (Lead) → Router → Agent (Enterprise Sales) or Workflow (Self-serve) +Input (Lead) → Router + ├── [Enterprise] → Agent (Enterprise Sales) + └── [Self-serve] → Workflow (Automated Onboarding) ``` - ## Best Practices -- **Provide clear target descriptions**: Help the Router understand when to select each destination with specific, detailed descriptions -- **Use specific routing criteria**: Define clear conditions and examples for each path to improve accuracy -- **Implement fallback paths**: Connect a default destination for when no specific path is appropriate -- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content -- **Monitor routing performance**: Review routing decisions regularly and refine criteria based on actual usage patterns -- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions +- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria. +- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions. +- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes. +- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability. +- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content. +- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns. +- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions. diff --git a/apps/docs/content/docs/en/enterprise/index.mdx b/apps/docs/content/docs/en/enterprise/index.mdx index c5b451d83d..3e5acdf5e2 100644 --- a/apps/docs/content/docs/en/enterprise/index.mdx +++ b/apps/docs/content/docs/en/enterprise/index.mdx @@ -1,6 +1,6 @@ --- title: Enterprise -description: Enterprise features for organizations with advanced security and compliance requirements +description: Enterprise features for business organizations --- import { Callout } from 'fumadocs-ui/components/callout' @@ -9,6 +9,28 @@ Sim Studio Enterprise provides advanced features for organizations with enhanced --- +## Access Control + +Define permission groups to control what features and integrations team members can use. + +### Features + +- **Allowed Model Providers** - Restrict which AI providers users can access (OpenAI, Anthropic, Google, etc.) +- **Allowed Blocks** - Control which workflow blocks are available +- **Platform Settings** - Hide Knowledge Base, disable MCP tools, or disable custom tools + +### Setup + +1. Navigate to **Settings** → **Access Control** in your workspace +2. Create a permission group with your desired restrictions +3. Add team members to the permission group + + + Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time. + + +--- + ## Bring Your Own Key (BYOK) Use your own API keys for AI model providers instead of Sim Studio's hosted keys. @@ -61,15 +83,38 @@ Enterprise authentication with SAML 2.0 and OIDC support for centralized identit --- -## Self-Hosted +## Self-Hosted Configuration + +For self-hosted deployments, enterprise features can be enabled via environment variables without requiring billing. -For self-hosted deployments, enterprise features can be enabled via environment variables: +### Environment Variables | Variable | Description | |----------|-------------| +| `ORGANIZATIONS_ENABLED`, `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | Enable team/organization management | +| `ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | Permission groups for access restrictions | | `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 | - - BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables. - +### Organization Management + +When billing is disabled, use the Admin API to manage organizations: + +```bash +# Create an organization +curl -X POST https://your-instance/api/v1/admin/organizations \ + -H "x-admin-key: YOUR_ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "My Organization", "ownerId": "user-id-here"}' + +# Add a member +curl -X POST https://your-instance/api/v1/admin/organizations/{orgId}/members \ + -H "x-admin-key: YOUR_ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"userId": "user-id-here", "role": "admin"}' +``` + +### Notes + +- Enabling `ACCESS_CONTROL_ENABLED` automatically enables organizations, as access control requires organization membership. +- BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables. diff --git a/apps/docs/content/docs/en/execution/costs.mdx b/apps/docs/content/docs/en/execution/costs.mdx index 960681b0eb..dce00ace96 100644 --- a/apps/docs/content/docs/en/execution/costs.mdx +++ b/apps/docs/content/docs/en/execution/costs.mdx @@ -48,40 +48,40 @@ The model breakdown shows: - **Hosted Models** - Sim provides API keys with a 2x pricing multiplier: + **Hosted Models** - Sim provides API keys with a 1.4x pricing multiplier for Agent blocks: **OpenAI** | Model | Base Price (Input/Output) | Hosted Price (Input/Output) | |-------|---------------------------|----------------------------| - | GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 | - | GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 | - | GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 | - | GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 | - | GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 | - | GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 | - | o1 | $15.00 / $60.00 | $30.00 / $120.00 | - | o3 | $2.00 / $8.00 | $4.00 / $16.00 | - | o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 | + | GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 | + | GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 | + | GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 | + | GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 | + | GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 | + | GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 | + | o1 | $15.00 / $60.00 | $21.00 / $84.00 | + | o3 | $2.00 / $8.00 | $2.80 / $11.20 | + | o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 | **Anthropic** | Model | Base Price (Input/Output) | Hosted Price (Input/Output) | |-------|---------------------------|----------------------------| - | Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 | - | Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 | - | Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 | + | Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 | + | Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 | + | Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 | **Google** | Model | Base Price (Input/Output) | Hosted Price (Input/Output) | |-------|---------------------------|----------------------------| - | Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 | - | Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 | - | Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 | + | Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 | + | Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 | + | Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 | - *The 2x multiplier covers infrastructure and API management costs.* + *The 1.4x multiplier covers infrastructure and API management costs.* diff --git a/apps/docs/content/docs/en/execution/form.mdx b/apps/docs/content/docs/en/execution/form.mdx new file mode 100644 index 0000000000..3df49004e5 --- /dev/null +++ b/apps/docs/content/docs/en/execution/form.mdx @@ -0,0 +1,136 @@ +--- +title: Form Deployment +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Deploy your workflow as an embeddable form that users can fill out on your website or share via link. Form submissions trigger your workflow with the `form` trigger type. + +## Overview + +Form deployment turns your workflow's Input Format into a responsive form that can be: +- Shared via a direct link (e.g., `https://sim.ai/form/my-survey`) +- Embedded in any website using an iframe + +When a user submits the form, it triggers your workflow with the form data. + + +Forms derive their fields from your workflow's Start block Input Format. Each field becomes a form input with the appropriate type. + + +## Creating a Form + +1. Open your workflow and click **Deploy** +2. Select the **Form** tab +3. Configure: + - **URL**: Unique identifier (e.g., `contact-form` → `sim.ai/form/contact-form`) + - **Title**: Form heading + - **Description**: Optional subtitle + - **Form Fields**: Customize labels and descriptions for each field + - **Authentication**: Public, password-protected, or email whitelist + - **Thank You Message**: Shown after submission +4. Click **Launch** + +## Field Type Mapping + +| Input Format Type | Form Field | +|------------------|------------| +| `string` | Text input | +| `number` | Number input | +| `boolean` | Toggle switch | +| `object` | JSON editor | +| `array` | JSON array editor | +| `files` | File upload | + +## Access Control + +| Mode | Description | +|------|-------------| +| **Public** | Anyone with the link can submit | +| **Password** | Users must enter a password | +| **Email Whitelist** | Only specified emails/domains can submit | + +For email whitelist: +- Exact: `user@example.com` +- Domain: `@example.com` (all emails from domain) + +## Embedding + +### Direct Link + +``` +https://sim.ai/form/your-identifier +``` + +### Iframe + +```html + +``` + +## API Submission + +Submit forms programmatically: + + + +```bash +curl -X POST https://sim.ai/api/form/your-identifier \ + -H "Content-Type: application/json" \ + -d '{ + "formData": { + "name": "John Doe", + "email": "john@example.com" + } + }' +``` + + +```typescript +const response = await fetch('https://sim.ai/api/form/your-identifier', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + formData: { + name: 'John Doe', + email: 'john@example.com' + } + }) +}); + +const result = await response.json(); +// { success: true, data: { executionId: '...' } } +``` + + + +### Protected Forms + +For password-protected forms: +```bash +curl -X POST https://sim.ai/api/form/your-identifier \ + -H "Content-Type: application/json" \ + -d '{ "password": "secret", "formData": { "name": "John" } }' +``` + +For email-protected forms: +```bash +curl -X POST https://sim.ai/api/form/your-identifier \ + -H "Content-Type: application/json" \ + -d '{ "email": "allowed@example.com", "formData": { "name": "John" } }' +``` + +## Troubleshooting + +**"No input fields configured"** - Add Input Format fields to your Start block. + +**Form not loading in iframe** - Check your site's CSP allows iframes from `sim.ai`. + +**Submissions failing** - Verify the identifier is correct and required fields are filled. diff --git a/apps/docs/content/docs/en/execution/meta.json b/apps/docs/content/docs/en/execution/meta.json index 37cac68f5a..02f2c537db 100644 --- a/apps/docs/content/docs/en/execution/meta.json +++ b/apps/docs/content/docs/en/execution/meta.json @@ -1,3 +1,3 @@ { - "pages": ["index", "basics", "api", "logging", "costs"] + "pages": ["index", "basics", "api", "form", "logging", "costs"] } diff --git a/apps/docs/content/docs/en/tools/google_drive.mdx b/apps/docs/content/docs/en/tools/google_drive.mdx index 27de721e6e..54b3ed3bc3 100644 --- a/apps/docs/content/docs/en/tools/google_drive.mdx +++ b/apps/docs/content/docs/en/tools/google_drive.mdx @@ -48,7 +48,7 @@ Integrate Google Drive into the workflow. Can create, upload, and list files. ### `google_drive_upload` -Upload a file to Google Drive +Upload a file to Google Drive with complete metadata returned #### Input @@ -65,11 +65,11 @@ Upload a file to Google Drive | Parameter | Type | Description | | --------- | ---- | ----------- | -| `file` | json | Uploaded file metadata including ID, name, and links | +| `file` | object | Complete uploaded file metadata from Google Drive | ### `google_drive_create_folder` -Create a new folder in Google Drive +Create a new folder in Google Drive with complete metadata returned #### Input @@ -83,11 +83,11 @@ Create a new folder in Google Drive | Parameter | Type | Description | | --------- | ---- | ----------- | -| `file` | json | Created folder metadata including ID, name, and parent information | +| `file` | object | Complete created folder metadata from Google Drive | ### `google_drive_download` -Download a file from Google Drive (exports Google Workspace files automatically) +Download a file from Google Drive with complete metadata (exports Google Workspace files automatically) #### Input @@ -96,16 +96,17 @@ Download a file from Google Drive (exports Google Workspace files automatically) | `fileId` | string | Yes | The ID of the file to download | | `mimeType` | string | No | The MIME type to export Google Workspace files to \(optional\) | | `fileName` | string | No | Optional filename override | +| `includeRevisions` | boolean | No | Whether to include revision history in the metadata \(default: true\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `file` | file | Downloaded file stored in execution files | +| `file` | object | Downloaded file stored in execution files | ### `google_drive_list` -List files and folders in Google Drive +List files and folders in Google Drive with complete metadata #### Input @@ -121,7 +122,7 @@ List files and folders in Google Drive | Parameter | Type | Description | | --------- | ---- | ----------- | -| `files` | json | Array of file metadata objects from the specified folder | +| `files` | array | Array of file metadata objects from Google Drive | diff --git a/apps/docs/content/docs/en/tools/grain.mdx b/apps/docs/content/docs/en/tools/grain.mdx index cd30c96139..7df544d8ae 100644 --- a/apps/docs/content/docs/en/tools/grain.mdx +++ b/apps/docs/content/docs/en/tools/grain.mdx @@ -162,6 +162,7 @@ Create a webhook to receive recording events | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Grain API key \(Personal Access Token\) | | `hookUrl` | string | Yes | Webhook endpoint URL \(must respond 2xx\) | +| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" | | `filterBeforeDatetime` | string | No | Filter: recordings before this date | | `filterAfterDatetime` | string | No | Filter: recordings after this date | | `filterParticipantScope` | string | No | Filter: "internal" or "external" | @@ -178,6 +179,7 @@ Create a webhook to receive recording events | `id` | string | Hook UUID | | `enabled` | boolean | Whether hook is active | | `hook_url` | string | The webhook URL | +| `hook_type` | string | Type of hook: recording_added or upload_status | | `filter` | object | Applied filters | | `include` | object | Included fields | | `inserted_at` | string | ISO8601 creation timestamp | diff --git a/apps/docs/content/docs/en/tools/linear.mdx b/apps/docs/content/docs/en/tools/linear.mdx index bf1814774a..c0d6daa5fd 100644 --- a/apps/docs/content/docs/en/tools/linear.mdx +++ b/apps/docs/content/docs/en/tools/linear.mdx @@ -851,24 +851,6 @@ List all status updates for a project in Linear | --------- | ---- | ----------- | | `updates` | array | Array of project updates | -### `linear_create_project_link` - -Add an external link to a project in Linear - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `projectId` | string | Yes | Project ID to add link to | -| `url` | string | Yes | URL of the external link | -| `label` | string | No | Link label/title | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `link` | object | The created project link | - ### `linear_list_notifications` List notifications for the current user in Linear @@ -1246,7 +1228,6 @@ Create a new project label in Linear | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `projectId` | string | Yes | The project for this label | | `name` | string | Yes | Project label name | | `color` | string | No | Label color \(hex code\) | | `description` | string | No | Label description | @@ -1424,12 +1405,12 @@ Create a new project status in Linear | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `projectId` | string | Yes | The project to create the status for | | `name` | string | Yes | Project status name | +| `type` | string | Yes | Status type: "backlog", "planned", "started", "paused", "completed", or "canceled" | | `color` | string | Yes | Status color \(hex code\) | +| `position` | number | Yes | Position in status list \(e.g. 0, 1, 2...\) | | `description` | string | No | Status description | | `indefinite` | boolean | No | Whether the status is indefinite | -| `position` | number | No | Position in status list | #### Output diff --git a/apps/docs/content/docs/en/tools/posthog.mdx b/apps/docs/content/docs/en/tools/posthog.mdx index bbc27e9e8f..c7acf1fdbe 100644 --- a/apps/docs/content/docs/en/tools/posthog.mdx +++ b/apps/docs/content/docs/en/tools/posthog.mdx @@ -79,30 +79,6 @@ Capture multiple events at once in PostHog. Use this for bulk event ingestion to | `status` | string | Status message indicating whether the batch was captured successfully | | `eventsProcessed` | number | Number of events processed in the batch | -### `posthog_list_events` - -List events in PostHog. Note: This endpoint is deprecated but kept for backwards compatibility. For production use, prefer the Query endpoint with HogQL. - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) | -| `region` | string | No | PostHog region: us \(default\) or eu | -| `projectId` | string | Yes | PostHog Project ID | -| `limit` | number | No | Number of events to return \(default: 100, max: 100\) | -| `offset` | number | No | Number of events to skip for pagination | -| `event` | string | No | Filter by specific event name | -| `distinctId` | string | No | Filter by specific distinct_id | -| `before` | string | No | ISO 8601 timestamp - only return events before this time | -| `after` | string | No | ISO 8601 timestamp - only return events after this time | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `events` | array | List of events with their properties and metadata | - ### `posthog_list_persons` List persons (users) in PostHog. Returns user profiles with their properties and distinct IDs. diff --git a/apps/docs/content/docs/en/tools/translate.mdx b/apps/docs/content/docs/en/tools/translate.mdx index 790cc4d8bc..d28443a91b 100644 --- a/apps/docs/content/docs/en/tools/translate.mdx +++ b/apps/docs/content/docs/en/tools/translate.mdx @@ -53,6 +53,9 @@ Send a chat completion request to any supported LLM provider | `vertexProject` | string | No | Google Cloud project ID for Vertex AI | | `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) | | `vertexCredential` | string | No | Google Cloud OAuth credential ID for Vertex AI | +| `bedrockAccessKeyId` | string | No | AWS Access Key ID for Bedrock | +| `bedrockSecretKey` | string | No | AWS Secret Access Key for Bedrock | +| `bedrockRegion` | string | No | AWS region for Bedrock \(defaults to us-east-1\) | #### Output diff --git a/apps/docs/content/docs/en/triggers/start.mdx b/apps/docs/content/docs/en/triggers/start.mdx index 10610afe48..8997372e8e 100644 --- a/apps/docs/content/docs/en/triggers/start.mdx +++ b/apps/docs/content/docs/en/triggers/start.mdx @@ -44,7 +44,7 @@ Reference structured values downstream with expressions such as <start. ## How it behaves per entry point - + When you click Run in the editor, the Start block renders the Input Format as a form. Default values make it easy to retest without retyping data. Submitting the form triggers the workflow immediately and the values become available on <start.fieldName> (for example <start.sampleField>). @@ -64,6 +64,13 @@ Reference structured values downstream with expressions such as <start. If you launch chat with additional structured context (for example from an embed), it merges into the corresponding <start.fieldName> outputs, keeping downstream blocks consistent with API and manual runs. + + Form deployments render the Input Format as a standalone, embeddable form page. Each field becomes a form input with appropriate UI controls—text inputs for strings, number inputs for numbers, toggle switches for booleans, and file upload zones for files. + + When a user submits the form, values become available on <start.fieldName> just like other entry points. The workflow executes with trigger type form, and submitters see a customizable thank-you message upon completion. + + Forms can be embedded via iframe or shared as direct links, making them ideal for surveys, contact forms, and data collection workflows. + ## Referencing Start data downstream diff --git a/apps/docs/content/docs/es/execution/costs.mdx b/apps/docs/content/docs/es/execution/costs.mdx index 52bd7fc577..59c5d386a4 100644 --- a/apps/docs/content/docs/es/execution/costs.mdx +++ b/apps/docs/content/docs/es/execution/costs.mdx @@ -49,40 +49,40 @@ El desglose del modelo muestra: - **Modelos alojados** - Sim proporciona claves API con un multiplicador de precio de 2x: + **Modelos alojados** - Sim proporciona claves API con un multiplicador de precios de 1.4x para bloques de agente: **OpenAI** | Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) | |-------|---------------------------|----------------------------| - | GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 | - | GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 | - | GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 | - | GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 | - | GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 | - | GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 | - | o1 | $15.00 / $60.00 | $30.00 / $120.00 | - | o3 | $2.00 / $8.00 | $4.00 / $16.00 | - | o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 | + | GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 | + | GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 | + | GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 | + | GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 | + | GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 | + | GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 | + | o1 | $15.00 / $60.00 | $21.00 / $84.00 | + | o3 | $2.00 / $8.00 | $2.80 / $11.20 | + | o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 | **Anthropic** | Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) | |-------|---------------------------|----------------------------| - | Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 | - | Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 | - | Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 | + | Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 | + | Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 | + | Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 | **Google** | Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) | |-------|---------------------------|----------------------------| - | Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 | - | Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 | - | Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 | + | Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 | + | Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 | + | Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 | - *El multiplicador 2x cubre los costos de infraestructura y gestión de API.* + *El multiplicador de 1.4x cubre los costos de infraestructura y gestión de API.* diff --git a/apps/docs/content/docs/fr/execution/costs.mdx b/apps/docs/content/docs/fr/execution/costs.mdx index 5b34903448..e18e7b86a2 100644 --- a/apps/docs/content/docs/fr/execution/costs.mdx +++ b/apps/docs/content/docs/fr/execution/costs.mdx @@ -49,40 +49,40 @@ La répartition des modèles montre : - **Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 2x : + **Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 1,4x pour les blocs Agent : **OpenAI** | Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) | |-------|---------------------------|----------------------------| - | GPT-5.1 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ | - | GPT-5 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ | - | GPT-5 Mini | 0,25 $ / 2,00 $ | 0,50 $ / 4,00 $ | - | GPT-5 Nano | 0,05 $ / 0,40 $ | 0,10 $ / 0,80 $ | - | GPT-4o | 2,50 $ / 10,00 $ | 5,00 $ / 20,00 $ | - | GPT-4.1 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ | - | GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,80 $ / 3,20 $ | - | GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,20 $ / 0,80 $ | - | o1 | 15,00 $ / 60,00 $ | 30,00 $ / 120,00 $ | - | o3 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ | - | o4 Mini | 1,10 $ / 4,40 $ | 2,20 $ / 8,80 $ | + | GPT-5.1 | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ | + | GPT-5 | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ | + | GPT-5 Mini | 0,25 $ / 2,00 $ | 0,35 $ / 2,80 $ | + | GPT-5 Nano | 0,05 $ / 0,40 $ | 0,07 $ / 0,56 $ | + | GPT-4o | 2,50 $ / 10,00 $ | 3,50 $ / 14,00 $ | + | GPT-4.1 | 2,00 $ / 8,00 $ | 2,80 $ / 11,20 $ | + | GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,56 $ / 2,24 $ | + | GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,14 $ / 0,56 $ | + | o1 | 15,00 $ / 60,00 $ | 21,00 $ / 84,00 $ | + | o3 | 2,00 $ / 8,00 $ | 2,80 $ / 11,20 $ | + | o4 Mini | 1,10 $ / 4,40 $ | 1,54 $ / 6,16 $ | **Anthropic** | Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) | |-------|---------------------------|----------------------------| - | Claude Opus 4.5 | 5,00 $ / 25,00 $ | 10,00 $ / 50,00 $ | - | Claude Opus 4.1 | 15,00 $ / 75,00 $ | 30,00 $ / 150,00 $ | - | Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ | - | Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ | - | Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 2,00 $ / 10,00 $ | + | Claude Opus 4.5 | 5,00 $ / 25,00 $ | 7,00 $ / 35,00 $ | + | Claude Opus 4.1 | 15,00 $ / 75,00 $ | 21,00 $ / 105,00 $ | + | Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 4,20 $ / 21,00 $ | + | Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 4,20 $ / 21,00 $ | + | Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 1,40 $ / 7,00 $ | **Google** | Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) | |-------|---------------------------|----------------------------| - | Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 4,00 $ / 24,00 $ | - | Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ | - | Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,60 $ / 5,00 $ | + | Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 2,80 $ / 16,80 $ | + | Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ | + | Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,42 $ / 3,50 $ | - *Le multiplicateur 2x couvre les coûts d'infrastructure et de gestion des API.* + *Le multiplicateur de 1,4x couvre les coûts d'infrastructure et de gestion des API.* diff --git a/apps/docs/content/docs/ja/execution/costs.mdx b/apps/docs/content/docs/ja/execution/costs.mdx index efbbedaaf4..5c4f1def1e 100644 --- a/apps/docs/content/docs/ja/execution/costs.mdx +++ b/apps/docs/content/docs/ja/execution/costs.mdx @@ -47,42 +47,42 @@ AIブロックを使用するワークフローでは、ログで詳細なコス ## 料金オプション - + - **ホステッドモデル** - Simは2倍の価格乗数でAPIキーを提供します: + **ホステッドモデル** - Simは、エージェントブロック用に1.4倍の価格乗数を適用したAPIキーを提供します: **OpenAI** | モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) | |-------|---------------------------|----------------------------| - | GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 | - | GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 | - | GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 | - | GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 | - | GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 | - | GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 | - | o1 | $15.00 / $60.00 | $30.00 / $120.00 | - | o3 | $2.00 / $8.00 | $4.00 / $16.00 | - | o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 | + | GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 | + | GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 | + | GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 | + | GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 | + | GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 | + | GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 | + | o1 | $15.00 / $60.00 | $21.00 / $84.00 | + | o3 | $2.00 / $8.00 | $2.80 / $11.20 | + | o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 | **Anthropic** | モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) | |-------|---------------------------|----------------------------| - | Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 | - | Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 | - | Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 | + | Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 | + | Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 | + | Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 | **Google** | モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) | |-------|---------------------------|----------------------------| - | Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 | - | Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 | - | Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 | + | Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 | + | Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 | + | Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 | - *2倍の乗数は、インフラストラクチャとAPI管理コストをカバーします。* + *1.4倍の乗数は、インフラストラクチャとAPI管理のコストをカバーします。* diff --git a/apps/docs/content/docs/zh/execution/costs.mdx b/apps/docs/content/docs/zh/execution/costs.mdx index 27348044ec..349039c19e 100644 --- a/apps/docs/content/docs/zh/execution/costs.mdx +++ b/apps/docs/content/docs/zh/execution/costs.mdx @@ -47,42 +47,42 @@ totalCost = baseExecutionCharge + modelCost ## 定价选项 - + - **托管模型** - Sim 提供 API 密钥,价格为基础价格的 2 倍: + **托管模型** - Sim 为 Agent 模块提供 API Key,价格乘以 1.4 倍: **OpenAI** | 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) | |-------|---------------------------|----------------------------| - | GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 | - | GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 | - | GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 | - | GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 | - | GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 | - | GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 | - | GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 | - | o1 | $15.00 / $60.00 | $30.00 / $120.00 | - | o3 | $2.00 / $8.00 | $4.00 / $16.00 | - | o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 | + | GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 | + | GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 | + | GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 | + | GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 | + | GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 | + | GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 | + | GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 | + | o1 | $15.00 / $60.00 | $21.00 / $84.00 | + | o3 | $2.00 / $8.00 | $2.80 / $11.20 | + | o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 | **Anthropic** | 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) | |-------|---------------------------|----------------------------| - | Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 | - | Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 | - | Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 | - | Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 | + | Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 | + | Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 | + | Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 | + | Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 | **Google** | 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) | |-------|---------------------------|----------------------------| - | Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 | - | Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 | - | Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 | + | Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 | + | Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 | + | Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 | - *2 倍系数涵盖了基础设施和 API 管理成本。* + *1.4 倍的系数涵盖了基础设施和 API 管理成本。* diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index 99e4ad41d1..dbc142b5bd 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -4581,11 +4581,11 @@ checksums: content/10: d19c8c67f52eb08b6a49c0969a9c8b86 content/11: 4024a36e0d9479ff3191fb9cd2b2e365 content/12: 0396a1e5d9548207f56e6b6cae85a542 - content/13: 4bfdeac5ad21c75209dcdfde85aa52b0 - content/14: 35df9a16b866dbe4bb9fc1d7aee42711 - content/15: 135c044066cea8cc0e22f06d67754ec5 - content/16: 6882b91e30548d7d331388c26cf2e948 - content/17: 29aed7061148ae46fa6ec8bcbc857c3d + content/13: 68f90237f86be125224c56a2643904a3 + content/14: e854781f0fbf6f397a3ac682e892a993 + content/15: 2340c44af715fb8ca58f43151515aae1 + content/16: fc7ae93bff492d80f4b6f16e762e05fa + content/17: 8a46692d5df3fed9f94d59dfc3fb7e0a content/18: e0571c88ea5bcd4305a6f5772dcbed98 content/19: 83fc31418ff454a5e06b290e3708ef32 content/20: 4392b5939a6d5774fb080cad1ee1dbb8 diff --git a/apps/docs/public/static/blocks/router.png b/apps/docs/public/static/blocks/router.png index 30942f2988..c66f3039e7 100644 Binary files a/apps/docs/public/static/blocks/router.png and b/apps/docs/public/static/blocks/router.png differ diff --git a/apps/sim/app/(auth)/components/branded-button.tsx b/apps/sim/app/(auth)/components/branded-button.tsx new file mode 100644 index 0000000000..2b7c8e9702 --- /dev/null +++ b/apps/sim/app/(auth)/components/branded-button.tsx @@ -0,0 +1,100 @@ +'use client' + +import { forwardRef, useState } from 'react' +import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react' +import { Button, type ButtonProps as EmcnButtonProps } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' + +export interface BrandedButtonProps extends Omit { + /** Shows loading spinner and disables button */ + loading?: boolean + /** Text to show when loading (appends "..." automatically) */ + loadingText?: string + /** Show arrow animation on hover (default: true) */ + showArrow?: boolean + /** Make button full width (default: true) */ + fullWidth?: boolean +} + +/** + * Branded button for auth and status pages. + * Automatically detects whitelabel customization and applies appropriate styling. + * + * @example + * ```tsx + * // Primary branded button with arrow + * Sign In + * + * // Loading state + * Sign In + * + * // Without arrow animation + * Continue + * ``` + */ +export const BrandedButton = forwardRef( + ( + { + children, + loading = false, + loadingText, + showArrow = true, + fullWidth = true, + className, + disabled, + onMouseEnter, + onMouseLeave, + ...props + }, + ref + ) => { + const buttonClass = useBrandedButtonClass() + const [isHovered, setIsHovered] = useState(false) + + const handleMouseEnter = (e: React.MouseEvent) => { + setIsHovered(true) + onMouseEnter?.(e) + } + + const handleMouseLeave = (e: React.MouseEvent) => { + setIsHovered(false) + onMouseLeave?.(e) + } + + return ( + + ) + } +) + +BrandedButton.displayName = 'BrandedButton' diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 395a8d4541..df758576c2 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -34,7 +34,7 @@ export function SSOLoginButton({ } const primaryBtnClasses = cn( - primaryClassName || 'auth-button-gradient', + primaryClassName || 'branded-button-gradient', 'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200' ) diff --git a/apps/sim/app/(auth)/components/status-page-layout.tsx b/apps/sim/app/(auth)/components/status-page-layout.tsx new file mode 100644 index 0000000000..d3177b8754 --- /dev/null +++ b/apps/sim/app/(auth)/components/status-page-layout.tsx @@ -0,0 +1,74 @@ +'use client' + +import type { ReactNode } from 'react' +import { inter } from '@/app/_styles/fonts/inter/inter' +import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import AuthBackground from '@/app/(auth)/components/auth-background' +import Nav from '@/app/(landing)/components/nav/nav' +import { SupportFooter } from './support-footer' + +export interface StatusPageLayoutProps { + /** Page title displayed prominently */ + title: string + /** Description text below the title */ + description: string | ReactNode + /** Content to render below the title/description (usually buttons) */ + children?: ReactNode + /** Whether to show the support footer (default: true) */ + showSupportFooter?: boolean + /** Whether to hide the nav bar (useful for embedded forms) */ + hideNav?: boolean +} + +/** + * Unified layout for status/error pages (404, form unavailable, chat error, etc.). + * Uses AuthBackground and Nav for consistent styling with auth pages. + * + * @example + * ```tsx + * + * router.push('/')}>Return to Home + * + * ``` + */ +export function StatusPageLayout({ + title, + description, + children, + showSupportFooter = true, + hideNav = false, +}: StatusPageLayoutProps) { + return ( + +
+ {!hideNav &&
+
+ ) +} diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx new file mode 100644 index 0000000000..057334ee5f --- /dev/null +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useBrandConfig } from '@/lib/branding/branding' +import { inter } from '@/app/_styles/fonts/inter/inter' + +export interface SupportFooterProps { + /** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */ + position?: 'fixed' | 'absolute' +} + +/** + * Support footer component for auth and status pages. + * Displays a "Need help? Contact support" link using branded support email. + * + * @example + * ```tsx + * // Fixed position (for standalone pages) + * + * + * // Absolute position (for pages using AuthLayout) + * + * ``` + */ +export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { + const brandConfig = useBrandConfig() + + return ( +
+ Need help?{' '} + + Contact support + +
+ ) +} diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 10b2313bfd..c2094755a9 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -105,7 +105,7 @@ export default function LoginPage({ const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) const [callbackUrl, setCallbackUrl] = useState('/workspace') @@ -146,9 +146,9 @@ export default function LoginPage({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 7f5b8647d5..7212b52d53 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -27,7 +27,7 @@ export function RequestResetForm({ statusMessage, className, }: RequestResetFormProps) { - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) useEffect(() => { @@ -36,9 +36,9 @@ export function RequestResetForm({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } @@ -138,7 +138,7 @@ export function SetNewPasswordForm({ const [validationMessage, setValidationMessage] = useState('') const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) useEffect(() => { @@ -147,9 +147,9 @@ export function SetNewPasswordForm({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 108e964909..670d4434b0 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -95,7 +95,7 @@ function SignupFormContent({ const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [redirectUrl, setRedirectUrl] = useState('') const [isInviteFlow, setIsInviteFlow] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) const [name, setName] = useState('') @@ -132,9 +132,9 @@ function SignupFormContent({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx index 4d01ebd0b1..0d371bbaff 100644 --- a/apps/sim/app/(auth)/sso/sso-form.tsx +++ b/apps/sim/app/(auth)/sso/sso-form.tsx @@ -57,7 +57,7 @@ export default function SSOForm() { const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [callbackUrl, setCallbackUrl] = useState('/workspace') useEffect(() => { @@ -96,9 +96,9 @@ export default function SSOForm() { const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 7259205bc8..ed05354b94 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -58,7 +58,7 @@ function VerificationForm({ setCountdown(30) } - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') useEffect(() => { const checkCustomBrand = () => { @@ -66,9 +66,9 @@ function VerificationForm({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(landing)/privacy/page.tsx b/apps/sim/app/(landing)/privacy/page.tsx index 7f6a0ee7d6..a32e2b980c 100644 --- a/apps/sim/app/(landing)/privacy/page.tsx +++ b/apps/sim/app/(landing)/privacy/page.tsx @@ -767,7 +767,7 @@ export default function PrivacyPolicy() { privacy@sim.ai -
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA
  • +
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA
  • We will respond to your request within a reasonable timeframe.

    diff --git a/apps/sim/app/(landing)/studio/head.tsx b/apps/sim/app/(landing)/studio/head.tsx deleted file mode 100644 index c528800775..0000000000 --- a/apps/sim/app/(landing)/studio/head.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export default function Head() { - return ( - <> - - - - ) -} diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 70fe344bd0..29ab636e74 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { createContext, useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import posthog from 'posthog-js' import { client } from '@/lib/auth/auth-client' @@ -35,12 +36,15 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { const [data, setData] = useState(null) const [isPending, setIsPending] = useState(true) const [error, setError] = useState(null) + const queryClient = useQueryClient() - const loadSession = useCallback(async () => { + const loadSession = useCallback(async (bypassCache = false) => { try { setIsPending(true) setError(null) - const res = await client.getSession() + const res = bypassCache + ? await client.getSession({ query: { disableCookieCache: true } }) + : await client.getSession() setData(res?.data ?? null) } catch (e) { setError(e instanceof Error ? e : new Error('Failed to fetch session')) @@ -50,8 +54,25 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }, []) useEffect(() => { - loadSession() - }, [loadSession]) + // Check if user was redirected after plan upgrade + const params = new URLSearchParams(window.location.search) + const wasUpgraded = params.get('upgraded') === 'true' + + if (wasUpgraded) { + params.delete('upgraded') + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname + window.history.replaceState({}, '', newUrl) + } + + loadSession(wasUpgraded).then(() => { + if (wasUpgraded) { + queryClient.invalidateQueries({ queryKey: ['organizations'] }) + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + } + }) + }, [loadSession, queryClient]) useEffect(() => { if (isPending || typeof posthog.identify !== 'function') { diff --git a/apps/sim/app/_shell/providers/theme-provider.tsx b/apps/sim/app/_shell/providers/theme-provider.tsx index dae3071b5c..6b3c7f315e 100644 --- a/apps/sim/app/_shell/providers/theme-provider.tsx +++ b/apps/sim/app/_shell/providers/theme-provider.tsx @@ -22,12 +22,13 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) { pathname.startsWith('/changelog') || pathname.startsWith('/chat') || pathname.startsWith('/studio') || - pathname.startsWith('/resume') + pathname.startsWith('/resume') || + pathname.startsWith('/form') return ( div > div { + position: relative; +} + +.react-flow__node.selected > div > div::after { + content: ""; + position: absolute; + inset: 0; + z-index: 40; + border-radius: 8px; + box-shadow: 0 0 0 1.75px var(--brand-secondary); + pointer-events: none; +} + /** * Color tokens - single source of truth for all colors * Light mode: Warm theme @@ -553,27 +587,25 @@ input[type="search"]::-ms-clear { animation: placeholder-pulse 1.5s ease-in-out infinite; } - .auth-button-gradient { - background: linear-gradient(to bottom, var(--brand-primary-hex), var(--brand-400)) !important; - border-color: var(--brand-400) !important; - box-shadow: inset 0 2px 4px 0 var(--brand-400) !important; + .branded-button-gradient { + background: linear-gradient(to bottom, #8357ff, #6f3dfa) !important; + border-color: #6f3dfa !important; + box-shadow: inset 0 2px 4px 0 #9b77ff !important; } - .auth-button-gradient:hover { - background: linear-gradient(to bottom, var(--brand-primary-hex), var(--brand-400)) !important; + .branded-button-gradient:hover { + background: linear-gradient(to bottom, #8357ff, #6f3dfa) !important; opacity: 0.9; } - .auth-button-custom { + .branded-button-custom { background: var(--brand-primary-hex) !important; border-color: var(--brand-primary-hex) !important; - box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1) !important; } - .auth-button-custom:hover { + .branded-button-custom:hover { background: var(--brand-primary-hover-hex) !important; border-color: var(--brand-primary-hover-hex) !important; - opacity: 1; } /** diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index de7b0ff4b6..7a9b7bdee9 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -7,10 +7,11 @@ import type { NextRequest } from 'next/server' import { z } from 'zod' import { renderOTPEmail } from '@/components/emails' import { getRedisClient } from '@/lib/core/config/redis' +import { addCorsHeaders } from '@/lib/core/security/deployment' import { getStorageMethod } from '@/lib/core/storage' import { generateRequestId } from '@/lib/core/utils/request' import { sendEmail } from '@/lib/messaging/email/mailer' -import { addCorsHeaders, setChatAuthCookie } from '@/app/api/chat/utils' +import { setChatAuthCookie } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatOtpAPI') diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index b63aa797f6..efc89bc0f4 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -3,6 +3,7 @@ * * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest } from '@/app/api/__test-utils__/utils' @@ -120,14 +121,8 @@ describe('Chat Identifier API Route', () => { validateAuthToken: vi.fn().mockReturnValue(true), })) - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - })) + // Mock logger - use loggerMock from @sim/testing + vi.doMock('@sim/logger', () => loggerMock) vi.doMock('@sim/db', () => { const mockSelect = vi.fn().mockImplementation((fields) => { diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 5754d38b24..57041c4cc5 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -5,16 +5,12 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { ChatFiles } from '@/lib/uploads' -import { - addCorsHeaders, - setChatAuthCookie, - validateAuthToken, - validateChatAuth, -} from '@/app/api/chat/utils' +import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatIdentifierAPI') @@ -253,7 +249,7 @@ export async function POST( userId: deployment.userId, workspaceId, isDeployed: workflowRecord?.isDeployed ?? false, - variables: workflowRecord?.variables || {}, + variables: (workflowRecord?.variables as Record) ?? undefined, } const stream = await createStreamingResponse({ diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 1be5f483b2..12e6b01a9c 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -1,9 +1,10 @@ -import { NextRequest } from 'next/server' /** * Tests for chat edit API route * * @vitest-environment node */ +import { loggerMock } from '@sim/testing' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/feature-flags', () => ({ @@ -50,14 +51,8 @@ describe('Chat Edit API Route', () => { chat: { id: 'id', identifier: 'identifier', userId: 'userId' }, })) - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - })) + // Mock logger - use loggerMock from @sim/testing + vi.doMock('@sim/logger', () => loggerMock) vi.doMock('@/app/api/workflows/utils', () => ({ createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => { diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 70d92990b4..b6678fb53e 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -1,3 +1,4 @@ +import { databaseMock, loggerMock } from '@sim/testing' import type { NextResponse } from 'next/server' /** * Tests for chat API utils @@ -5,14 +6,9 @@ import type { NextResponse } from 'next/server' * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { env } from '@/lib/core/config/env' -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(), - update: vi.fn(), - }, -})) +vi.mock('@sim/db', () => databaseMock) +vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/logs/execution/logging-session', () => ({ LoggingSession: vi.fn().mockImplementation(() => ({ @@ -52,19 +48,10 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ describe('Chat API Utils', () => { beforeEach(() => { - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - })) - vi.stubGlobal('process', { ...process, env: { - ...env, + ...process.env, NODE_ENV: 'development', }, }) @@ -75,8 +62,8 @@ describe('Chat API Utils', () => { }) describe('Auth token utils', () => { - it('should validate auth tokens', async () => { - const { validateAuthToken } = await import('@/app/api/chat/utils') + it.concurrent('should validate auth tokens', async () => { + const { validateAuthToken } = await import('@/lib/core/security/deployment') const chatId = 'test-chat-id' const type = 'password' @@ -92,8 +79,8 @@ describe('Chat API Utils', () => { expect(isInvalidChat).toBe(false) }) - it('should reject expired tokens', async () => { - const { validateAuthToken } = await import('@/app/api/chat/utils') + it.concurrent('should reject expired tokens', async () => { + const { validateAuthToken } = await import('@/lib/core/security/deployment') const chatId = 'test-chat-id' const expiredToken = Buffer.from( @@ -136,7 +123,7 @@ describe('Chat API Utils', () => { describe('CORS handling', () => { it('should add CORS headers for localhost in development', async () => { - const { addCorsHeaders } = await import('@/app/api/chat/utils') + const { addCorsHeaders } = await import('@/lib/core/security/deployment') const mockRequest = { headers: { @@ -343,7 +330,7 @@ describe('Chat API Utils', () => { }) describe('Execution Result Processing', () => { - it('should process logs regardless of overall success status', () => { + it.concurrent('should process logs regardless of overall success status', () => { const executionResult = { success: false, output: {}, @@ -381,7 +368,7 @@ describe('Chat API Utils', () => { expect(executionResult.logs[1].error).toBe('Agent 2 failed') }) - it('should handle ExecutionResult vs StreamingExecution types correctly', () => { + it.concurrent('should handle ExecutionResult vs StreamingExecution types correctly', () => { const executionResult = { success: true, output: { content: 'test' }, diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 712886a2ff..654c36c8ba 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,17 +1,25 @@ -import { createHash } from 'crypto' import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' -import { isDev } from '@/lib/core/config/feature-flags' +import { + isEmailAllowed, + setDeploymentAuthCookie, + validateAuthToken, +} from '@/lib/core/security/deployment' import { decryptSecret } from '@/lib/core/security/encryption' import { hasAdminPermission } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ChatAuthUtils') -function hashPassword(encryptedPassword: string): string { - return createHash('sha256').update(encryptedPassword).digest('hex').substring(0, 8) +export function setChatAuthCookie( + response: NextResponse, + chatId: string, + type: string, + encryptedPassword?: string | null +): void { + setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword) } /** @@ -82,77 +90,6 @@ export async function checkChatAccess( return { hasAccess: false } } -function encryptAuthToken(chatId: string, type: string, encryptedPassword?: string | null): string { - const pwHash = encryptedPassword ? hashPassword(encryptedPassword) : '' - return Buffer.from(`${chatId}:${type}:${Date.now()}:${pwHash}`).toString('base64') -} - -export function validateAuthToken( - token: string, - chatId: string, - encryptedPassword?: string | null -): boolean { - try { - const decoded = Buffer.from(token, 'base64').toString() - const parts = decoded.split(':') - const [storedId, _type, timestamp, storedPwHash] = parts - - if (storedId !== chatId) { - return false - } - - const createdAt = Number.parseInt(timestamp) - const now = Date.now() - const expireTime = 24 * 60 * 60 * 1000 - - if (now - createdAt > expireTime) { - return false - } - - if (encryptedPassword) { - const currentPwHash = hashPassword(encryptedPassword) - if (storedPwHash !== currentPwHash) { - return false - } - } - - return true - } catch (_e) { - return false - } -} - -export function setChatAuthCookie( - response: NextResponse, - chatId: string, - type: string, - encryptedPassword?: string | null -): void { - const token = encryptAuthToken(chatId, type, encryptedPassword) - response.cookies.set({ - name: `chat_auth_${chatId}`, - value: token, - httpOnly: true, - secure: !isDev, - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24, - }) -} - -export function addCorsHeaders(response: NextResponse, request: NextRequest) { - const origin = request.headers.get('origin') || '' - - if (isDev && origin.includes('localhost')) { - response.headers.set('Access-Control-Allow-Origin', origin) - response.headers.set('Access-Control-Allow-Credentials', 'true') - response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - response.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With') - } - - return response -} - export async function validateChatAuth( requestId: string, deployment: any, @@ -231,12 +168,7 @@ export async function validateChatAuth( const allowedEmails = deployment.allowedEmails || [] - if (allowedEmails.includes(email)) { - return { authorized: false, error: 'otp_required' } - } - - const domain = email.split('@')[1] - if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + if (isEmailAllowed(email, allowedEmails)) { return { authorized: false, error: 'otp_required' } } @@ -270,12 +202,7 @@ export async function validateChatAuth( const allowedEmails = deployment.allowedEmails || [] - if (allowedEmails.includes(email)) { - return { authorized: true } - } - - const domain = email.split('@')[1] - if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + if (isEmailAllowed(email, allowedEmails)) { return { authorized: true } } @@ -296,12 +223,7 @@ export async function validateChatAuth( const allowedEmails = deployment.allowedEmails || [] - if (allowedEmails.includes(userEmail)) { - return { authorized: true } - } - - const domain = userEmail.split('@')[1] - if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + if (isEmailAllowed(userEmail, allowedEmails)) { return { authorized: true } } diff --git a/apps/sim/app/api/copilot/chat/update-title/route.ts b/apps/sim/app/api/copilot/chat/update-title/route.ts deleted file mode 100644 index 7c1451c642..0000000000 --- a/apps/sim/app/api/copilot/chat/update-title/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @deprecated This route is not currently in use - * @remarks Kept for reference - may be removed in future cleanup - */ - -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' - -const logger = createLogger('UpdateChatTitleAPI') - -const UpdateTitleSchema = z.object({ - chatId: z.string(), - title: z.string(), -}) - -export async function POST(request: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const parsed = UpdateTitleSchema.parse(body) - - // Update the chat title - await db - .update(copilotChats) - .set({ - title: parsed.title, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, parsed.chatId)) - - logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error updating chat title:', error) - return NextResponse.json( - { success: false, error: 'Failed to update chat title' }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 5e7fa4006e..ad2818b0d1 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' -import type { EnvironmentVariable } from '@/stores/settings/environment/types' +import type { EnvironmentVariable } from '@/stores/settings/environment' const logger = createLogger('EnvironmentAPI') diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts new file mode 100644 index 0000000000..bfae3e36e0 --- /dev/null +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -0,0 +1,414 @@ +import { randomUUID } from 'crypto' +import { db } from '@sim/db' +import { form, workflow, workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' +import { generateRequestId } from '@/lib/core/utils/request' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('FormIdentifierAPI') + +const formPostBodySchema = z.object({ + formData: z.record(z.unknown()).optional(), + password: z.string().optional(), + email: z.string().email('Invalid email format').optional().or(z.literal('')), +}) + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +/** + * Get the input format schema from the workflow's start block + */ +async function getWorkflowInputSchema(workflowId: string): Promise { + try { + const blocks = await db + .select() + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)) + + // Find the start block (starter or start_trigger type) + const startBlock = blocks.find( + (block) => block.type === 'starter' || block.type === 'start_trigger' + ) + + if (!startBlock) { + return [] + } + + // Extract inputFormat from subBlocks + const subBlocks = startBlock.subBlocks as Record | null + if (!subBlocks?.inputFormat?.value) { + return [] + } + + return Array.isArray(subBlocks.inputFormat.value) ? subBlocks.inputFormat.value : [] + } catch (error) { + logger.error('Error fetching workflow input schema:', error) + return [] + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ identifier: string }> } +) { + const { identifier } = await params + const requestId = generateRequestId() + + try { + logger.debug(`[${requestId}] Processing form submission for identifier: ${identifier}`) + + let parsedBody + try { + const rawBody = await request.json() + const validation = formPostBodySchema.safeParse(rawBody) + + if (!validation.success) { + const errorMessage = validation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + logger.warn(`[${requestId}] Validation error: ${errorMessage}`) + return addCorsHeaders( + createErrorResponse(`Invalid request body: ${errorMessage}`, 400), + request + ) + } + + parsedBody = validation.data + } catch (_error) { + return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) + } + + const deploymentResult = await db + .select({ + id: form.id, + workflowId: form.workflowId, + userId: form.userId, + isActive: form.isActive, + authType: form.authType, + password: form.password, + allowedEmails: form.allowedEmails, + customizations: form.customizations, + }) + .from(form) + .where(eq(form.identifier, identifier)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Form not found', 404), request) + } + + const deployment = deploymentResult[0] + + if (!deployment.isActive) { + logger.warn(`[${requestId}] Form is not active: ${identifier}`) + + const [workflowRecord] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, deployment.workflowId)) + .limit(1) + + const workspaceId = workflowRecord?.workspaceId + if (!workspaceId) { + logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`) + return addCorsHeaders( + createErrorResponse('This form is currently unavailable', 403), + request + ) + } + + const executionId = randomUUID() + const loggingSession = new LoggingSession( + deployment.workflowId, + executionId, + 'form', + requestId + ) + + await loggingSession.safeStart({ + userId: deployment.userId, + workspaceId, + variables: {}, + }) + + await loggingSession.safeCompleteWithError({ + error: { + message: 'This form is currently unavailable. The form has been disabled.', + stackTrace: undefined, + }, + traceSpans: [], + }) + + return addCorsHeaders(createErrorResponse('This form is currently unavailable', 403), request) + } + + const authResult = await validateFormAuth(requestId, deployment, request, parsedBody) + if (!authResult.authorized) { + return addCorsHeaders( + createErrorResponse(authResult.error || 'Authentication required', 401), + request + ) + } + + const { formData, password, email } = parsedBody + + // If only authentication credentials provided (no form data), just return authenticated + if ((password || email) && !formData) { + const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) + return response + } + + if (!formData || Object.keys(formData).length === 0) { + return addCorsHeaders(createErrorResponse('No form data provided', 400), request) + } + + const executionId = randomUUID() + const loggingSession = new LoggingSession(deployment.workflowId, executionId, 'form', requestId) + + const preprocessResult = await preprocessExecution({ + workflowId: deployment.workflowId, + userId: deployment.userId, + triggerType: 'form', + executionId, + requestId, + checkRateLimit: true, + checkDeployment: true, + loggingSession, + }) + + if (!preprocessResult.success) { + logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) + return addCorsHeaders( + createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 + ), + request + ) + } + + const { actorUserId, workflowRecord } = preprocessResult + const workspaceOwnerId = actorUserId! + const workspaceId = workflowRecord?.workspaceId + if (!workspaceId) { + logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) + return addCorsHeaders( + createErrorResponse('Workflow has no associated workspace', 500), + request + ) + } + + try { + const workflowForExecution = { + id: deployment.workflowId, + userId: deployment.userId, + workspaceId, + isDeployed: workflowRecord?.isDeployed ?? false, + variables: (workflowRecord?.variables ?? {}) as Record, + } + + // Pass form data as the workflow input + const workflowInput = { + input: formData, + ...formData, // Spread form fields at top level for convenience + } + + // Execute workflow using streaming (for consistency with chat) + const stream = await createStreamingResponse({ + requestId, + workflow: workflowForExecution, + input: workflowInput, + executingUserId: workspaceOwnerId, + streamConfig: { + selectedOutputs: [], + isSecureMode: true, + workflowTriggerType: 'api', // Use 'api' type since form is similar + }, + executionId, + }) + + // For forms, we don't stream back - we wait for completion and return success + // Consume the stream to wait for completion + const reader = stream.getReader() + let lastOutput: any = null + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + // Parse SSE data if present + const text = new TextDecoder().decode(value) + const lines = text.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)) + if (data.type === 'complete' || data.output) { + lastOutput = data.output || data + } + } catch { + // Ignore parse errors + } + } + } + } + } finally { + reader.releaseLock() + } + + logger.info(`[${requestId}] Form submission successful for ${identifier}`) + + // Return success with customizations for thank you screen + const customizations = deployment.customizations as Record | null + return addCorsHeaders( + createSuccessResponse({ + success: true, + executionId, + thankYouTitle: customizations?.thankYouTitle || 'Thank you!', + thankYouMessage: + customizations?.thankYouMessage || 'Your response has been submitted successfully.', + }), + request + ) + } catch (error: any) { + logger.error(`[${requestId}] Error processing form submission:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to process form submission', 500), + request + ) + } + } catch (error: any) { + logger.error(`[${requestId}] Error processing form submission:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to process form submission', 500), + request + ) + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ identifier: string }> } +) { + const { identifier } = await params + const requestId = generateRequestId() + + try { + logger.debug(`[${requestId}] Fetching form info for identifier: ${identifier}`) + + const deploymentResult = await db + .select({ + id: form.id, + title: form.title, + description: form.description, + customizations: form.customizations, + isActive: form.isActive, + workflowId: form.workflowId, + authType: form.authType, + password: form.password, + allowedEmails: form.allowedEmails, + showBranding: form.showBranding, + }) + .from(form) + .where(eq(form.identifier, identifier)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Form not found', 404), request) + } + + const deployment = deploymentResult[0] + + if (!deployment.isActive) { + logger.warn(`[${requestId}] Form is not active: ${identifier}`) + return addCorsHeaders(createErrorResponse('This form is currently unavailable', 403), request) + } + + // Get the workflow's input schema + const inputSchema = await getWorkflowInputSchema(deployment.workflowId) + + const cookieName = `form_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) + + // If authenticated (via cookie), return full form config + if ( + deployment.authType !== 'public' && + authCookie && + validateAuthToken(authCookie.value, deployment.id, deployment.password) + ) { + return addCorsHeaders( + createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }), + request + ) + } + + // Check authentication requirement + const authResult = await validateFormAuth(requestId, deployment, request) + if (!authResult.authorized) { + // Return limited info for auth required forms + logger.info( + `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}` + ) + return addCorsHeaders( + NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + authType: deployment.authType, + title: deployment.title, + customizations: { + primaryColor: (deployment.customizations as any)?.primaryColor, + logoUrl: (deployment.customizations as any)?.logoUrl, + }, + }, + { status: 401 } + ), + request + ) + } + + return addCorsHeaders( + createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }), + request + ) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching form info:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to fetch form information', 500), + request + ) + } +} + +export async function OPTIONS(request: NextRequest) { + return addCorsHeaders(new NextResponse(null, { status: 204 }), request) +} diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts new file mode 100644 index 0000000000..f2f1cbd1fb --- /dev/null +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -0,0 +1,233 @@ +import { db } from '@sim/db' +import { form } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/core/security/encryption' +import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('FormManageAPI') + +const fieldConfigSchema = z.object({ + name: z.string(), + type: z.string(), + label: z.string(), + description: z.string().optional(), + required: z.boolean().optional(), +}) + +const updateFormSchema = z.object({ + identifier: z + .string() + .min(1, 'Identifier is required') + .max(100, 'Identifier must be 100 characters or less') + .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens') + .optional(), + title: z + .string() + .min(1, 'Title is required') + .max(200, 'Title must be 200 characters or less') + .optional(), + description: z.string().max(1000, 'Description must be 1000 characters or less').optional(), + customizations: z + .object({ + primaryColor: z.string().optional(), + welcomeMessage: z + .string() + .max(500, 'Welcome message must be 500 characters or less') + .optional(), + thankYouTitle: z + .string() + .max(100, 'Thank you title must be 100 characters or less') + .optional(), + thankYouMessage: z + .string() + .max(500, 'Thank you message must be 500 characters or less') + .optional(), + logoUrl: z.string().url('Logo URL must be a valid URL').optional().or(z.literal('')), + fieldConfigs: z.array(fieldConfigSchema).optional(), + }) + .optional(), + authType: z.enum(['public', 'password', 'email']).optional(), + password: z + .string() + .min(6, 'Password must be at least 6 characters') + .optional() + .or(z.literal('')), + allowedEmails: z.array(z.string()).optional(), + showBranding: z.boolean().optional(), + isActive: z.boolean().optional(), +}) + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const { id } = await params + + const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) + + if (!hasAccess || !formRecord) { + return createErrorResponse('Form not found or access denied', 404) + } + + const { password: _password, ...formWithoutPassword } = formRecord + + return createSuccessResponse({ + form: { + ...formWithoutPassword, + hasPassword: !!formRecord.password, + }, + }) + } catch (error: any) { + logger.error('Error fetching form:', error) + return createErrorResponse(error.message || 'Failed to fetch form', 500) + } +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const { id } = await params + + const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) + + if (!hasAccess || !formRecord) { + return createErrorResponse('Form not found or access denied', 404) + } + + const body = await request.json() + + try { + const validatedData = updateFormSchema.parse(body) + + const { + identifier, + title, + description, + customizations, + authType, + password, + allowedEmails, + showBranding, + isActive, + } = validatedData + + if (identifier && identifier !== formRecord.identifier) { + const existingIdentifier = await db + .select() + .from(form) + .where(eq(form.identifier, identifier)) + .limit(1) + + if (existingIdentifier.length > 0) { + return createErrorResponse('Identifier already in use', 400) + } + } + + if (authType === 'password' && !password && !formRecord.password) { + return createErrorResponse('Password is required when using password protection', 400) + } + + if ( + authType === 'email' && + (!allowedEmails || allowedEmails.length === 0) && + (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0) + ) { + return createErrorResponse( + 'At least one email or domain is required when using email access control', + 400 + ) + } + + const updateData: Record = { + updatedAt: new Date(), + } + + if (identifier !== undefined) updateData.identifier = identifier + if (title !== undefined) updateData.title = title + if (description !== undefined) updateData.description = description + if (showBranding !== undefined) updateData.showBranding = showBranding + if (isActive !== undefined) updateData.isActive = isActive + if (authType !== undefined) updateData.authType = authType + if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails + + if (customizations !== undefined) { + const existingCustomizations = (formRecord.customizations as Record) || {} + updateData.customizations = { + ...DEFAULT_FORM_CUSTOMIZATIONS, + ...existingCustomizations, + ...customizations, + } + } + + if (password) { + const { encrypted } = await encryptSecret(password) + updateData.password = encrypted + } else if (authType && authType !== 'password') { + updateData.password = null + } + + await db.update(form).set(updateData).where(eq(form.id, id)) + + logger.info(`Form ${id} updated successfully`) + + return createSuccessResponse({ + message: 'Form updated successfully', + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid request data' + return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + } + throw validationError + } + } catch (error: any) { + logger.error('Error updating form:', error) + return createErrorResponse(error.message || 'Failed to update form', 500) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const { id } = await params + + const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) + + if (!hasAccess || !formRecord) { + return createErrorResponse('Form not found or access denied', 404) + } + + await db.update(form).set({ isActive: false, updatedAt: new Date() }).where(eq(form.id, id)) + + logger.info(`Form ${id} deleted (soft delete)`) + + return createSuccessResponse({ + message: 'Form deleted successfully', + }) + } catch (error: any) { + logger.error('Error deleting form:', error) + return createErrorResponse(error.message || 'Failed to delete form', 500) + } +} diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts new file mode 100644 index 0000000000..ada13f5ee1 --- /dev/null +++ b/apps/sim/app/api/form/route.ts @@ -0,0 +1,214 @@ +import { db } from '@sim/db' +import { form } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { isDev } from '@/lib/core/config/feature-flags' +import { encryptSecret } from '@/lib/core/security/encryption' +import { getEmailDomain } from '@/lib/core/utils/urls' +import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { + checkWorkflowAccessForFormCreation, + DEFAULT_FORM_CUSTOMIZATIONS, +} from '@/app/api/form/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('FormAPI') + +const fieldConfigSchema = z.object({ + name: z.string(), + type: z.string(), + label: z.string(), + description: z.string().optional(), + required: z.boolean().optional(), +}) + +const formSchema = z.object({ + workflowId: z.string().min(1, 'Workflow ID is required'), + identifier: z + .string() + .min(1, 'Identifier is required') + .max(100, 'Identifier must be 100 characters or less') + .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'), + title: z.string().min(1, 'Title is required').max(200, 'Title must be 200 characters or less'), + description: z.string().max(1000, 'Description must be 1000 characters or less').optional(), + customizations: z + .object({ + primaryColor: z.string().optional(), + welcomeMessage: z + .string() + .max(500, 'Welcome message must be 500 characters or less') + .optional(), + thankYouTitle: z + .string() + .max(100, 'Thank you title must be 100 characters or less') + .optional(), + thankYouMessage: z + .string() + .max(500, 'Thank you message must be 500 characters or less') + .optional(), + logoUrl: z.string().url('Logo URL must be a valid URL').optional().or(z.literal('')), + fieldConfigs: z.array(fieldConfigSchema).optional(), + }) + .optional(), + authType: z.enum(['public', 'password', 'email']).default('public'), + password: z + .string() + .min(6, 'Password must be at least 6 characters') + .optional() + .or(z.literal('')), + allowedEmails: z.array(z.string()).optional().default([]), + showBranding: z.boolean().optional().default(true), +}) + +export async function GET(request: NextRequest) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const deployments = await db.select().from(form).where(eq(form.userId, session.user.id)) + + return createSuccessResponse({ deployments }) + } catch (error: any) { + logger.error('Error fetching form deployments:', error) + return createErrorResponse(error.message || 'Failed to fetch form deployments', 500) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const body = await request.json() + + try { + const validatedData = formSchema.parse(body) + + const { + workflowId, + identifier, + title, + description = '', + customizations, + authType = 'public', + password, + allowedEmails = [], + showBranding = true, + } = validatedData + + if (authType === 'password' && !password) { + return createErrorResponse('Password is required when using password protection', 400) + } + + if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse( + 'At least one email or domain is required when using email access control', + 400 + ) + } + + const existingIdentifier = await db + .select() + .from(form) + .where(eq(form.identifier, identifier)) + .limit(1) + + if (existingIdentifier.length > 0) { + return createErrorResponse('Identifier already in use', 400) + } + + const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForFormCreation( + workflowId, + session.user.id + ) + + if (!hasAccess || !workflowRecord) { + return createErrorResponse('Workflow not found or access denied', 404) + } + + const result = await deployWorkflow({ + workflowId, + deployedBy: session.user.id, + }) + + if (!result.success) { + return createErrorResponse(result.error || 'Failed to deploy workflow', 500) + } + + logger.info( + `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})` + ) + + let encryptedPassword = null + if (authType === 'password' && password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + } + + const id = uuidv4() + + logger.info('Creating form deployment with values:', { + workflowId, + identifier, + title, + authType, + hasPassword: !!encryptedPassword, + emailCount: allowedEmails?.length || 0, + showBranding, + }) + + const mergedCustomizations = { + ...DEFAULT_FORM_CUSTOMIZATIONS, + ...(customizations || {}), + } + + await db.insert(form).values({ + id, + workflowId, + userId: session.user.id, + identifier, + title, + description: description || '', + customizations: mergedCustomizations, + isActive: true, + authType, + password: encryptedPassword, + allowedEmails: authType === 'email' ? allowedEmails : [], + showBranding, + createdAt: new Date(), + updatedAt: new Date(), + }) + + const baseDomain = getEmailDomain() + const protocol = isDev ? 'http' : 'https' + const formUrl = `${protocol}://${baseDomain}/form/${identifier}` + + logger.info(`Form "${title}" deployed successfully at ${formUrl}`) + + return createSuccessResponse({ + id, + formUrl, + message: 'Form deployment created successfully', + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid request data' + return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + } + throw validationError + } + } catch (error: any) { + logger.error('Error creating form deployment:', error) + return createErrorResponse(error.message || 'Failed to create form deployment', 500) + } +} diff --git a/apps/sim/app/api/form/utils.test.ts b/apps/sim/app/api/form/utils.test.ts new file mode 100644 index 0000000000..4c5a220eae --- /dev/null +++ b/apps/sim/app/api/form/utils.test.ts @@ -0,0 +1,367 @@ +import { databaseMock, loggerMock } from '@sim/testing' +import type { NextResponse } from 'next/server' +/** + * Tests for form API utils + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => databaseMock) +vi.mock('@sim/logger', () => loggerMock) + +const mockDecryptSecret = vi.fn() + +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mockDecryptSecret, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isDev: true, + isHosted: false, + isProd: false, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + hasAdminPermission: vi.fn(), +})) + +describe('Form API Utils', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Auth token utils', () => { + it.concurrent('should validate auth tokens', async () => { + const { validateAuthToken } = await import('@/lib/core/security/deployment') + + const formId = 'test-form-id' + const type = 'password' + + const token = Buffer.from(`${formId}:${type}:${Date.now()}`).toString('base64') + expect(typeof token).toBe('string') + expect(token.length).toBeGreaterThan(0) + + const isValid = validateAuthToken(token, formId) + expect(isValid).toBe(true) + + const isInvalidForm = validateAuthToken(token, 'wrong-form-id') + expect(isInvalidForm).toBe(false) + }) + + it.concurrent('should reject expired tokens', async () => { + const { validateAuthToken } = await import('@/lib/core/security/deployment') + + const formId = 'test-form-id' + const expiredToken = Buffer.from( + `${formId}:password:${Date.now() - 25 * 60 * 60 * 1000}` + ).toString('base64') + + const isValid = validateAuthToken(expiredToken, formId) + expect(isValid).toBe(false) + }) + + it.concurrent('should validate tokens with password hash', async () => { + const { validateAuthToken } = await import('@/lib/core/security/deployment') + const crypto = await import('crypto') + + const formId = 'test-form-id' + const encryptedPassword = 'encrypted-password-value' + const pwHash = crypto + .createHash('sha256') + .update(encryptedPassword) + .digest('hex') + .substring(0, 8) + + const token = Buffer.from(`${formId}:password:${Date.now()}:${pwHash}`).toString('base64') + + const isValid = validateAuthToken(token, formId, encryptedPassword) + expect(isValid).toBe(true) + + const isInvalidPassword = validateAuthToken(token, formId, 'different-password') + expect(isInvalidPassword).toBe(false) + }) + }) + + describe('Cookie handling', () => { + it('should set auth cookie correctly', async () => { + const { setFormAuthCookie } = await import('@/app/api/form/utils') + + const mockSet = vi.fn() + const mockResponse = { + cookies: { + set: mockSet, + }, + } as unknown as NextResponse + + const formId = 'test-form-id' + const type = 'password' + + setFormAuthCookie(mockResponse, formId, type) + + expect(mockSet).toHaveBeenCalledWith({ + name: `form_auth_${formId}`, + value: expect.any(String), + httpOnly: true, + secure: false, // Development mode + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24, + }) + }) + }) + + describe('CORS handling', () => { + it.concurrent('should add CORS headers for any origin', async () => { + const { addCorsHeaders } = await import('@/lib/core/security/deployment') + + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue('http://localhost:3000'), + }, + } as any + + const mockResponse = { + headers: { + set: vi.fn(), + }, + } as unknown as NextResponse + + addCorsHeaders(mockResponse, mockRequest) + + expect(mockResponse.headers.set).toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + 'http://localhost:3000' + ) + expect(mockResponse.headers.set).toHaveBeenCalledWith( + 'Access-Control-Allow-Credentials', + 'true' + ) + expect(mockResponse.headers.set).toHaveBeenCalledWith( + 'Access-Control-Allow-Methods', + 'GET, POST, OPTIONS' + ) + expect(mockResponse.headers.set).toHaveBeenCalledWith( + 'Access-Control-Allow-Headers', + 'Content-Type, X-Requested-With' + ) + }) + + it.concurrent('should not set CORS headers when no origin', async () => { + const { addCorsHeaders } = await import('@/lib/core/security/deployment') + + const mockRequest = { + headers: { + get: vi.fn().mockReturnValue(''), + }, + } as any + + const mockResponse = { + headers: { + set: vi.fn(), + }, + } as unknown as NextResponse + + addCorsHeaders(mockResponse, mockRequest) + + expect(mockResponse.headers.set).not.toHaveBeenCalled() + }) + }) + + describe('Form auth validation', () => { + beforeEach(async () => { + vi.clearAllMocks() + mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' }) + }) + + it('should allow access to public forms', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + + const deployment = { + id: 'form-id', + authType: 'public', + } + + const mockRequest = { + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const result = await validateFormAuth('request-id', deployment, mockRequest) + + expect(result.authorized).toBe(true) + }) + + it('should request password auth for GET requests', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + + const deployment = { + id: 'form-id', + authType: 'password', + } + + const mockRequest = { + method: 'GET', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const result = await validateFormAuth('request-id', deployment, mockRequest) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('auth_required_password') + }) + + it('should validate password for POST requests', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + const { decryptSecret } = await import('@/lib/core/security/encryption') + + const deployment = { + id: 'form-id', + authType: 'password', + password: 'encrypted-password', + } + + const mockRequest = { + method: 'POST', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const parsedBody = { + password: 'correct-password', + } + + const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody) + + expect(decryptSecret).toHaveBeenCalledWith('encrypted-password') + expect(result.authorized).toBe(true) + }) + + it('should reject incorrect password', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + + const deployment = { + id: 'form-id', + authType: 'password', + password: 'encrypted-password', + } + + const mockRequest = { + method: 'POST', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const parsedBody = { + password: 'wrong-password', + } + + const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('Invalid password') + }) + + it('should request email auth for email-protected forms', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + + const deployment = { + id: 'form-id', + authType: 'email', + allowedEmails: ['user@example.com', '@company.com'], + } + + const mockRequest = { + method: 'GET', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const result = await validateFormAuth('request-id', deployment, mockRequest) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('auth_required_email') + }) + + it('should check allowed emails for email auth', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + + const deployment = { + id: 'form-id', + authType: 'email', + allowedEmails: ['user@example.com', '@company.com'], + } + + const mockRequest = { + method: 'POST', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + // Exact email match should authorize + const result1 = await validateFormAuth('request-id', deployment, mockRequest, { + email: 'user@example.com', + }) + expect(result1.authorized).toBe(true) + + // Domain match should authorize + const result2 = await validateFormAuth('request-id', deployment, mockRequest, { + email: 'other@company.com', + }) + expect(result2.authorized).toBe(true) + + // Unknown email should not authorize + const result3 = await validateFormAuth('request-id', deployment, mockRequest, { + email: 'user@unknown.com', + }) + expect(result3.authorized).toBe(false) + expect(result3.error).toBe('Email not authorized for this form') + }) + + it('should require password when formData is present without password', async () => { + const { validateFormAuth } = await import('@/app/api/form/utils') + + const deployment = { + id: 'form-id', + authType: 'password', + password: 'encrypted-password', + } + + const mockRequest = { + method: 'POST', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const parsedBody = { + formData: { field1: 'value1' }, + // No password provided + } + + const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody) + + expect(result.authorized).toBe(false) + expect(result.error).toBe('auth_required_password') + }) + }) + + describe('Default customizations', () => { + it.concurrent('should have correct default values', async () => { + const { DEFAULT_FORM_CUSTOMIZATIONS } = await import('@/app/api/form/utils') + + expect(DEFAULT_FORM_CUSTOMIZATIONS).toEqual({ + welcomeMessage: '', + thankYouTitle: 'Thank you!', + thankYouMessage: 'Your response has been submitted successfully.', + }) + }) + }) +}) diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts new file mode 100644 index 0000000000..34255df600 --- /dev/null +++ b/apps/sim/app/api/form/utils.ts @@ -0,0 +1,204 @@ +import { db } from '@sim/db' +import { form, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { NextRequest, NextResponse } from 'next/server' +import { + isEmailAllowed, + setDeploymentAuthCookie, + validateAuthToken, +} from '@/lib/core/security/deployment' +import { decryptSecret } from '@/lib/core/security/encryption' +import { hasAdminPermission } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('FormAuthUtils') + +export function setFormAuthCookie( + response: NextResponse, + formId: string, + type: string, + encryptedPassword?: string | null +): void { + setDeploymentAuthCookie(response, 'form', formId, type, encryptedPassword) +} + +/** + * Check if user has permission to create a form for a specific workflow + * Either the user owns the workflow directly OR has admin permission for the workflow's workspace + */ +export async function checkWorkflowAccessForFormCreation( + workflowId: string, + userId: string +): Promise<{ hasAccess: boolean; workflow?: any }> { + const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1) + + if (workflowData.length === 0) { + return { hasAccess: false } + } + + const workflowRecord = workflowData[0] + + if (workflowRecord.userId === userId) { + return { hasAccess: true, workflow: workflowRecord } + } + + if (workflowRecord.workspaceId) { + const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId) + if (hasAdmin) { + return { hasAccess: true, workflow: workflowRecord } + } + } + + return { hasAccess: false } +} + +/** + * Check if user has access to view/edit/delete a specific form + * Either the user owns the form directly OR has admin permission for the workflow's workspace + */ +export async function checkFormAccess( + formId: string, + userId: string +): Promise<{ hasAccess: boolean; form?: any }> { + const formData = await db + .select({ + form: form, + workflowWorkspaceId: workflow.workspaceId, + }) + .from(form) + .innerJoin(workflow, eq(form.workflowId, workflow.id)) + .where(eq(form.id, formId)) + .limit(1) + + if (formData.length === 0) { + return { hasAccess: false } + } + + const { form: formRecord, workflowWorkspaceId } = formData[0] + + if (formRecord.userId === userId) { + return { hasAccess: true, form: formRecord } + } + + if (workflowWorkspaceId) { + const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId) + if (hasAdmin) { + return { hasAccess: true, form: formRecord } + } + } + + return { hasAccess: false } +} + +export async function validateFormAuth( + requestId: string, + deployment: any, + request: NextRequest, + parsedBody?: any +): Promise<{ authorized: boolean; error?: string }> { + const authType = deployment.authType || 'public' + + if (authType === 'public') { + return { authorized: true } + } + + const cookieName = `form_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) + + if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) { + return { authorized: true } + } + + if (authType === 'password') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_password' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Password is required' } + } + + const { password, formData } = parsedBody + + if (formData && !password) { + return { authorized: false, error: 'auth_required_password' } + } + + if (!password) { + return { authorized: false, error: 'Password is required' } + } + + if (!deployment.password) { + logger.error(`[${requestId}] No password set for password-protected form: ${deployment.id}`) + return { authorized: false, error: 'Authentication configuration error' } + } + + const { decrypted } = await decryptSecret(deployment.password) + if (password !== decrypted) { + return { authorized: false, error: 'Invalid password' } + } + + return { authorized: true } + } catch (error) { + logger.error(`[${requestId}] Error validating password:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'email') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_email' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Email is required' } + } + + const { email, formData } = parsedBody + + if (formData && !email) { + return { authorized: false, error: 'auth_required_email' } + } + + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + const allowedEmails: string[] = deployment.allowedEmails || [] + + if (isEmailAllowed(email, allowedEmails)) { + return { authorized: true } + } + + return { authorized: false, error: 'Email not authorized for this form' } + } catch (error) { + logger.error(`[${requestId}] Error validating email:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + return { authorized: false, error: 'Unsupported authentication type' } +} + +/** + * Form customizations interface + */ +export interface FormCustomizations { + primaryColor?: string + welcomeMessage?: string + thankYouTitle?: string + thankYouMessage?: string + logoUrl?: string +} + +/** + * Default form customizations + * Note: primaryColor is intentionally undefined to allow thank you screen to use its green default + */ +export const DEFAULT_FORM_CUSTOMIZATIONS: FormCustomizations = { + welcomeMessage: '', + thankYouTitle: 'Thank you!', + thankYouMessage: 'Your response has been submitted successfully.', +} diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts new file mode 100644 index 0000000000..8352149fd9 --- /dev/null +++ b/apps/sim/app/api/form/validate/route.ts @@ -0,0 +1,71 @@ +import { db } from '@sim/db' +import { form } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('FormValidateAPI') + +const validateQuerySchema = z.object({ + identifier: z + .string() + .min(1, 'Identifier is required') + .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens') + .max(100, 'Identifier must be 100 characters or less'), +}) + +/** + * GET endpoint to validate form identifier availability + */ +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return createErrorResponse('Unauthorized', 401) + } + const { searchParams } = new URL(request.url) + const identifier = searchParams.get('identifier') + + const validation = validateQuerySchema.safeParse({ identifier }) + + if (!validation.success) { + const errorMessage = validation.error.errors[0]?.message || 'Invalid identifier' + logger.warn(`Validation error: ${errorMessage}`) + + if (identifier && !/^[a-z0-9-]+$/.test(identifier)) { + return createSuccessResponse({ + available: false, + error: errorMessage, + }) + } + + return createErrorResponse(errorMessage, 400) + } + + const { identifier: validatedIdentifier } = validation.data + + const existingForm = await db + .select({ id: form.id }) + .from(form) + .where(eq(form.identifier, validatedIdentifier)) + .limit(1) + + const isAvailable = existingForm.length === 0 + + logger.debug( + `Identifier "${validatedIdentifier}" availability check: ${isAvailable ? 'available' : 'taken'}` + ) + + return createSuccessResponse({ + available: isAvailable, + error: isAvailable ? null : 'This identifier is already in use', + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to validate identifier' + logger.error('Error validating form identifier:', error) + return createErrorResponse(message, 500) + } +} diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 12bf26a7ab..783b89d1b2 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -3,6 +3,7 @@ * * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest } from '@/app/api/__test-utils__/utils' @@ -82,14 +83,7 @@ vi.mock('@/lib/execution/isolated-vm', () => ({ }), })) -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - })), -})) +vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/execution/e2b', () => ({ executeInE2B: vi.fn(), diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 676397d2d2..f874c6304b 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -21,7 +21,6 @@ export async function POST(req: NextRequest) { const requestId = generateRequestId() try { - // Get user session const session = await getSession() if (!session?.user?.email) { logger.warn(`[${requestId}] Unauthorized help request attempt`) @@ -30,20 +29,20 @@ export async function POST(req: NextRequest) { const email = session.user.email - // Handle multipart form data const formData = await req.formData() - // Extract form fields const subject = formData.get('subject') as string const message = formData.get('message') as string const type = formData.get('type') as string + const workflowId = formData.get('workflowId') as string | null + const workspaceId = formData.get('workspaceId') as string + const userAgent = formData.get('userAgent') as string | null logger.info(`[${requestId}] Processing help request`, { type, email: `${email.substring(0, 3)}***`, // Log partial email for privacy }) - // Validate the form data const validationResult = helpFormSchema.safeParse({ subject, message, @@ -60,7 +59,6 @@ export async function POST(req: NextRequest) { ) } - // Extract images const images: { filename: string; content: Buffer; contentType: string }[] = [] for (const [key, value] of formData.entries()) { @@ -81,10 +79,14 @@ export async function POST(req: NextRequest) { logger.debug(`[${requestId}] Help request includes ${images.length} images`) - // Prepare email content + const userId = session.user.id let emailText = ` Type: ${type} From: ${email} +User ID: ${userId} +Workspace ID: ${workspaceId ?? 'N/A'} +Workflow ID: ${workflowId ?? 'N/A'} +Browser: ${userAgent ?? 'N/A'} ${message} ` @@ -115,7 +117,6 @@ ${message} logger.info(`[${requestId}] Help request email sent successfully`) - // Send confirmation email to the user try { const confirmationHtml = await renderHelpConfirmationEmail( type as 'bug' | 'feedback' | 'feature_request' | 'other', diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index e5ebe22a8e..279f7e56e7 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -4,18 +4,15 @@ * * @vitest-environment node */ -import { createEnvMock } from '@sim/testing' +import { createEnvMock, createMockLogger } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('drizzle-orm') -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - })), +const loggerMock = vi.hoisted(() => ({ + createLogger: () => createMockLogger(), })) + +vi.mock('drizzle-orm') +vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/db') vi.mock('@/lib/knowledge/documents/utils', () => ({ retryWithExponentialBackoff: (fn: any) => fn(), diff --git a/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts b/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts new file mode 100644 index 0000000000..c6e3faa2d2 --- /dev/null +++ b/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts @@ -0,0 +1,166 @@ +import { db } from '@sim/db' +import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { hasAccessControlAccess } from '@/lib/billing' + +const logger = createLogger('PermissionGroupBulkMembers') + +async function getPermissionGroupWithAccess(groupId: string, userId: string) { + const [group] = await db + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + }) + .from(permissionGroup) + .where(eq(permissionGroup.id, groupId)) + .limit(1) + + if (!group) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId))) + .limit(1) + + if (!membership) return null + + return { group, role: membership.role } +} + +const bulkAddSchema = z.object({ + userIds: z.array(z.string()).optional(), + addAllOrgMembers: z.boolean().optional(), +}) + +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 }) + } + + const { id } = await params + + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } + + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group 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 { userIds, addAllOrgMembers } = bulkAddSchema.parse(body) + + let targetUserIds: string[] = [] + + if (addAllOrgMembers) { + const orgMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, result.group.organizationId)) + + targetUserIds = orgMembers.map((m) => m.userId) + } else if (userIds && userIds.length > 0) { + const validMembers = await db + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, result.group.organizationId), + inArray(member.userId, userIds) + ) + ) + + targetUserIds = validMembers.map((m) => m.userId) + } + + if (targetUserIds.length === 0) { + return NextResponse.json({ added: 0, moved: 0 }) + } + + const existingMemberships = await db + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + permissionGroupId: permissionGroupMember.permissionGroupId, + }) + .from(permissionGroupMember) + .where(inArray(permissionGroupMember.userId, targetUserIds)) + + const alreadyInThisGroup = new Set( + existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId) + ) + const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid)) + + if (usersToAdd.length === 0) { + return NextResponse.json({ added: 0, moved: 0 }) + } + + const membershipsToDelete = existingMemberships.filter( + (m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId) + ) + const movedCount = membershipsToDelete.length + + await db.transaction(async (tx) => { + if (membershipsToDelete.length > 0) { + await tx.delete(permissionGroupMember).where( + inArray( + permissionGroupMember.id, + membershipsToDelete.map((m) => m.id) + ) + ) + } + + const newMembers = usersToAdd.map((userId) => ({ + id: crypto.randomUUID(), + permissionGroupId: id, + userId, + assignedBy: session.user.id, + assignedAt: new Date(), + })) + + await tx.insert(permissionGroupMember).values(newMembers) + }) + + logger.info('Bulk added members to permission group', { + permissionGroupId: id, + addedCount: usersToAdd.length, + movedCount, + assignedBy: session.user.id, + }) + + return NextResponse.json({ added: usersToAdd.length, moved: movedCount }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + if ( + error instanceof Error && + error.message.includes('permission_group_member_user_id_unique') + ) { + return NextResponse.json( + { error: 'One or more users are already in a permission group' }, + { status: 409 } + ) + } + logger.error('Error bulk adding members to permission group', error) + return NextResponse.json({ error: 'Failed to add members' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/permission-groups/[id]/members/route.ts b/apps/sim/app/api/permission-groups/[id]/members/route.ts new file mode 100644 index 0000000000..4979da755e --- /dev/null +++ b/apps/sim/app/api/permission-groups/[id]/members/route.ts @@ -0,0 +1,229 @@ +import { db } from '@sim/db' +import { member, permissionGroup, permissionGroupMember, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { hasAccessControlAccess } from '@/lib/billing' + +const logger = createLogger('PermissionGroupMembers') + +async function getPermissionGroupWithAccess(groupId: string, userId: string) { + const [group] = await db + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + }) + .from(permissionGroup) + .where(eq(permissionGroup.id, groupId)) + .limit(1) + + if (!group) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId))) + .limit(1) + + if (!membership) return null + + return { group, 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 }) + } + + const { id } = await params + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } + + const members = await db + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + assignedAt: permissionGroupMember.assignedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissionGroupMember) + .leftJoin(user, eq(permissionGroupMember.userId, user.id)) + .where(eq(permissionGroupMember.permissionGroupId, id)) + + return NextResponse.json({ members }) +} + +const addMemberSchema = z.object({ + userId: z.string().min(1), +}) + +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 }) + } + + const { id } = await params + + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } + + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group 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 { userId } = addMemberSchema.parse(body) + + const [orgMember] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId))) + .limit(1) + + if (!orgMember) { + return NextResponse.json( + { error: 'User is not a member of this organization' }, + { status: 400 } + ) + } + + const [existingMembership] = await db + .select({ + id: permissionGroupMember.id, + permissionGroupId: permissionGroupMember.permissionGroupId, + }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.userId, userId)) + .limit(1) + + if (existingMembership?.permissionGroupId === id) { + return NextResponse.json( + { error: 'User is already in this permission group' }, + { status: 409 } + ) + } + + const newMember = await db.transaction(async (tx) => { + if (existingMembership) { + await tx + .delete(permissionGroupMember) + .where(eq(permissionGroupMember.id, existingMembership.id)) + } + + const memberData = { + id: crypto.randomUUID(), + permissionGroupId: id, + userId, + assignedBy: session.user.id, + assignedAt: new Date(), + } + + await tx.insert(permissionGroupMember).values(memberData) + return memberData + }) + + logger.info('Added member to permission group', { + permissionGroupId: id, + userId, + assignedBy: session.user.id, + }) + + return NextResponse.json({ member: newMember }, { status: 201 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + if ( + error instanceof Error && + error.message.includes('permission_group_member_user_id_unique') + ) { + return NextResponse.json({ error: 'User is already in a permission group' }, { status: 409 }) + } + logger.error('Error adding member to permission group', error) + return NextResponse.json({ error: 'Failed to add member' }, { 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 }) + } + + 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 hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } + + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group 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(permissionGroupMember) + .where( + and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id)) + ) + .limit(1) + + if (!memberToRemove) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId)) + + logger.info('Removed member from permission group', { + permissionGroupId: id, + memberId, + userId: session.user.id, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error removing member from permission group', error) + return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/permission-groups/[id]/route.ts b/apps/sim/app/api/permission-groups/[id]/route.ts new file mode 100644 index 0000000000..5e1486ff26 --- /dev/null +++ b/apps/sim/app/api/permission-groups/[id]/route.ts @@ -0,0 +1,212 @@ +import { db } from '@sim/db' +import { member, permissionGroup, permissionGroupMember } 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 { hasAccessControlAccess } from '@/lib/billing' +import { + type PermissionGroupConfig, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/types' + +const logger = createLogger('PermissionGroup') + +const configSchema = z.object({ + allowedIntegrations: z.array(z.string()).nullable().optional(), + allowedModelProviders: z.array(z.string()).nullable().optional(), + hideTraceSpans: z.boolean().optional(), + hideKnowledgeBaseTab: z.boolean().optional(), + hideCopilot: z.boolean().optional(), + hideApiKeysTab: z.boolean().optional(), + hideEnvironmentTab: z.boolean().optional(), + hideFilesTab: z.boolean().optional(), + disableMcpTools: z.boolean().optional(), + disableCustomTools: z.boolean().optional(), + hideTemplates: z.boolean().optional(), +}) + +const updateSchema = z.object({ + name: z.string().trim().min(1).max(100).optional(), + description: z.string().max(500).nullable().optional(), + config: configSchema.optional(), +}) + +async function getPermissionGroupWithAccess(groupId: string, userId: string) { + const [group] = await db + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + name: permissionGroup.name, + description: permissionGroup.description, + config: permissionGroup.config, + createdBy: permissionGroup.createdBy, + createdAt: permissionGroup.createdAt, + updatedAt: permissionGroup.updatedAt, + }) + .from(permissionGroup) + .where(eq(permissionGroup.id, groupId)) + .limit(1) + + if (!group) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, group.organizationId))) + .limit(1) + + if (!membership) return null + + return { group, 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 }) + } + + const { id } = await params + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } + + return NextResponse.json({ + permissionGroup: { + ...result.group, + config: parsePermissionGroupConfig(result.group.config), + }, + }) +} + +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 }) + } + + const { id } = await params + + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } + + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group 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 = updateSchema.parse(body) + + if (updates.name) { + const existingGroup = await db + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.organizationId, result.group.organizationId), + eq(permissionGroup.name, updates.name) + ) + ) + .limit(1) + + if (existingGroup.length > 0 && existingGroup[0].id !== id) { + return NextResponse.json( + { error: 'A permission group with this name already exists' }, + { status: 409 } + ) + } + } + + const currentConfig = parsePermissionGroupConfig(result.group.config) + const newConfig: PermissionGroupConfig = updates.config + ? { ...currentConfig, ...updates.config } + : currentConfig + + await db + .update(permissionGroup) + .set({ + ...(updates.name !== undefined && { name: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + config: newConfig, + updatedAt: new Date(), + }) + .where(eq(permissionGroup.id, id)) + + const [updated] = await db + .select() + .from(permissionGroup) + .where(eq(permissionGroup.id, id)) + .limit(1) + + return NextResponse.json({ + permissionGroup: { + ...updated, + config: parsePermissionGroupConfig(updated.config), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error updating permission group', error) + return NextResponse.json({ error: 'Failed to update permission group' }, { 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 }) + } + + const { id } = await params + + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } + + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group 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(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id)) + await db.delete(permissionGroup).where(eq(permissionGroup.id, id)) + + logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting permission group', error) + return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/permission-groups/route.ts b/apps/sim/app/api/permission-groups/route.ts new file mode 100644 index 0000000000..a3c3a7512b --- /dev/null +++ b/apps/sim/app/api/permission-groups/route.ts @@ -0,0 +1,185 @@ +import { db } from '@sim/db' +import { member, organization, permissionGroup, permissionGroupMember, 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 { hasAccessControlAccess } from '@/lib/billing' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/types' + +const logger = createLogger('PermissionGroups') + +const configSchema = z.object({ + allowedIntegrations: z.array(z.string()).nullable().optional(), + allowedModelProviders: z.array(z.string()).nullable().optional(), + hideTraceSpans: z.boolean().optional(), + hideKnowledgeBaseTab: z.boolean().optional(), + hideCopilot: z.boolean().optional(), + hideApiKeysTab: z.boolean().optional(), + hideEnvironmentTab: z.boolean().optional(), + hideFilesTab: z.boolean().optional(), + disableMcpTools: z.boolean().optional(), + disableCustomTools: z.boolean().optional(), + hideTemplates: z.boolean().optional(), +}) + +const createSchema = z.object({ + organizationId: z.string().min(1), + name: z.string().trim().min(1).max(100), + description: z.string().max(500).optional(), + config: configSchema.optional(), +}) + +export async function GET(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + 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 groups = await db + .select({ + id: permissionGroup.id, + name: permissionGroup.name, + description: permissionGroup.description, + config: permissionGroup.config, + createdBy: permissionGroup.createdBy, + createdAt: permissionGroup.createdAt, + updatedAt: permissionGroup.updatedAt, + creatorName: user.name, + creatorEmail: user.email, + }) + .from(permissionGroup) + .leftJoin(user, eq(permissionGroup.createdBy, user.id)) + .where(eq(permissionGroup.organizationId, organizationId)) + .orderBy(desc(permissionGroup.createdAt)) + + const groupsWithCounts = await Promise.all( + groups.map(async (group) => { + const [memberCount] = await db + .select({ count: count() }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, group.id)) + + return { + ...group, + config: parsePermissionGroupConfig(group.config), + memberCount: memberCount?.count ?? 0, + } + }) + ) + + return NextResponse.json({ permissionGroups: groupsWithCounts }) +} + +export async function POST(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } + + const body = await req.json() + const { organizationId, name, description, config } = createSchema.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' }, { 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 existingGroup = await db + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where( + and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name)) + ) + .limit(1) + + if (existingGroup.length > 0) { + return NextResponse.json( + { error: 'A permission group with this name already exists' }, + { status: 409 } + ) + } + + const groupConfig: PermissionGroupConfig = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + ...config, + } + + const now = new Date() + const newGroup = { + id: crypto.randomUUID(), + organizationId, + name, + description: description || null, + config: groupConfig, + createdBy: session.user.id, + createdAt: now, + updatedAt: now, + } + + await db.insert(permissionGroup).values(newGroup) + + logger.info('Created permission group', { + permissionGroupId: newGroup.id, + organizationId, + userId: session.user.id, + }) + + return NextResponse.json({ permissionGroup: newGroup }, { status: 201 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error creating permission group', error) + return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts new file mode 100644 index 0000000000..e41c826533 --- /dev/null +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -0,0 +1,72 @@ +import { db } from '@sim/db' +import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { parsePermissionGroupConfig } from '@/lib/permission-groups/types' + +export async function GET(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + 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 }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .limit(1) + + if (!membership) { + return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 }) + } + + // Short-circuit: if org is not on enterprise plan, ignore permission configs + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!isEnterprise) { + return NextResponse.json({ + permissionGroupId: null, + groupName: null, + config: null, + }) + } + + const [groupMembership] = await db + .select({ + permissionGroupId: permissionGroupMember.permissionGroupId, + config: permissionGroup.config, + groupName: permissionGroup.name, + }) + .from(permissionGroupMember) + .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) + .where( + and( + eq(permissionGroupMember.userId, session.user.id), + eq(permissionGroup.organizationId, organizationId) + ) + ) + .limit(1) + + if (!groupMembership) { + return NextResponse.json({ + permissionGroupId: null, + groupName: null, + config: null, + }) + } + + return NextResponse.json({ + permissionGroupId: groupMembership.permissionGroupId, + groupName: groupMembership.groupName, + config: parsePermissionGroupConfig(groupMembership.config), + }) +} diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index 35b045fc94..807c19d900 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -4,8 +4,8 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' +import { validateAuthToken } from '@/lib/core/security/deployment' import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { validateAuthToken } from '@/app/api/chat/utils' const logger = createLogger('ProxyTTSStreamAPI') diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index 0ab1195884..b7ce032a4b 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -3,6 +3,7 @@ * * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -43,14 +44,7 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'test-request-id', })) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), -})) +vi.mock('@sim/logger', () => loggerMock) import { PUT } from './route' diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index 986e731138..608a1eb068 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -3,6 +3,7 @@ * * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -40,13 +41,7 @@ vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'test-request-id', })) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})) +vi.mock('@sim/logger', () => loggerMock) import { GET } from '@/app/api/schedules/route' diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 5e1e4e8c94..bc38d2dd56 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -10,6 +10,7 @@ import { extractRequiredCredentials, sanitizeCredentials, } from '@/lib/workflows/credentials/credential-extractor' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplateByIdAPI') @@ -189,12 +190,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ .where(eq(workflow.id, template.workflowId)) .limit(1) - const currentState = { + const currentState: Partial = { blocks: normalizedData.blocks, edges: normalizedData.edges, loops: normalizedData.loops, parallels: normalizedData.parallels, - variables: workflowRecord?.variables || undefined, + variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined, lastSaved: Date.now(), } diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index 4ad3bda21e..59c5466871 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' -import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils' +import { + type RegenerateStateInput, + regenerateWorkflowStateIds, +} from '@/lib/workflows/persistence/utils' const logger = createLogger('TemplateUseAPI') @@ -104,9 +107,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Step 2: Regenerate IDs when creating a copy (not when connecting/editing template) // When connecting to template (edit mode), keep original IDs // When using template (copy mode), regenerate all IDs to avoid conflicts + const templateState = templateData.state as RegenerateStateInput const workflowState = connectToTemplate - ? templateData.state - : regenerateWorkflowStateIds(templateData.state) + ? templateState + : regenerateWorkflowStateIds(templateState) // Step 3: Save the workflow state using the existing state endpoint (like imports do) // Ensure variables in state are remapped for the new workflow as well diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 88f61ca129..da83f66153 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -1,14 +1,14 @@ -import { NextRequest } from 'next/server' /** * Tests for custom tools API routes * * @vitest-environment node */ +import { loggerMock } from '@sim/testing' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest } from '@/app/api/__test-utils__/utils' describe('Custom Tools API Routes', () => { - // Sample data for testing const sampleTools = [ { id: 'tool-1', @@ -66,7 +66,6 @@ describe('Custom Tools API Routes', () => { }, ] - // Mock implementation stubs const mockSelect = vi.fn() const mockFrom = vi.fn() const mockWhere = vi.fn() @@ -82,13 +81,9 @@ describe('Custom Tools API Routes', () => { beforeEach(() => { vi.resetModules() - // Reset all mock implementations mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) - // where() can be called with orderBy(), limit(), or directly awaited - // Create a mock query builder that supports all patterns mockWhere.mockImplementation((condition) => { - // Return an object that is both awaitable and has orderBy() and limit() methods const queryBuilder = { orderBy: mockOrderBy, limit: mockLimit, @@ -101,7 +96,6 @@ describe('Custom Tools API Routes', () => { return queryBuilder }) mockOrderBy.mockImplementation(() => { - // orderBy returns an awaitable query builder const queryBuilder = { limit: mockLimit, then: (resolve: (value: typeof sampleTools) => void) => { @@ -119,7 +113,6 @@ describe('Custom Tools API Routes', () => { mockSet.mockReturnValue({ where: mockWhere }) mockDelete.mockReturnValue({ where: mockWhere }) - // Mock database vi.doMock('@sim/db', () => ({ db: { select: mockSelect, @@ -127,14 +120,11 @@ describe('Custom Tools API Routes', () => { update: mockUpdate, delete: mockDelete, transaction: vi.fn().mockImplementation(async (callback) => { - // Execute the callback with a transaction object that has the same methods - // Create transaction-specific mocks that follow the same pattern const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom }) const txMockInsert = vi.fn().mockReturnValue({ values: mockValues }) const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet }) const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere }) - // Transaction where() should also support the query builder pattern with orderBy const txMockOrderBy = vi.fn().mockImplementation(() => { const queryBuilder = { limit: mockLimit, @@ -160,7 +150,6 @@ describe('Custom Tools API Routes', () => { return queryBuilder }) - // Update mockFrom to return txMockWhere for transaction queries const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere }) txMockSelect.mockReturnValue({ from: txMockFrom }) @@ -174,7 +163,6 @@ describe('Custom Tools API Routes', () => { }, })) - // Mock schema vi.doMock('@sim/db/schema', () => ({ customTools: { id: 'id', @@ -189,12 +177,10 @@ describe('Custom Tools API Routes', () => { }, })) - // Mock authentication vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue(mockSession), })) - // Mock hybrid auth vi.doMock('@/lib/auth/hybrid', () => ({ checkHybridAuth: vi.fn().mockResolvedValue({ success: true, @@ -203,22 +189,12 @@ describe('Custom Tools API Routes', () => { }), })) - // Mock permissions vi.doMock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), })) - // Mock logger - vi.doMock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - })) + vi.doMock('@sim/logger', () => loggerMock) - // Mock drizzle-orm functions vi.doMock('drizzle-orm', async () => { const actual = await vi.importActual('drizzle-orm') return { @@ -232,12 +208,10 @@ describe('Custom Tools API Routes', () => { } }) - // Mock utils vi.doMock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn().mockReturnValue('test-request-id'), })) - // Mock custom tools operations vi.doMock('@/lib/workflows/custom-tools/operations', () => ({ upsertCustomTools: vi.fn().mockResolvedValue(sampleTools), })) @@ -252,29 +226,23 @@ describe('Custom Tools API Routes', () => { */ describe('GET /api/tools/custom', () => { it('should return tools for authenticated user with workspaceId', async () => { - // Create mock request with workspaceId const req = new NextRequest( 'http://localhost:3000/api/tools/custom?workspaceId=workspace-123' ) - // Simulate DB returning tools with orderBy chain mockWhere.mockReturnValueOnce({ orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)), }) - // Import handler after mocks are set up const { GET } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await GET(req) const data = await response.json() - // Verify response expect(response.status).toBe(200) expect(data).toHaveProperty('data') expect(data.data).toEqual(sampleTools) - // Verify DB query expect(mockSelect).toHaveBeenCalled() expect(mockFrom).toHaveBeenCalled() expect(mockWhere).toHaveBeenCalled() @@ -282,12 +250,10 @@ describe('Custom Tools API Routes', () => { }) it('should handle unauthorized access', async () => { - // Create mock request const req = new NextRequest( 'http://localhost:3000/api/tools/custom?workspaceId=workspace-123' ) - // Mock hybrid auth to return unauthorized vi.doMock('@/lib/auth/hybrid', () => ({ checkHybridAuth: vi.fn().mockResolvedValue({ success: false, @@ -295,26 +261,20 @@ describe('Custom Tools API Routes', () => { }), })) - // Import handler after mocks are set up const { GET } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await GET(req) const data = await response.json() - // Verify response expect(response.status).toBe(401) expect(data).toHaveProperty('error', 'Unauthorized') }) it('should handle workflowId parameter', async () => { - // Create mock request with workflowId parameter const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123') - // Mock workflow lookup to return workspaceId (for limit(1) call) mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }]) - // Mock the where() call for fetching tools (returns awaitable query builder) mockWhere.mockImplementationOnce((condition) => { const queryBuilder = { limit: mockLimit, @@ -327,18 +287,14 @@ describe('Custom Tools API Routes', () => { return queryBuilder }) - // Import handler after mocks are set up const { GET } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await GET(req) const data = await response.json() - // Verify response expect(response.status).toBe(200) expect(data).toHaveProperty('data') - // Verify DB query was called expect(mockWhere).toHaveBeenCalled() }) }) @@ -348,7 +304,6 @@ describe('Custom Tools API Routes', () => { */ describe('POST /api/tools/custom', () => { it('should reject unauthorized requests', async () => { - // Mock hybrid auth to return unauthorized vi.doMock('@/lib/auth/hybrid', () => ({ checkHybridAuth: vi.fn().mockResolvedValue({ success: false, @@ -356,39 +311,29 @@ describe('Custom Tools API Routes', () => { }), })) - // Create mock request const req = createMockRequest('POST', { tools: [], workspaceId: 'workspace-123' }) - // Import handler after mocks are set up const { POST } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await POST(req) const data = await response.json() - // Verify response expect(response.status).toBe(401) expect(data).toHaveProperty('error', 'Unauthorized') }) it('should validate request data', async () => { - // Create invalid tool data (missing required fields) const invalidTool = { - // Missing title, schema code: 'return "invalid";', } - // Create mock request with invalid tool and workspaceId const req = createMockRequest('POST', { tools: [invalidTool], workspaceId: 'workspace-123' }) - // Import handler after mocks are set up const { POST } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await POST(req) const data = await response.json() - // Verify response expect(response.status).toBe(400) expect(data).toHaveProperty('error', 'Invalid request data') expect(data).toHaveProperty('details') @@ -400,96 +345,74 @@ describe('Custom Tools API Routes', () => { */ describe('DELETE /api/tools/custom', () => { it('should delete a workspace-scoped tool by ID', async () => { - // Mock finding existing workspace-scoped tool mockLimit.mockResolvedValueOnce([sampleTools[0]]) - // Create mock request with ID and workspaceId parameters const req = new NextRequest( 'http://localhost:3000/api/tools/custom?id=tool-1&workspaceId=workspace-123' ) - // Import handler after mocks are set up const { DELETE } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await DELETE(req) const data = await response.json() - // Verify response expect(response.status).toBe(200) expect(data).toHaveProperty('success', true) - // Verify delete was called with correct parameters expect(mockDelete).toHaveBeenCalled() expect(mockWhere).toHaveBeenCalled() }) it('should reject requests missing tool ID', async () => { - // Create mock request without ID parameter const req = createMockRequest('DELETE') - // Import handler after mocks are set up const { DELETE } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await DELETE(req) const data = await response.json() - // Verify response expect(response.status).toBe(400) expect(data).toHaveProperty('error', 'Tool ID is required') }) it('should handle tool not found', async () => { - // Mock tool not found mockLimit.mockResolvedValueOnce([]) - // Create mock request with non-existent ID const req = new NextRequest('http://localhost:3000/api/tools/custom?id=non-existent') - // Import handler after mocks are set up const { DELETE } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await DELETE(req) const data = await response.json() - // Verify response expect(response.status).toBe(404) expect(data).toHaveProperty('error', 'Tool not found') }) it('should prevent unauthorized deletion of user-scoped tool', async () => { - // Mock hybrid auth for the DELETE request vi.doMock('@/lib/auth/hybrid', () => ({ checkHybridAuth: vi.fn().mockResolvedValue({ success: true, - userId: 'user-456', // Different user + userId: 'user-456', authType: 'session', }), })) - // Mock finding user-scoped tool (no workspaceId) that belongs to user-123 const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' } mockLimit.mockResolvedValueOnce([userScopedTool]) - // Create mock request (no workspaceId for user-scoped tool) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') - // Import handler after mocks are set up const { DELETE } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await DELETE(req) const data = await response.json() - // Verify response expect(response.status).toBe(403) expect(data).toHaveProperty('error', 'Access denied') }) it('should reject unauthorized requests', async () => { - // Mock hybrid auth to return unauthorized vi.doMock('@/lib/auth/hybrid', () => ({ checkHybridAuth: vi.fn().mockResolvedValue({ success: false, @@ -497,17 +420,13 @@ describe('Custom Tools API Routes', () => { }), })) - // Create mock request const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') - // Import handler after mocks are set up const { DELETE } = await import('@/app/api/tools/custom/route') - // Call the handler const response = await DELETE(req) const data = await response.json() - // Verify response expect(response.status).toBe(401) expect(data).toHaveProperty('error', 'Unauthorized') }) diff --git a/apps/sim/app/api/v1/admin/access-control/route.ts b/apps/sim/app/api/v1/admin/access-control/route.ts new file mode 100644 index 0000000000..7da37edc8e --- /dev/null +++ b/apps/sim/app/api/v1/admin/access-control/route.ts @@ -0,0 +1,169 @@ +/** + * Admin Access Control (Permission Groups) API + * + * GET /api/v1/admin/access-control + * List all permission groups with optional filtering. + * + * Query Parameters: + * - organizationId?: string - Filter by organization ID + * + * Response: { data: AdminPermissionGroup[], pagination: PaginationMeta } + * + * DELETE /api/v1/admin/access-control + * Delete permission groups for an organization. + * Used when an enterprise plan churns to clean up access control data. + * + * Query Parameters: + * - organizationId: string - Delete all permission groups for this organization + * + * Response: { success: true, deletedCount: number, membersRemoved: number } + */ + +import { db } from '@sim/db' +import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { count, 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('AdminAccessControlAPI') + +export interface AdminPermissionGroup { + id: string + organizationId: string + organizationName: string | null + name: string + description: string | null + memberCount: number + createdAt: string + createdByUserId: string + createdByEmail: string | null +} + +export const GET = withAdminAuth(async (request) => { + const url = new URL(request.url) + const organizationId = url.searchParams.get('organizationId') + + try { + const baseQuery = db + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + organizationName: organization.name, + name: permissionGroup.name, + description: permissionGroup.description, + createdAt: permissionGroup.createdAt, + createdByUserId: permissionGroup.createdBy, + createdByEmail: user.email, + }) + .from(permissionGroup) + .leftJoin(organization, eq(permissionGroup.organizationId, organization.id)) + .leftJoin(user, eq(permissionGroup.createdBy, user.id)) + + let groups + if (organizationId) { + groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId)) + } else { + groups = await baseQuery + } + + const groupsWithCounts = await Promise.all( + groups.map(async (group) => { + const [memberCount] = await db + .select({ count: count() }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, group.id)) + + return { + id: group.id, + organizationId: group.organizationId, + organizationName: group.organizationName, + name: group.name, + description: group.description, + memberCount: memberCount?.count ?? 0, + createdAt: group.createdAt.toISOString(), + createdByUserId: group.createdByUserId, + createdByEmail: group.createdByEmail, + } as AdminPermissionGroup + }) + ) + + logger.info('Admin API: Listed permission groups', { + organizationId, + count: groupsWithCounts.length, + }) + + return singleResponse({ + data: groupsWithCounts, + pagination: { + total: groupsWithCounts.length, + limit: groupsWithCounts.length, + offset: 0, + hasMore: false, + }, + }) + } catch (error) { + logger.error('Admin API: Failed to list permission groups', { error, organizationId }) + return internalErrorResponse('Failed to list permission groups') + } +}) + +export const DELETE = withAdminAuth(async (request) => { + const url = new URL(request.url) + const organizationId = url.searchParams.get('organizationId') + const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup' + + if (!organizationId) { + return badRequestResponse('organizationId is required') + } + + try { + const existingGroups = await db + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where(eq(permissionGroup.organizationId, organizationId)) + + if (existingGroups.length === 0) { + logger.info('Admin API: No permission groups to delete', { organizationId }) + return singleResponse({ + success: true, + deletedCount: 0, + membersRemoved: 0, + message: 'No permission groups found for the given organization', + }) + } + + const groupIds = existingGroups.map((g) => g.id) + + const [memberCountResult] = await db + .select({ count: sql`count(*)` }) + .from(permissionGroupMember) + .where(inArray(permissionGroupMember.permissionGroupId, groupIds)) + + const membersToRemove = Number(memberCountResult?.count ?? 0) + + // Members are deleted via cascade when permission groups are deleted + await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId)) + + logger.info('Admin API: Deleted permission groups', { + organizationId, + deletedCount: existingGroups.length, + membersRemoved: membersToRemove, + reason, + }) + + return singleResponse({ + success: true, + deletedCount: existingGroups.length, + membersRemoved: membersToRemove, + reason, + }) + } catch (error) { + logger.error('Admin API: Failed to delete permission groups', { error, organizationId }) + return internalErrorResponse('Failed to delete permission groups') + } +}) diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index f41409bf90..ad91e0c447 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -36,6 +36,7 @@ * * Organizations: * GET /api/v1/admin/organizations - List all organizations + * POST /api/v1/admin/organizations - Create organization (requires ownerId) * GET /api/v1/admin/organizations/:id - Get organization details * PATCH /api/v1/admin/organizations/:id - Update organization * GET /api/v1/admin/organizations/:id/members - List organization members @@ -55,6 +56,10 @@ * 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 + * + * Access Control (Permission Groups): + * GET /api/v1/admin/access-control - List permission groups (?organizationId=X) + * DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X) */ export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth' diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 952b437144..b563699830 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -16,10 +16,11 @@ */ import { db } from '@sim/db' -import { organization } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { count, eq } from 'drizzle-orm' import { getOrganizationBillingData } from '@/lib/billing/core/organization' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -39,6 +40,42 @@ export const GET = withAdminAuthParams(async (_, context) => { const { id: organizationId } = await context.params try { + if (!isBillingEnabled) { + const [[orgData], [memberCount]] = await Promise.all([ + db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), + db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)), + ]) + + if (!orgData) { + return notFoundResponse('Organization') + } + + const data: AdminOrganizationBillingSummary = { + organizationId: orgData.id, + organizationName: orgData.name, + subscriptionPlan: 'none', + subscriptionStatus: 'none', + totalSeats: Number.MAX_SAFE_INTEGER, + usedSeats: memberCount?.count || 0, + availableSeats: Number.MAX_SAFE_INTEGER, + totalCurrentUsage: 0, + totalUsageLimit: Number.MAX_SAFE_INTEGER, + minimumBillingAmount: 0, + averageUsagePerMember: 0, + usagePercentage: 0, + billingPeriodStart: null, + billingPeriodEnd: null, + membersOverLimit: 0, + membersNearLimit: 0, + } + + logger.info( + `Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)` + ) + + return singleResponse(data) + } + const billingData = await getOrganizationBillingData(organizationId) if (!billingData) { diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 2496c363c6..d3691a6720 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -30,6 +30,7 @@ import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -182,7 +183,7 @@ export const PATCH = withAdminAuthParams(async (request, context) = export const DELETE = withAdminAuthParams(async (request, context) => { const { id: organizationId, memberId } = await context.params const url = new URL(request.url) - const skipBillingLogic = url.searchParams.get('skipBillingLogic') === 'true' + const skipBillingLogic = !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true' try { const [orgData] = await db diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 797831b887..cc9cee6320 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -34,6 +34,7 @@ import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { addUserToOrganization } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -221,14 +222,14 @@ export const POST = withAdminAuthParams(async (request, context) => userId: body.userId, organizationId, role: body.role, + skipBillingLogic: !isBillingEnabled, }) if (!result.success) { return badRequestResponse(result.error || 'Failed to add member') } - // Sync Pro subscription cancellation with Stripe (same as invitation flow) - if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { + if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { try { const stripe = requireStripeClient() await stripe.subscriptions.update( diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index f19f822467..5cac5aba07 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -8,14 +8,32 @@ * - offset: number (default: 0) * * Response: AdminListResponse + * + * POST /api/v1/admin/organizations + * + * Create a new organization. + * + * Body: + * - name: string - Organization name (required) + * - slug: string - Organization slug (optional, auto-generated from name if not provided) + * - ownerId: string - User ID of the organization owner (required) + * + * Response: AdminSingleResponse */ +import { randomUUID } from 'crypto' import { db } from '@sim/db' -import { organization } from '@sim/db/schema' +import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count } from 'drizzle-orm' +import { count, eq } from 'drizzle-orm' import { withAdminAuth } from '@/app/api/v1/admin/middleware' -import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' +import { + badRequestResponse, + internalErrorResponse, + listResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' import { type AdminOrganization, createPaginationMeta, @@ -47,3 +65,90 @@ export const GET = withAdminAuth(async (request) => { return internalErrorResponse('Failed to list organizations') } }) + +export const POST = withAdminAuth(async (request) => { + try { + const body = await request.json() + + if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) { + return badRequestResponse('name is required') + } + + if (!body.ownerId || typeof body.ownerId !== 'string') { + return badRequestResponse('ownerId is required') + } + + const [ownerData] = await db + .select({ id: user.id, name: user.name }) + .from(user) + .where(eq(user.id, body.ownerId)) + .limit(1) + + if (!ownerData) { + return notFoundResponse('Owner user') + } + + const [existingMembership] = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, body.ownerId)) + .limit(1) + + if (existingMembership) { + return badRequestResponse( + 'User is already a member of another organization. Users can only belong to one organization at a time.' + ) + } + + const name = body.name.trim() + const slug = + body.slug?.trim() || + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + + const organizationId = randomUUID() + const memberId = randomUUID() + const now = new Date() + + await db.transaction(async (tx) => { + await tx.insert(organization).values({ + id: organizationId, + name, + slug, + createdAt: now, + updatedAt: now, + }) + + await tx.insert(member).values({ + id: memberId, + userId: body.ownerId, + organizationId, + role: 'owner', + createdAt: now, + }) + }) + + const [createdOrg] = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + logger.info(`Admin API: Created organization ${organizationId}`, { + name, + slug, + ownerId: body.ownerId, + memberId, + }) + + return singleResponse({ + ...toAdminOrganization(createdOrg), + memberId, + }) + } catch (error) { + logger.error('Admin API: Failed to create organization', { error }) + return internalErrorResponse('Failed to create organization') + } +}) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 4c3916810c..fbc12ae7ec 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -243,7 +243,7 @@ export interface WorkflowExportState { color?: string exportedAt?: string } - variables?: WorkflowVariable[] + variables?: Record } export interface WorkflowExportPayload { @@ -317,36 +317,44 @@ export interface WorkspaceImportResponse { // ============================================================================= /** - * Parse workflow variables from database JSON format to array format. - * Handles both array and Record formats. + * Parse workflow variables from database JSON format to Record format. + * Handles both legacy Array and current Record formats. */ export function parseWorkflowVariables( dbVariables: DbWorkflow['variables'] -): WorkflowVariable[] | undefined { +): Record | undefined { if (!dbVariables) return undefined try { const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables + // Handle legacy Array format by converting to Record if (Array.isArray(varsObj)) { - return varsObj.map((v) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + const result: Record = {} + for (const v of varsObj) { + result[v.id] = { + id: v.id, + name: v.name, + type: v.type, + value: v.value, + } + } + return result } + // Already Record format - normalize and return if (typeof varsObj === 'object' && varsObj !== null) { - return Object.values(varsObj).map((v: unknown) => { + const result: Record = {} + for (const [key, v] of Object.entries(varsObj)) { const variable = v as { id: string; name: string; type: VariableType; value: unknown } - return { + result[key] = { id: variable.id, name: variable.name, type: variable.type, value: variable.value, } - }) + } + return result } } catch { // pass diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index db83f52d07..7c3dd58ad6 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -19,6 +19,7 @@ import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { @@ -31,7 +32,6 @@ import { type WorkflowImportRequest, type WorkflowVariable, } from '@/app/api/v1/admin/types' -import { parseWorkflowJson } from '@/stores/workflows/json/importer' const logger = createLogger('AdminWorkflowImportAPI') diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index fa569b7f24..6bb6a4db66 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -31,6 +31,7 @@ import { NextResponse } from 'next/server' import { extractWorkflowName, extractWorkflowsFromZip, + parseWorkflowJson, } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -46,7 +47,6 @@ import { type WorkspaceImportRequest, type WorkspaceImportResponse, } from '@/app/api/v1/admin/types' -import { parseWorkflowJson } from '@/stores/workflows/json/importer' const logger = createLogger('AdminWorkspaceImportAPI') diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 5e8f43560f..80eb62fc99 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -74,8 +74,6 @@ export async function POST( loops: deployedState.loops || {}, parallels: deployedState.parallels || {}, lastSaved: Date.now(), - isDeployed: true, - deployedAt: new Date(), deploymentStatuses: deployedState.deploymentStatuses || {}, }) @@ -88,7 +86,6 @@ export async function POST( .set({ lastSynced: new Date(), updatedAt: new Date() }) .where(eq(workflow.id, id)) - // Sync MCP tools with the reverted version's parameter schema await syncMcpToolsForWorkflow({ workflowId: id, requestId, diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.ts b/apps/sim/app/api/workflows/[id]/form/status/route.ts new file mode 100644 index 0000000000..a14abe736f --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/form/status/route.ts @@ -0,0 +1,47 @@ +import { db } from '@sim/db' +import { form } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getSession } from '@/lib/auth' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('FormStatusAPI') + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const { id: workflowId } = await params + + const formResult = await db + .select({ + id: form.id, + identifier: form.identifier, + title: form.title, + isActive: form.isActive, + }) + .from(form) + .where(and(eq(form.workflowId, workflowId), eq(form.isActive, true))) + .limit(1) + + if (formResult.length === 0) { + return createSuccessResponse({ + isDeployed: false, + form: null, + }) + } + + return createSuccessResponse({ + isDeployed: true, + form: formResult[0], + }) + } catch (error: any) { + logger.error('Error fetching form status:', error) + return createErrorResponse(error.message || 'Failed to fetch form status', 500) + } +} diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 12ea444173..35f3d3473c 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -5,6 +5,7 @@ * @vitest-environment node */ +import { loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,14 +21,7 @@ vi.mock('@/lib/auth', () => ({ getSession: () => mockGetSession(), })) -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - })), -})) +vi.mock('@sim/logger', () => loggerMock) vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: (workflowId: string) => diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index f7e105d3c9..b2485fa408 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -207,9 +207,15 @@ describe('Workflow Variables API Route', () => { update: { results: [{}] }, }) - const variables = [ - { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, - ] + const variables = { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-123', + name: 'test', + type: 'string', + value: 'hello', + }, + } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { method: 'POST', @@ -242,9 +248,15 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - const variables = [ - { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, - ] + const variables = { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-123', + name: 'test', + type: 'string', + value: 'hello', + }, + } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { method: 'POST', @@ -277,7 +289,6 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - // Invalid data - missing required fields const invalidData = { variables: [{ name: 'test' }] } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index ec7d5d486f..f107f31748 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -11,16 +11,22 @@ import type { Variable } from '@/stores/panel/variables/types' const logger = createLogger('WorkflowVariablesAPI') +const VariableSchema = z.object({ + id: z.string(), + workflowId: z.string(), + name: z.string(), + type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), + value: z.union([ + z.string(), + z.number(), + z.boolean(), + z.record(z.unknown()), + z.array(z.unknown()), + ]), +}) + const VariablesSchema = z.object({ - variables: z.array( - z.object({ - id: z.string(), - workflowId: z.string(), - name: z.string(), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), - value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]), - }) - ), + variables: z.record(z.string(), VariableSchema), }) export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -60,21 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const { variables } = VariablesSchema.parse(body) - // Format variables for storage - const variablesRecord: Record = {} - variables.forEach((variable) => { - variablesRecord[variable.id] = variable - }) - - // Replace variables completely with the incoming ones + // Variables are already in Record format - use directly // The frontend is the source of truth for what variables should exist - const updatedVariables = variablesRecord - - // Update workflow with variables await db .update(workflow) .set({ - variables: updatedVariables, + variables, updatedAt: new Date(), }) .where(eq(workflow.id, workflowId)) @@ -148,8 +145,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: headers, } ) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Workflow variables fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: errorMessage }, { status: 500 }) } } diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index 0a43ea1849..926f96a064 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -460,43 +460,22 @@ export default function ChatClient({ identifier }: { identifier: string }) { ) if (error) { - return + return } if (authRequired) { - const title = new URLSearchParams(window.location.search).get('title') || 'chat' - const primaryColor = - new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)' + // const title = new URLSearchParams(window.location.search).get('title') || 'chat' + // const primaryColor = + // new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)' if (authRequired === 'password') { - return ( - - ) + return } if (authRequired === 'email') { - return ( - - ) + return } if (authRequired === 'sso') { - return ( - - ) + return } } diff --git a/apps/sim/app/chat/components/auth/email/email-auth.tsx b/apps/sim/app/chat/components/auth/email/email-auth.tsx index fb2a5d8036..d6ba3de532 100644 --- a/apps/sim/app/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/chat/components/auth/email/email-auth.tsx @@ -2,14 +2,16 @@ import { type KeyboardEvent, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' +import { Input } from '@/components/emcn' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' import { Label } from '@/components/ui/label' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { inter } from '@/app/_styles/fonts/inter/inter' import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import AuthBackground from '@/app/(auth)/components/auth-background' +import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { SupportFooter } from '@/app/(auth)/components/support-footer' import Nav from '@/app/(landing)/components/nav/nav' const logger = createLogger('EmailAuth') @@ -17,8 +19,6 @@ const logger = createLogger('EmailAuth') interface EmailAuthProps { identifier: string onAuthSuccess: () => void - title?: string - primaryColor?: string } const validateEmailField = (emailValue: string): string[] => { @@ -37,57 +37,19 @@ const validateEmailField = (emailValue: string): string[] => { return errors } -export default function EmailAuth({ - identifier, - onAuthSuccess, - title = 'chat', - primaryColor = 'var(--brand-primary-hover-hex)', -}: EmailAuthProps) { - // Email auth state +export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps) { const [email, setEmail] = useState('') const [authError, setAuthError] = useState(null) const [isSendingOtp, setIsSendingOtp] = useState(false) const [isVerifyingOtp, setIsVerifyingOtp] = useState(false) const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') - // OTP verification state const [showOtpVerification, setShowOtpVerification] = useState(false) const [otpValue, setOtpValue] = useState('') const [countdown, setCountdown] = useState(0) const [isResendDisabled, setIsResendDisabled] = useState(false) - useEffect(() => { - // Check if CSS variable has been customized - const checkCustomBrand = () => { - const computedStyle = getComputedStyle(document.documentElement) - const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() - - // Check if the CSS variable exists and is different from the default - if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') - } else { - setButtonClass('auth-button-gradient') - } - } - - checkCustomBrand() - - // Also check on window resize or theme changes - window.addEventListener('resize', checkCustomBrand) - const observer = new MutationObserver(checkCustomBrand) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style', 'class'], - }) - - return () => { - window.removeEventListener('resize', checkCustomBrand) - observer.disconnect() - } - }, []) - useEffect(() => { if (countdown > 0) { const timer = setTimeout(() => setCountdown(countdown - 1), 1000) @@ -98,7 +60,6 @@ export default function EmailAuth({ } }, [countdown, isResendDisabled]) - // Handle email input key down const handleEmailKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() @@ -109,21 +70,16 @@ export default function EmailAuth({ const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value setEmail(newEmail) - - // Silently validate but don't show errors until submit const errors = validateEmailField(newEmail) setEmailErrors(errors) setShowEmailValidationError(false) } - // Handle sending OTP const handleSendOtp = async () => { - // Validate email on submit const emailValidationErrors = validateEmailField(email) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) - // If there are validation errors, stop submission if (emailValidationErrors.length > 0) { return } @@ -217,7 +173,6 @@ export default function EmailAuth({ return } - // Don't show success message in error state, just reset OTP setOtpValue('') } catch (error) { logger.error('Error resending OTP:', error) @@ -230,36 +185,34 @@ export default function EmailAuth({ } return ( -
    -