mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 15:38:00 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be578e2ed7 | ||
|
|
baa54b4c97 | ||
|
|
a11d452d7b | ||
|
|
6262503b89 | ||
|
|
67440432bf | ||
|
|
47eb060311 | ||
|
|
fd76e98f0e | ||
|
|
1dbd16115f | ||
|
|
38e827b61a | ||
|
|
1f5e8a41f8 | ||
|
|
796f73ee01 | ||
|
|
d3d6012d5c | ||
|
|
860610b4c2 | ||
|
|
05bbf34265 | ||
|
|
753600ed60 | ||
|
|
4da43d937c | ||
|
|
9502227fd4 |
@@ -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' })
|
||||
```
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -173,13 +173,13 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts`
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
// Mocks BEFORE imports
|
||||
vi.mock('@sim/db', () => ({ db: { select: vi.fn() } }))
|
||||
|
||||
// Imports AFTER mocks
|
||||
import { databaseMock, loggerMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSession, loggerMock } from '@sim/testing'
|
||||
|
||||
vi.mock('@sim/db', () => databaseMock)
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -4575,3 +4575,22 @@ export function FirefliesIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<linearGradient id='bedrock_gradient' x1='80%' x2='20%' y1='20%' y2='80%'>
|
||||
<stop offset='0%' stopColor='#6350FB' />
|
||||
<stop offset='50%' stopColor='#3D8FFF' />
|
||||
<stop offset='100%' stopColor='#9AD8F8' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d='M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z'
|
||||
fill='url(#bedrock_gradient)'
|
||||
fillRule='nonzero'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,40 +49,40 @@ Die Modellaufschlüsselung zeigt:
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**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.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -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.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/router.png"
|
||||
alt="Router Block with Multiple Paths"
|
||||
alt="Router Block with Multiple Route Ports"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
@@ -32,21 +32,23 @@ The Router block uses AI to intelligently route workflows based on content analy
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Content/Prompt
|
||||
### Context
|
||||
|
||||
The content or prompt that the Router will analyze to make routing decisions. This can be:
|
||||
The context that the Router will analyze to make routing decisions. This is the input data that gets evaluated against your route descriptions. It can be:
|
||||
|
||||
- A direct user query or input
|
||||
- Output from a previous block
|
||||
- A system-generated message
|
||||
- Any text content that needs intelligent routing
|
||||
|
||||
### Target Blocks
|
||||
### Routes
|
||||
|
||||
The possible destination blocks that the Router can select from. The Router will automatically detect connected blocks, but you can also:
|
||||
Define the possible paths that the Router can take. Each route consists of:
|
||||
|
||||
- Customize the descriptions of target blocks to improve routing accuracy
|
||||
- Specify routing criteria for each target block
|
||||
- Exclude certain blocks from being considered as routing targets
|
||||
- **Route Title**: A name for the route (e.g., "Sales", "Support", "Technical")
|
||||
- **Route Description**: A clear description of when this route should be selected (e.g., "Route here when the query is about pricing, purchasing, or sales inquiries")
|
||||
|
||||
Each route you add creates a **separate output port** on the Router block. Connect each port to the appropriate downstream block for that route.
|
||||
|
||||
### Model Selection
|
||||
|
||||
@@ -66,8 +68,9 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
|
||||
## Outputs
|
||||
|
||||
- **`<router.prompt>`**: Summary of the routing prompt
|
||||
- **`<router.selected_path>`**: Chosen destination block
|
||||
- **`<router.context>`**: The context that was analyzed
|
||||
- **`<router.selectedRoute>`**: The ID of the selected route
|
||||
- **`<router.selected_path>`**: Details of the chosen destination block
|
||||
- **`<router.tokens>`**: Token usage statistics
|
||||
- **`<router.cost>`**: Estimated routing cost
|
||||
- **`<router.model>`**: 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
<Callout type="info">
|
||||
Users not assigned to any permission group have full access. Permission restrictions are enforced at both UI and execution time.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
For self-hosted deployments, enterprise features can be enabled via environment variables without requiring billing.
|
||||
|
||||
### 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 |
|
||||
|
||||
<Callout type="warn">
|
||||
BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
|
||||
</Callout>
|
||||
### 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.
|
||||
|
||||
@@ -48,40 +48,40 @@ The model breakdown shows:
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**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.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
136
apps/docs/content/docs/en/execution/form.mdx
Normal file
136
apps/docs/content/docs/en/execution/form.mdx
Normal file
@@ -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.
|
||||
|
||||
<Callout type="info">
|
||||
Forms derive their fields from your workflow's Start block Input Format. Each field becomes a form input with the appropriate type.
|
||||
</Callout>
|
||||
|
||||
## 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
|
||||
<iframe
|
||||
src="https://sim.ai/form/your-identifier"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
title="Form"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
## API Submission
|
||||
|
||||
Submit forms programmatically:
|
||||
|
||||
<Tabs items={['cURL', 'TypeScript']}>
|
||||
<Tab value="cURL">
|
||||
```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"
|
||||
}
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="TypeScript">
|
||||
```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: '...' } }
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### 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.
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "basics", "api", "logging", "costs"]
|
||||
"pages": ["index", "basics", "api", "form", "logging", "costs"]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Reference structured values downstream with expressions such as <code><start.
|
||||
|
||||
## How it behaves per entry point
|
||||
|
||||
<Tabs items={['Editor run', 'Deploy to API', 'Deploy to chat']}>
|
||||
<Tabs items={['Editor run', 'Deploy to API', 'Deploy to chat', 'Deploy to form']}>
|
||||
<Tab>
|
||||
When you click <strong>Run</strong> 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 <code><start.fieldName></code> (for example <code><start.sampleField></code>).
|
||||
|
||||
@@ -64,6 +64,13 @@ Reference structured values downstream with expressions such as <code><start.
|
||||
|
||||
If you launch chat with additional structured context (for example from an embed), it merges into the corresponding <code><start.fieldName></code> outputs, keeping downstream blocks consistent with API and manual runs.
|
||||
</Tab>
|
||||
<Tab>
|
||||
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 <code><start.fieldName></code> just like other entry points. The workflow executes with trigger type <code>form</code>, 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.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Referencing Start data downstream
|
||||
|
||||
@@ -49,40 +49,40 @@ El desglose del modelo muestra:
|
||||
|
||||
<Tabs items={['Modelos alojados', 'Trae tu propia clave API']}>
|
||||
<Tab>
|
||||
**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.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -49,40 +49,40 @@ La répartition des modèles montre :
|
||||
|
||||
<Tabs items={['Modèles hébergés', 'Apportez votre propre clé API']}>
|
||||
<Tab>
|
||||
**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.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -47,42 +47,42 @@ AIブロックを使用するワークフローでは、ログで詳細なコス
|
||||
|
||||
## 料金オプション
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tabs items={['ホステッドモデル', '独自のAPIキーを使用']}>
|
||||
<Tab>
|
||||
**ホステッドモデル** - 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管理のコストをカバーします。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -47,42 +47,42 @@ totalCost = baseExecutionCharge + modelCost
|
||||
|
||||
## 定价选项
|
||||
|
||||
<Tabs items={[ '托管模型', '自带 API 密钥' ]}>
|
||||
<Tabs items={['托管模型', '自带 API Key']}>
|
||||
<Tab>
|
||||
**托管模型** - 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 管理成本。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 11 KiB |
100
apps/sim/app/(auth)/components/branded-button.tsx
Normal file
100
apps/sim/app/(auth)/components/branded-button.tsx
Normal file
@@ -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<EmcnButtonProps, 'variant' | 'size'> {
|
||||
/** 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
|
||||
* <BrandedButton onClick={handleSubmit}>Sign In</BrandedButton>
|
||||
*
|
||||
* // Loading state
|
||||
* <BrandedButton loading loadingText="Signing in">Sign In</BrandedButton>
|
||||
*
|
||||
* // Without arrow animation
|
||||
* <BrandedButton showArrow={false}>Continue</BrandedButton>
|
||||
* ```
|
||||
*/
|
||||
export const BrandedButton = forwardRef<HTMLButtonElement, BrandedButtonProps>(
|
||||
(
|
||||
{
|
||||
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<HTMLButtonElement>) => {
|
||||
setIsHovered(true)
|
||||
onMouseEnter?.(e)
|
||||
}
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsHovered(false)
|
||||
onMouseLeave?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant='branded'
|
||||
size='branded'
|
||||
disabled={disabled || loading}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(buttonClass, 'group', fullWidth && 'w-full', className)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
{loadingText ? `${loadingText}...` : children}
|
||||
</span>
|
||||
) : showArrow ? (
|
||||
<span className='flex items-center gap-1'>
|
||||
{children}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
BrandedButton.displayName = 'BrandedButton'
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
74
apps/sim/app/(auth)/components/status-page-layout.tsx
Normal file
74
apps/sim/app/(auth)/components/status-page-layout.tsx
Normal file
@@ -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
|
||||
* <StatusPageLayout
|
||||
* title="Page Not Found"
|
||||
* description="The page you're looking for doesn't exist."
|
||||
* >
|
||||
* <BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
* </StatusPageLayout>
|
||||
* ```
|
||||
*/
|
||||
export function StatusPageLayout({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
showSupportFooter = true,
|
||||
hideNav = false,
|
||||
}: StatusPageLayoutProps) {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
{!hideNav && <Nav hideAuthButtons={true} variant='auth' />}
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showSupportFooter && <SupportFooter position='absolute' />}
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
40
apps/sim/app/(auth)/components/support-footer.tsx
Normal file
40
apps/sim/app/(auth)/components/support-footer.tsx
Normal file
@@ -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)
|
||||
* <SupportFooter />
|
||||
*
|
||||
* // Absolute position (for pages using AuthLayout)
|
||||
* <SupportFooter position="absolute" />
|
||||
* ```
|
||||
*/
|
||||
export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed ${position}`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function SSOForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
|
||||
</ul>
|
||||
<p>We will respond to your request within a reasonable timeframe.</p>
|
||||
</section>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<link rel='canonical' href='https://sim.ai/studio' />
|
||||
<link
|
||||
rel='alternate'
|
||||
type='application/rss+xml'
|
||||
title='Sim Studio'
|
||||
href='https://sim.ai/studio/rss.xml'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<AppSession>(null)
|
||||
const [isPending, setIsPending] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(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') {
|
||||
|
||||
@@ -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 (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
defaultTheme='dark'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
|
||||
@@ -42,6 +42,40 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow selection box styling
|
||||
* Uses brand-secondary color for selection highlighting
|
||||
*/
|
||||
.react-flow__selection {
|
||||
background: rgba(51, 180, 255, 0.08) !important;
|
||||
border: 1px solid var(--brand-secondary) !important;
|
||||
}
|
||||
|
||||
.react-flow__nodesselection-rect,
|
||||
.react-flow__nodesselection {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
*/
|
||||
.react-flow__node.selected > 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, unknown>) ?? undefined,
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
414
apps/sim/app/api/form/[identifier]/route.ts
Normal file
414
apps/sim/app/api/form/[identifier]/route.ts
Normal file
@@ -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<any[]> {
|
||||
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<string, any> | 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<string, unknown>,
|
||||
}
|
||||
|
||||
// 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<string, any> | 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)
|
||||
}
|
||||
233
apps/sim/app/api/form/manage/[id]/route.ts
Normal file
233
apps/sim/app/api/form/manage/[id]/route.ts
Normal file
@@ -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<string, any> = {
|
||||
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<string, any>) || {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
214
apps/sim/app/api/form/route.ts
Normal file
214
apps/sim/app/api/form/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
367
apps/sim/app/api/form/utils.test.ts
Normal file
367
apps/sim/app/api/form/utils.test.ts
Normal file
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
204
apps/sim/app/api/form/utils.ts
Normal file
204
apps/sim/app/api/form/utils.ts
Normal file
@@ -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.',
|
||||
}
|
||||
71
apps/sim/app/api/form/validate/route.ts
Normal file
71
apps/sim/app/api/form/validate/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
166
apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts
Normal file
166
apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
229
apps/sim/app/api/permission-groups/[id]/members/route.ts
Normal file
229
apps/sim/app/api/permission-groups/[id]/members/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
212
apps/sim/app/api/permission-groups/[id]/route.ts
Normal file
212
apps/sim/app/api/permission-groups/[id]/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
185
apps/sim/app/api/permission-groups/route.ts
Normal file
185
apps/sim/app/api/permission-groups/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
72
apps/sim/app/api/permission-groups/user/route.ts
Normal file
72
apps/sim/app/api/permission-groups/user/route.ts
Normal file
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<WorkflowState> = {
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
169
apps/sim/app/api/v1/admin/access-control/route.ts
Normal file
169
apps/sim/app/api/v1/admin/access-control/route.ts
Normal file
@@ -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<number>`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')
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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<RouteParams>(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) {
|
||||
|
||||
@@ -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<RouteParams>(async (request, context) =
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(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
|
||||
|
||||
@@ -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<RouteParams>(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(
|
||||
|
||||
@@ -8,14 +8,32 @@
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminOrganization>
|
||||
*
|
||||
* 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<AdminOrganization & { memberId: string }>
|
||||
*/
|
||||
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -243,7 +243,7 @@ export interface WorkflowExportState {
|
||||
color?: string
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: WorkflowVariable[]
|
||||
variables?: Record<string, WorkflowVariable>
|
||||
}
|
||||
|
||||
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<string, Variable> formats.
|
||||
* Parse workflow variables from database JSON format to Record format.
|
||||
* Handles both legacy Array and current Record<string, Variable> formats.
|
||||
*/
|
||||
export function parseWorkflowVariables(
|
||||
dbVariables: DbWorkflow['variables']
|
||||
): WorkflowVariable[] | undefined {
|
||||
): Record<string, WorkflowVariable> | 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<string, WorkflowVariable> = {}
|
||||
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<string, WorkflowVariable> = {}
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
apps/sim/app/api/workflows/[id]/form/status/route.ts
Normal file
47
apps/sim/app/api/workflows/[id]/form/status/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<string, Variable> = {}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,43 +460,22 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return <ChatErrorState error={error} starCount={starCount} />
|
||||
return <ChatErrorState error={error} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<PasswordAuth
|
||||
identifier={identifier}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
/>
|
||||
)
|
||||
return <PasswordAuth identifier={identifier} onAuthSuccess={handleAuthSuccess} />
|
||||
}
|
||||
if (authRequired === 'email') {
|
||||
return (
|
||||
<EmailAuth
|
||||
identifier={identifier}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
/>
|
||||
)
|
||||
return <EmailAuth identifier={identifier} onAuthSuccess={handleAuthSuccess} />
|
||||
}
|
||||
if (authRequired === 'sso') {
|
||||
return (
|
||||
<SSOAuth
|
||||
identifier={identifier}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
/>
|
||||
)
|
||||
return <SSOAuth identifier={identifier} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [isSendingOtp, setIsSendingOtp] = useState(false)
|
||||
const [isVerifyingOtp, setIsVerifyingOtp] = useState(false)
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -109,21 +70,16 @@ export default function EmailAuth({
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className='bg-white'>
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Header */}
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{showOtpVerification
|
||||
? `A verification code has been sent to ${email}`
|
||||
: 'This chat requires email verification'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className={`${inter.className} mt-8 w-full`}>
|
||||
{!showOtpVerification ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSendOtp()
|
||||
}}
|
||||
className='space-y-8'
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{showOtpVerification
|
||||
? `A verification code has been sent to ${email}`
|
||||
: 'This chat requires email verification'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px]`}>
|
||||
{!showOtpVerification ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSendOtp()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
@@ -291,18 +244,12 @@ export default function EmailAuth({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isSendingOtp}
|
||||
>
|
||||
{isSendingOtp ? 'Sending Code...' : 'Continue'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className='space-y-8'>
|
||||
<BrandedButton type='submit' loading={isSendingOtp} loadingText='Sending Code'>
|
||||
Continue
|
||||
</BrandedButton>
|
||||
</form>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-muted-foreground text-sm'>
|
||||
Enter the 6-digit code to verify your account. If you don't see it in your
|
||||
@@ -340,60 +287,61 @@ export default function EmailAuth({
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{authError && (
|
||||
<div className='mt-1 space-y-1 text-center text-red-400 text-xs'>
|
||||
<p>{authError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleVerifyOtp()}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={otpValue.length !== 6 || isVerifyingOtp}
|
||||
>
|
||||
{isVerifyingOtp ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in{' '}
|
||||
<span className='font-medium text-foreground'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResendOtp}
|
||||
disabled={isVerifyingOtp || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center font-light text-[14px]'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowOtpVerification(false)
|
||||
setOtpValue('')
|
||||
setAuthError(null)
|
||||
}}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
<BrandedButton
|
||||
onClick={() => handleVerifyOtp()}
|
||||
disabled={otpValue.length !== 6}
|
||||
loading={isVerifyingOtp}
|
||||
loadingText='Verifying'
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
Verify Email
|
||||
</BrandedButton>
|
||||
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in{' '}
|
||||
<span className='font-medium text-foreground'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResendOtp}
|
||||
disabled={isVerifyingOtp || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center font-light text-[14px]'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowOtpVerification(false)
|
||||
setOtpValue('')
|
||||
setAuthError(null)
|
||||
}}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { type KeyboardEvent, useEffect, useState } from 'react'
|
||||
import { type KeyboardEvent, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
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('PasswordAuth')
|
||||
@@ -16,56 +18,15 @@ const logger = createLogger('PasswordAuth')
|
||||
interface PasswordAuthProps {
|
||||
identifier: string
|
||||
onAuthSuccess: () => void
|
||||
title?: string
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
export default function PasswordAuth({
|
||||
identifier,
|
||||
onAuthSuccess,
|
||||
title = 'chat',
|
||||
primaryColor = 'var(--brand-primary-hover-hex)',
|
||||
}: PasswordAuthProps) {
|
||||
// Password auth state
|
||||
export default function PasswordAuth({ identifier, onAuthSuccess }: PasswordAuthProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
const [isAuthenticating, setIsAuthenticating] = 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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle keyboard input for auth forms
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -80,7 +41,6 @@ export default function PasswordAuth({
|
||||
setPasswordErrors([])
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
const handleAuthenticate = async () => {
|
||||
if (!password.trim()) {
|
||||
setPasswordErrors(['Password is required'])
|
||||
@@ -88,7 +48,6 @@ export default function PasswordAuth({
|
||||
return
|
||||
}
|
||||
|
||||
setAuthError(null)
|
||||
setIsAuthenticating(true)
|
||||
|
||||
try {
|
||||
@@ -111,10 +70,7 @@ export default function PasswordAuth({
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication successful, notify parent
|
||||
onAuthSuccess()
|
||||
|
||||
// Reset auth state
|
||||
setPassword('')
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error)
|
||||
@@ -126,32 +82,30 @@ export default function PasswordAuth({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-white'>
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Header */}
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
Password Required
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
This chat is password-protected
|
||||
</p>
|
||||
</div>
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
Password Required
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
This chat is password-protected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className={`${inter.className} mt-8 w-full space-y-8`}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
@@ -194,19 +148,21 @@ export default function PasswordAuth({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isAuthenticating}
|
||||
>
|
||||
{isAuthenticating ? 'Authenticating...' : 'Continue'}
|
||||
</Button>
|
||||
</form>
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={!password.trim()}
|
||||
loading={isAuthenticating}
|
||||
loadingText='Authenticating'
|
||||
>
|
||||
Continue
|
||||
</BrandedButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { type KeyboardEvent, useEffect, useState } from 'react'
|
||||
import { type KeyboardEvent, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Input } from '@/components/emcn'
|
||||
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('SSOAuth')
|
||||
|
||||
interface SSOAuthProps {
|
||||
identifier: string
|
||||
onAuthSuccess: () => void
|
||||
title?: string
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
const validateEmailField = (emailValue: string): string[] => {
|
||||
@@ -37,46 +36,13 @@ const validateEmailField = (emailValue: string): string[] => {
|
||||
return errors
|
||||
}
|
||||
|
||||
export default function SSOAuth({
|
||||
identifier,
|
||||
onAuthSuccess,
|
||||
title = 'chat',
|
||||
primaryColor = 'var(--brand-primary-hover-hex)',
|
||||
}: SSOAuthProps) {
|
||||
export default function SSOAuth({ identifier }: SSOAuthProps) {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -133,32 +99,30 @@ export default function SSOAuth({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-white'>
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Header */}
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
SSO Authentication
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
This chat requires SSO authentication
|
||||
</p>
|
||||
</div>
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
SSO Authentication
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
This chat requires SSO authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className={`${inter.className} mt-8 w-full space-y-8`}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Work Email</Label>
|
||||
@@ -191,19 +155,16 @@ export default function SSOAuth({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redirecting to SSO...' : 'Continue with SSO'}
|
||||
</Button>
|
||||
</form>
|
||||
<BrandedButton type='submit' loading={isLoading} loadingText='Redirecting to SSO'>
|
||||
Continue with SSO
|
||||
</BrandedButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
export default function MarkdownRenderer({ content }: { content: string }) {
|
||||
const customComponents = {
|
||||
// Paragraph
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mt-0.5 mb-1 text-base leading-normal'>{children}</p>
|
||||
),
|
||||
|
||||
// Headings
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-3 mb-1 font-semibold text-xl'>{children}</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className='mt-3 mb-1 font-semibold text-lg'>{children}</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className='mt-3 mb-1 font-semibold text-base'>{children}</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className='mt-3 mb-1 font-semibold text-sm'>{children}</h4>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul className='my-1 list-disc space-y-0.5 pl-5'>{children}</ul>
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol className='my-1 list-decimal space-y-0.5 pl-5'>{children}</ol>
|
||||
),
|
||||
li: ({ children }: React.HTMLAttributes<HTMLLIElement>) => (
|
||||
<li className='text-base'>{children}</li>
|
||||
),
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => (
|
||||
<pre className='my-2 overflow-x-auto rounded-md bg-gray-100 p-3 font-mono text-sm dark:bg-gray-800'>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
|
||||
// Inline code
|
||||
code: ({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className='rounded-md bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] dark:bg-gray-800'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract language from className (format: language-xxx)
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match ? match[1] : ''
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
{language && (
|
||||
<div className='absolute top-1 right-2 text-gray-500 text-xs dark:text-gray-400'>
|
||||
{language}
|
||||
</div>
|
||||
)}
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-2 border-gray-200 border-l-4 py-0 pl-4 text-gray-700 italic dark:border-gray-700 dark:text-gray-300'>
|
||||
<div className='flex items-center py-0'>{children}</div>
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className='my-3 border-gray-200 dark:border-gray-700' />,
|
||||
|
||||
// Links
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<a
|
||||
href={href}
|
||||
className='text-blue-600 hover:underline dark:text-blue-400'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-2 overflow-x-auto rounded-md border border-gray-200 dark:border-gray-700'>
|
||||
<table className='w-full border-collapse'>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<thead className='border-gray-200 border-b bg-gray-50 dark:border-gray-700 dark:bg-gray-800'>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<tbody className='divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900'>
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr className='transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/60' {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th className='px-4 py-3 text-left font-medium text-gray-500 text-xs uppercase tracking-wider dark:text-gray-300'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td className='border-0 px-4 py-3 text-sm'>{children}</td>
|
||||
),
|
||||
|
||||
// Images
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || 'Image'}
|
||||
className='my-2 h-auto max-w-full rounded-md'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// Process text to clean up unnecessary whitespace and formatting issues
|
||||
const processedContent = content
|
||||
.replace(/\n{2,}/g, '\n\n') // Replace multiple newlines with exactly double newlines
|
||||
.replace(/^(#{1,6})\s+(.+?)\n{2,}/gm, '$1 $2\n') // Reduce space after headings to single newline
|
||||
.trim()
|
||||
|
||||
return (
|
||||
<div className='text-[#0D0D0D] text-base leading-normal dark:text-gray-100'>
|
||||
<ReactMarkdown components={customComponents}>{processedContent}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,95 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
|
||||
interface ChatErrorStateProps {
|
||||
error: string
|
||||
starCount: string
|
||||
}
|
||||
|
||||
export function ChatErrorState({ error, starCount }: ChatErrorStateProps) {
|
||||
export function ChatErrorState({ error }: ChatErrorStateProps) {
|
||||
const router = useRouter()
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-white'>
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Error content */}
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
Chat Unavailable
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action button - matching login form */}
|
||||
<div className='mt-8 w-full'>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => router.push('/workspace')}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
>
|
||||
Return to Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPageLayout title='Chat Unavailable' description={error}>
|
||||
<BrandedButton onClick={() => router.push('/workspace')}>Return to Workspace</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,12 +221,10 @@ export default function CredentialAccountInvitePage() {
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -260,7 +258,6 @@ export default function CredentialAccountInvitePage() {
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
19
apps/sim/app/form/[identifier]/components/error-state.tsx
Normal file
19
apps/sim/app/form/[identifier]/components/error-state.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
|
||||
interface FormErrorStateProps {
|
||||
error: string
|
||||
}
|
||||
|
||||
export function FormErrorState({ error }: FormErrorStateProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<StatusPageLayout title='Form Unavailable' description={error} hideNav>
|
||||
<BrandedButton onClick={() => router.push('/workspace')}>Return to Workspace</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
)
|
||||
}
|
||||
227
apps/sim/app/form/[identifier]/components/form-field.tsx
Normal file
227
apps/sim/app/form/[identifier]/components/form-field.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import { Input, Label, Switch, Textarea } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
interface InputField {
|
||||
name: string
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
description?: string
|
||||
value?: unknown
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
field: InputField
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
primaryColor?: string
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
primaryColor,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
}: FormFieldProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const formatLabel = (name: string) => {
|
||||
return name
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
const displayLabel = label || formatLabel(field.name)
|
||||
const placeholder = description || field.description || ''
|
||||
const isRequired = required ?? field.required
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) {
|
||||
onChange(files)
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length > 0) {
|
||||
onChange(files)
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const removeFile = useCallback(
|
||||
(index: number) => {
|
||||
if (Array.isArray(value)) {
|
||||
const newFiles = value.filter((_, i) => i !== index)
|
||||
onChange(newFiles.length > 0 ? newFiles : undefined)
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
)
|
||||
|
||||
const renderInput = () => {
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className='flex items-center gap-3'>
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
style={value ? { backgroundColor: primaryColor } : undefined}
|
||||
/>
|
||||
<span className={`${inter.className} text-[14px] text-muted-foreground`}>
|
||||
{value ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type='number'
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
onChange(val === '' ? '' : Number(val))
|
||||
}}
|
||||
placeholder={placeholder || 'Enter a number'}
|
||||
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
|
||||
/>
|
||||
)
|
||||
|
||||
case 'object':
|
||||
case 'array':
|
||||
return (
|
||||
<Textarea
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
placeholder || (field.type === 'array' ? '["item1", "item2"]' : '{"key": "value"}')
|
||||
}
|
||||
className='min-h-[100px] rounded-[10px] font-mono text-[13px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
|
||||
/>
|
||||
)
|
||||
|
||||
case 'files': {
|
||||
const files = Array.isArray(value) ? (value as File[]) : []
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center rounded-[10px] border-2 border-dashed px-6 py-8 transition-colors',
|
||||
isDragging
|
||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
||||
: 'border-border hover:border-muted-foreground/50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className='hidden'
|
||||
/>
|
||||
<Upload
|
||||
className='mb-2 h-6 w-6 text-muted-foreground'
|
||||
style={isDragging ? { color: primaryColor } : undefined}
|
||||
/>
|
||||
<p className={`${inter.className} text-center text-[14px] text-muted-foreground`}>
|
||||
<span style={{ color: primaryColor }} className='font-medium'>
|
||||
Click to upload
|
||||
</span>{' '}
|
||||
or drag and drop
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
{files.map((file, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className='flex items-center justify-between rounded-[8px] border border-border bg-muted/30 px-3 py-2'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p
|
||||
className={`${inter.className} truncate font-medium text-[13px] text-foreground`}
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className={`${inter.className} text-[12px] text-muted-foreground`}>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(idx)
|
||||
}}
|
||||
className='ml-2 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type='text'
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || 'Enter text'}
|
||||
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label className={`${inter.className} font-medium text-[14px] text-foreground`}>
|
||||
{displayLabel}
|
||||
{isRequired && <span className='ml-0.5 text-[var(--text-error)]'>*</span>}
|
||||
</Label>
|
||||
{renderInput()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
apps/sim/app/form/[identifier]/components/index.ts
Normal file
6
apps/sim/app/form/[identifier]/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { FormErrorState } from './error-state'
|
||||
export { FormField } from './form-field'
|
||||
export { FormLoadingState } from './loading-state'
|
||||
export { PasswordAuth } from './password-auth'
|
||||
export { PoweredBySim } from './powered-by-sim'
|
||||
export { ThankYouScreen } from './thank-you-screen'
|
||||
37
apps/sim/app/form/[identifier]/components/loading-state.tsx
Normal file
37
apps/sim/app/form/[identifier]/components/loading-state.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
|
||||
export function FormLoadingState() {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Title skeleton */}
|
||||
<div className='space-y-2 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-32' />
|
||||
<Skeleton className='mx-auto h-4 w-48' />
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className='mt-8 w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
105
apps/sim/app/form/[identifier]/components/password-auth.tsx
Normal file
105
apps/sim/app/form/[identifier]/components/password-auth.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
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'
|
||||
|
||||
interface PasswordAuthProps {
|
||||
onSubmit: (password: string) => void
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(password)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
Password Required
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter the password to access this form.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<label
|
||||
htmlFor='form-password'
|
||||
className='font-medium text-[14px] text-foreground'
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='form-password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder='Enter password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
error && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
{showPassword ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className='text-[14px] text-red-500'>{error}</p>}
|
||||
</div>
|
||||
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
disabled={!password.trim()}
|
||||
loading={isSubmitting}
|
||||
loadingText='Verifying'
|
||||
>
|
||||
Continue
|
||||
</BrandedButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
31
apps/sim/app/form/[identifier]/components/powered-by-sim.tsx
Normal file
31
apps/sim/app/form/[identifier]/components/powered-by-sim.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
|
||||
export function PoweredBySim() {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
<a
|
||||
href='https://sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1.5 transition hover:opacity-80'
|
||||
>
|
||||
<span>Powered by</span>
|
||||
<Image
|
||||
src='/logo/b&w/text/small.png'
|
||||
alt='Sim'
|
||||
width={30}
|
||||
height={15}
|
||||
className='h-[14px] w-auto'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
|
||||
interface ThankYouScreenProps {
|
||||
title: string
|
||||
message: string
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
/** Default green color matching --brand-tertiary-2 */
|
||||
const DEFAULT_THANK_YOU_COLOR = '#32bd7e'
|
||||
|
||||
/** Legacy blue default that should be treated as "no custom color" */
|
||||
const LEGACY_BLUE_DEFAULT = '#3972F6'
|
||||
|
||||
export function ThankYouScreen({ title, message, primaryColor }: ThankYouScreenProps) {
|
||||
// Treat legacy blue default as no custom color, fall back to green
|
||||
const thankYouColor =
|
||||
primaryColor && primaryColor !== LEGACY_BLUE_DEFAULT ? primaryColor : DEFAULT_THANK_YOU_COLOR
|
||||
|
||||
return (
|
||||
<main className='flex flex-1 flex-col items-center justify-center p-4'>
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<div
|
||||
className='flex h-20 w-20 items-center justify-center rounded-full'
|
||||
style={{ backgroundColor: `${thankYouColor}15` }}
|
||||
>
|
||||
<CheckCircle2 className='h-10 w-10' style={{ color: thankYouColor }} />
|
||||
</div>
|
||||
<h2
|
||||
className={`${soehne.className} mt-6 font-medium text-[32px] tracking-tight`}
|
||||
style={{ color: thankYouColor }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={`${inter.className} mt-3 max-w-md font-[380] text-[16px] text-muted-foreground`}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
29
apps/sim/app/form/[identifier]/error.tsx
Normal file
29
apps/sim/app/form/[identifier]/error.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
|
||||
const logger = createLogger('FormError')
|
||||
|
||||
interface FormErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export default function FormError({ error, reset }: FormErrorProps) {
|
||||
useEffect(() => {
|
||||
logger.error('Form page error:', { error: error.message, digest: error.digest })
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<StatusPageLayout
|
||||
title='Something went wrong'
|
||||
description='We encountered an error loading this form. Please try again.'
|
||||
hideNav
|
||||
>
|
||||
<BrandedButton onClick={reset}>Try again</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
)
|
||||
}
|
||||
343
apps/sim/app/form/[identifier]/form.tsx
Normal file
343
apps/sim/app/form/[identifier]/form.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
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 {
|
||||
FormErrorState,
|
||||
FormField,
|
||||
FormLoadingState,
|
||||
PasswordAuth,
|
||||
PoweredBySim,
|
||||
ThankYouScreen,
|
||||
} from '@/app/form/[identifier]/components'
|
||||
|
||||
const logger = createLogger('Form')
|
||||
|
||||
interface FieldConfig {
|
||||
name: string
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface FormConfig {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
customizations: {
|
||||
primaryColor?: string
|
||||
thankYouMessage?: string
|
||||
logoUrl?: string
|
||||
fieldConfigs?: FieldConfig[]
|
||||
}
|
||||
authType?: 'public' | 'password' | 'email'
|
||||
showBranding?: boolean
|
||||
inputSchema?: InputField[]
|
||||
}
|
||||
|
||||
interface InputField {
|
||||
name: string
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
|
||||
description?: string
|
||||
value?: unknown
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export default function Form({ identifier }: { identifier: string }) {
|
||||
const [formConfig, setFormConfig] = useState<FormConfig | null>(null)
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null)
|
||||
const [thankYouData, setThankYouData] = useState<{
|
||||
title: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const fetchFormConfig = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/form/${identifier}`, { signal })
|
||||
if (signal?.aborted) return
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
const authError = data.error
|
||||
if (authError === 'auth_required_password') {
|
||||
setAuthRequired('password')
|
||||
setFormConfig({
|
||||
id: '',
|
||||
title: data.title || 'Form',
|
||||
customizations: data.customizations || {},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (authError === 'auth_required_email') {
|
||||
setAuthRequired('email')
|
||||
setFormConfig({
|
||||
id: '',
|
||||
title: data.title || 'Form',
|
||||
customizations: data.customizations || {},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error(data.error || 'Failed to load form')
|
||||
}
|
||||
|
||||
setFormConfig(data)
|
||||
setAuthRequired(null)
|
||||
|
||||
// Initialize form data from input schema
|
||||
const fields = data.inputSchema || []
|
||||
if (fields.length > 0) {
|
||||
const initialData: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
if (field.value !== undefined) {
|
||||
initialData[field.name] = field.value
|
||||
} else {
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
initialData[field.name] = false
|
||||
break
|
||||
case 'number':
|
||||
initialData[field.name] = ''
|
||||
break
|
||||
case 'array':
|
||||
case 'files':
|
||||
initialData[field.name] = []
|
||||
break
|
||||
case 'object':
|
||||
initialData[field.name] = {}
|
||||
break
|
||||
default:
|
||||
initialData[field.name] = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
setFormData(initialData)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
logger.error('Error fetching form config:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load form')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[identifier]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
abortControllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
fetchFormConfig(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [fetchFormConfig])
|
||||
|
||||
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formConfig) return
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/form/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ formData }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to submit form')
|
||||
}
|
||||
|
||||
setThankYouData({
|
||||
title: data.thankYouTitle || 'Thank you!',
|
||||
message:
|
||||
data.thankYouMessage ||
|
||||
formConfig.customizations.thankYouMessage ||
|
||||
'Your response has been submitted successfully.',
|
||||
})
|
||||
setIsSubmitted(true)
|
||||
} catch (err: unknown) {
|
||||
logger.error('Error submitting form:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit form')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[identifier, formConfig, formData]
|
||||
)
|
||||
|
||||
const handlePasswordAuth = useCallback(
|
||||
async (password: string) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/form/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Invalid password')
|
||||
}
|
||||
|
||||
await fetchFormConfig()
|
||||
} catch (err: unknown) {
|
||||
logger.error('Error authenticating:', err)
|
||||
setError(err instanceof Error ? err.message : 'Invalid password')
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[identifier, fetchFormConfig]
|
||||
)
|
||||
|
||||
const primaryColor = formConfig?.customizations?.primaryColor || 'var(--brand-primary-hex)'
|
||||
|
||||
if (isLoading && !authRequired) {
|
||||
return <FormLoadingState />
|
||||
}
|
||||
|
||||
if (error && !authRequired) {
|
||||
return <FormErrorState error={error} />
|
||||
}
|
||||
|
||||
if (authRequired === 'password') {
|
||||
return <PasswordAuth onSubmit={handlePasswordAuth} error={error} />
|
||||
}
|
||||
|
||||
if (isSubmitted && thankYouData) {
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<ThankYouScreen
|
||||
title={thankYouData.title}
|
||||
message={thankYouData.message}
|
||||
primaryColor={formConfig?.customizations?.primaryColor}
|
||||
/>
|
||||
</div>
|
||||
{formConfig?.showBranding !== false ? (
|
||||
<PoweredBySim />
|
||||
) : (
|
||||
<SupportFooter position='absolute' />
|
||||
)}
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
if (!formConfig) {
|
||||
return <FormErrorState error='Form not found' />
|
||||
}
|
||||
|
||||
// Get fields from input schema
|
||||
const fields = formConfig.inputSchema || []
|
||||
|
||||
// Create a map of field configs for quick lookup
|
||||
const fieldConfigMap = new Map(
|
||||
(formConfig.customizations?.fieldConfigs || []).map((fc) => [fc.name, fc])
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<div className='relative z-30 flex flex-1 justify-center px-4 pt-16 pb-24'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
{/* Form title */}
|
||||
<div className='mb-8 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[28px] text-foreground tracking-tight`}
|
||||
>
|
||||
{formConfig.title}
|
||||
</h1>
|
||||
{formConfig.description && (
|
||||
<p
|
||||
className={`${inter.className} mt-2 font-[380] text-[15px] text-muted-foreground`}
|
||||
>
|
||||
{formConfig.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={`${inter.className} space-y-6`}>
|
||||
{fields.length === 0 ? (
|
||||
<div className='rounded-[10px] border border-border bg-muted/50 p-6 text-center text-muted-foreground'>
|
||||
This form has no fields configured.
|
||||
</div>
|
||||
) : (
|
||||
fields.map((field) => {
|
||||
const config = fieldConfigMap.get(field.name)
|
||||
return (
|
||||
<FormField
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={formData[field.name]}
|
||||
onChange={(value) => handleFieldChange(field.name, value)}
|
||||
primaryColor={primaryColor}
|
||||
label={config?.label}
|
||||
description={config?.description}
|
||||
required={config?.required}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-4)] p-3 text-red-500 text-sm'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.length > 0 && (
|
||||
<BrandedButton
|
||||
type='submit'
|
||||
loading={isSubmitting}
|
||||
loadingText='Submitting...'
|
||||
fullWidth
|
||||
>
|
||||
Submit
|
||||
</BrandedButton>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{formConfig.showBranding !== false ? (
|
||||
<PoweredBySim />
|
||||
) : (
|
||||
<SupportFooter position='absolute' />
|
||||
)}
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
6
apps/sim/app/form/[identifier]/page.tsx
Normal file
6
apps/sim/app/form/[identifier]/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import Form from '@/app/form/[identifier]/form'
|
||||
|
||||
export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) {
|
||||
const { identifier } = await params
|
||||
return <Form identifier={identifier} />
|
||||
}
|
||||
@@ -400,7 +400,6 @@ export default function Invite() {
|
||||
label: 'I already have an account',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
@@ -413,7 +412,6 @@ export default function Invite() {
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
@@ -454,12 +452,10 @@ export default function Invite() {
|
||||
{
|
||||
label: 'Manage Team Settings',
|
||||
onClick: () => router.push('/workspace'),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -483,12 +479,10 @@ export default function Invite() {
|
||||
await client.signOut()
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
|
||||
},
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -509,17 +503,14 @@ export default function Invite() {
|
||||
{
|
||||
label: 'Sign in to continue',
|
||||
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -531,21 +522,18 @@ export default function Invite() {
|
||||
const actions: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
}> = []
|
||||
|
||||
if (error.canRetry) {
|
||||
actions.push({
|
||||
label: 'Try Again',
|
||||
onClick: () => window.location.reload(),
|
||||
variant: 'default' as const,
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: error.canRetry ? ('ghost' as const) : ('default' as const),
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -601,7 +589,6 @@ export default function Invite() {
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,9 @@ export default function InviteLayout({ children }: InviteLayoutProps) {
|
||||
<main className='relative flex min-h-screen flex-col text-foreground'>
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowRight, ChevronRight, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
|
||||
@@ -16,7 +15,6 @@ interface InviteStatusCardProps {
|
||||
actions?: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>
|
||||
@@ -32,8 +30,6 @@ export function InviteStatusCard({
|
||||
isExpiredError = false,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter()
|
||||
const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null)
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
if (type === 'loading') {
|
||||
return (
|
||||
@@ -49,17 +45,7 @@ export function InviteStatusCard({
|
||||
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</div>
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -75,77 +61,25 @@ export function InviteStatusCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Button>
|
||||
)}
|
||||
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
|
||||
{isExpiredError && (
|
||||
<BrandedButton onClick={() => router.push('/')}>Request New Invitation</BrandedButton>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => {
|
||||
const isPrimary = (action.variant || 'default') === 'default'
|
||||
const isHovered = hoveredButtonIndex === index
|
||||
|
||||
if (isPrimary) {
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
onMouseEnter={() => setHoveredButtonIndex(index)}
|
||||
onMouseLeave={() => setHoveredButtonIndex(null)}
|
||||
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{action.loading ? `${action.label}...` : action.label}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant}
|
||||
className={
|
||||
action.variant === 'outline'
|
||||
? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
: 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? `${action.label}...` : action.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{actions.map((action, index) => (
|
||||
<BrandedButton
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
loading={action.loading}
|
||||
loadingText={action.label}
|
||||
>
|
||||
{action.label}
|
||||
</BrandedButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,95 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { inter } from '@/app/_styles/fonts/inter/inter'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import { BrandedButton } from '@/app/(auth)/components/branded-button'
|
||||
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
|
||||
|
||||
export default function NotFound() {
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
const brandConfig = useBrandConfig()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const hadDark = root.classList.contains('dark')
|
||||
const hadLight = root.classList.contains('light')
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
return () => {
|
||||
if (!hadLight) root.classList.remove('light')
|
||||
if (hadDark) root.classList.add('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
checkCustomBrand()
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='relative min-h-screen'>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
The page you’re looking for doesn’t exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 w-full space-y-3'>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => router.push('/')}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPageLayout
|
||||
title='Page Not Found'
|
||||
description="The page you're looking for doesn't exist or has been moved."
|
||||
>
|
||||
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
|
||||
</StatusPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
BubbleChatClose,
|
||||
BubbleChatPreview,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Card as CardIcon,
|
||||
Checkbox,
|
||||
ChevronDown,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
Combobox,
|
||||
Connections,
|
||||
Copy,
|
||||
DatePicker,
|
||||
DocumentAttachment,
|
||||
Duplicate,
|
||||
Eye,
|
||||
@@ -29,6 +33,7 @@ import {
|
||||
Label,
|
||||
Layout,
|
||||
Library,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -69,10 +74,15 @@ import {
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
Textarea,
|
||||
TimePicker,
|
||||
Tooltip,
|
||||
@@ -129,6 +139,14 @@ export default function PlaygroundPage() {
|
||||
const [timeValue, setTimeValue] = useState('09:30')
|
||||
const [activeTab, setActiveTab] = useState('profile')
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
|
||||
const [dateValue, setDateValue] = useState('')
|
||||
const [dateRangeStart, setDateRangeStart] = useState('')
|
||||
const [dateRangeEnd, setDateRangeEnd] = useState('')
|
||||
const [tagItems, setTagItems] = useState<TagItem[]>([
|
||||
{ value: 'user@example.com', isValid: true },
|
||||
{ value: 'invalid-email', isValid: false },
|
||||
])
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode(!isDarkMode)
|
||||
@@ -208,6 +226,57 @@ export default function PlaygroundPage() {
|
||||
<VariantRow label='disabled'>
|
||||
<Button disabled>Disabled</Button>
|
||||
</VariantRow>
|
||||
<VariantRow label='size sm'>
|
||||
<Button size='sm'>Small</Button>
|
||||
<Button size='sm' variant='primary'>
|
||||
Small Primary
|
||||
</Button>
|
||||
</VariantRow>
|
||||
<VariantRow label='size md'>
|
||||
<Button size='md'>Medium</Button>
|
||||
<Button size='md' variant='primary'>
|
||||
Medium Primary
|
||||
</Button>
|
||||
</VariantRow>
|
||||
<VariantRow label='size branded'>
|
||||
<Button size='branded' variant='branded' className='branded-button-gradient'>
|
||||
Branded
|
||||
</Button>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* ButtonGroup */}
|
||||
<Section title='ButtonGroup'>
|
||||
<VariantRow label='default'>
|
||||
<ButtonGroup value={buttonGroupValue} onValueChange={setButtonGroupValue}>
|
||||
<ButtonGroupItem value='curl'>cURL</ButtonGroupItem>
|
||||
<ButtonGroupItem value='python'>Python</ButtonGroupItem>
|
||||
<ButtonGroupItem value='javascript'>JavaScript</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</VariantRow>
|
||||
<VariantRow label='gap none'>
|
||||
<ButtonGroup value='opt1' gap='none'>
|
||||
<ButtonGroupItem value='opt1'>Option 1</ButtonGroupItem>
|
||||
<ButtonGroupItem value='opt2'>Option 2</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</VariantRow>
|
||||
<VariantRow label='gap sm'>
|
||||
<ButtonGroup value='opt1' gap='sm'>
|
||||
<ButtonGroupItem value='opt1'>Option 1</ButtonGroupItem>
|
||||
<ButtonGroupItem value='opt2'>Option 2</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</VariantRow>
|
||||
<VariantRow label='disabled'>
|
||||
<ButtonGroup value='opt1' disabled>
|
||||
<ButtonGroupItem value='opt1'>Option 1</ButtonGroupItem>
|
||||
<ButtonGroupItem value='opt2'>Option 2</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</VariantRow>
|
||||
<VariantRow label='single item'>
|
||||
<ButtonGroup value='only'>
|
||||
<ButtonGroupItem value='only'>Only Option</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Badge */}
|
||||
@@ -274,6 +343,46 @@ export default function PlaygroundPage() {
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* TagInput */}
|
||||
<Section title='TagInput'>
|
||||
<VariantRow label='default'>
|
||||
<div className='w-80'>
|
||||
<TagInput
|
||||
items={tagItems}
|
||||
onAdd={(value) => {
|
||||
const isValid = value.includes('@') && value.includes('.')
|
||||
setTagItems((prev) => [...prev, { value, isValid }])
|
||||
return isValid
|
||||
}}
|
||||
onRemove={(_, index) => {
|
||||
setTagItems((prev) => prev.filter((_, i) => i !== index))
|
||||
}}
|
||||
placeholder='Enter emails...'
|
||||
placeholderWithTags='Add another'
|
||||
/>
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='tag variants'>
|
||||
<Tag value='valid@email.com' variant='default' />
|
||||
<Tag value='invalid-email' variant='invalid' />
|
||||
</VariantRow>
|
||||
<VariantRow label='tag with remove'>
|
||||
<Tag value='removable@tag.com' variant='default' onRemove={() => {}} />
|
||||
<Tag value='invalid-removable' variant='invalid' onRemove={() => {}} />
|
||||
</VariantRow>
|
||||
<VariantRow label='disabled'>
|
||||
<div className='w-80'>
|
||||
<TagInput
|
||||
items={[{ value: 'disabled@email.com', isValid: true }]}
|
||||
onAdd={() => false}
|
||||
onRemove={() => {}}
|
||||
placeholder='Disabled input'
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Textarea */}
|
||||
<Section title='Textarea'>
|
||||
<Textarea placeholder='Enter your message...' className='max-w-md' rows={4} />
|
||||
@@ -432,6 +541,53 @@ export default function PlaygroundPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VariantRow>
|
||||
<VariantRow label='with footer'>
|
||||
<Table className='max-w-md'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead className='text-right'>Price</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Product A</TableCell>
|
||||
<TableCell className='text-right'>$10.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Product B</TableCell>
|
||||
<TableCell className='text-right'>$20.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell>Total</TableCell>
|
||||
<TableCell className='text-right'>$30.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</VariantRow>
|
||||
<VariantRow label='with caption'>
|
||||
<Table className='max-w-md'>
|
||||
<TableCaption>A list of team members</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Department</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>Alice</TableCell>
|
||||
<TableCell>Engineering</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Bob</TableCell>
|
||||
<TableCell>Design</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Combobox */}
|
||||
@@ -518,6 +674,43 @@ export default function PlaygroundPage() {
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* DatePicker */}
|
||||
<Section title='DatePicker'>
|
||||
<VariantRow label='single date'>
|
||||
<div className='w-56'>
|
||||
<DatePicker value={dateValue} onChange={setDateValue} placeholder='Select date' />
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='size sm'>
|
||||
<div className='w-56'>
|
||||
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='range mode'>
|
||||
<div className='w-72'>
|
||||
<DatePicker
|
||||
mode='range'
|
||||
startDate={dateRangeStart}
|
||||
endDate={dateRangeEnd}
|
||||
onRangeChange={(start, end) => {
|
||||
setDateRangeStart(start)
|
||||
setDateRangeEnd(end)
|
||||
}}
|
||||
placeholder='Select date range'
|
||||
/>
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='disabled'>
|
||||
<div className='w-56'>
|
||||
<DatePicker value='2025-01-15' disabled />
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='inline'>
|
||||
<DatePicker inline value={dateValue} onChange={setDateValue} />
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<Section title='Breadcrumb'>
|
||||
<Breadcrumb
|
||||
@@ -539,6 +732,26 @@ export default function PlaygroundPage() {
|
||||
<Tooltip.Content>Tooltip content</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</VariantRow>
|
||||
<VariantRow label='with shortcut'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='default'>Clear console</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</VariantRow>
|
||||
<VariantRow label='shortcut only'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='default'>Save</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys='⌘S' />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Popover */}
|
||||
@@ -760,6 +973,7 @@ export default function PlaygroundPage() {
|
||||
<Section title='Icons'>
|
||||
<div className='grid grid-cols-6 gap-4 sm:grid-cols-8 md:grid-cols-10'>
|
||||
{[
|
||||
{ Icon: BubbleChatClose, name: 'BubbleChatClose' },
|
||||
{ Icon: BubbleChatPreview, name: 'BubbleChatPreview' },
|
||||
{ Icon: CardIcon, name: 'Card' },
|
||||
{ Icon: ChevronDown, name: 'ChevronDown' },
|
||||
@@ -774,6 +988,7 @@ export default function PlaygroundPage() {
|
||||
{ Icon: KeyIcon, name: 'Key' },
|
||||
{ Icon: Layout, name: 'Layout' },
|
||||
{ Icon: Library, name: 'Library' },
|
||||
{ Icon: Loader, name: 'Loader' },
|
||||
{ Icon: MoreHorizontal, name: 'MoreHorizontal' },
|
||||
{ Icon: NoWrap, name: 'NoWrap' },
|
||||
{ Icon: PanelLeft, name: 'PanelLeft' },
|
||||
|
||||
@@ -332,7 +332,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return (
|
||||
<WorkflowPreview
|
||||
workflowState={template.state}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user