Compare commits

...

84 Commits

Author SHA1 Message Date
Siddharth Ganesan
75e6e4f230 Fix config 2026-01-10 11:32:34 -08:00
Siddharth Ganesan
963f1d561a Merge branch 'staging' into feat/copilot-subagents 2026-01-10 11:29:06 -08:00
Vikhyath Mondreti
92fabe785d fix(perms): copilot checks undefined issue (#2763) 2026-01-10 11:23:35 -08:00
Siddharth Ganesan
3ed177520a fix(router): fix router ports (#2757)
* Fix router block

* Fix autoconnect edge for router

* Fix lint

* router block error path decision

* improve router prompt

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-10 11:22:11 -08:00
Siddharth Ganesan
5e9d8e1e9a Stuff 2026-01-10 11:16:33 -08:00
Siddharth Ganesan
152e942074 Merge origin/staging into feat/copilot-subagents 2026-01-10 10:46:36 -08:00
Siddharth Ganesan
3359d7b0d8 Fix ops 2026-01-10 10:13:39 -08:00
Siddharth Ganesan
b7d0f2053b Fix ops bug 2026-01-10 10:12:54 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
baa54b4c97 feat(docs): added circleback docs (#2762) 2026-01-10 00:30:49 -08:00
Waleed
a11d452d7b fix(build): fixed circular dependencies (#2761) 2026-01-10 00:10:20 -08:00
Waleed
6262503b89 feat(deployed-form): added deployed form input (#2679)
* feat(deployed-form): added deployed form input

* styling consolidation, finishing touches on form

* updated docs

* remove unused files with knip

* added more form fields

* consolidated more test utils

* remove unused/unneeded zustand stores, refactored stores for consistency

* improvement(files): uncolorized plan name

* feat(emcn): button-group

* feat(emcn): tag input, tooltip shortcut

* improvement(emcn): modal padding, api, chat, form

* fix: deleted migrations

* feat(form): added migrations

* fix(emcn): tag input

* fix: failing tests on build

* add suplementary hover and fix bg color in date picker

* fix: build errors

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-09 23:42:21 -08:00
Waleed
67440432bf fix(ops): fix subflow resizing on exit (#2760)
* fix(sockets): broadcast handles and enabled/disabled state

* made all ops batched, removed all individual ops

* fix subflow resizing on exit

* removed unused custom event

* fix failing tests, update testing

* fix test mock
2026-01-09 22:35:03 -08:00
Emir Karabeg
1f6f58cf7f improvement(copilot): diff controls 2026-01-09 20:17:49 -08:00
Vikhyath Mondreti
47eb060311 feat(enterprise): permission groups, access control (#2736)
* feat(permission-groups): integration/model access controls for enterprise

* feat: enterprise gating for BYOK, SSO, credential sets with org admin/owner checks

* execution time enforcement of mcp and custom tools

* add admin routes to cleanup permission group data

* fix not being on enterprise checks

* separate out orgs from billing system

* update the docs

* add custom tool blockers based on perm configs

* add migrations

* fix

* address greptile comments

* regen migrations

* fix default model picking based on user config

* cleaned up UI
2026-01-09 20:16:22 -08:00
Emir Karabeg
41d767e170 improvement(copilot): ui/ux 2026-01-09 18:56:57 -08:00
Siddharth Ganesan
c5dc78ff08 Enable images 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
ae6e29512a Previous options should not be selectable 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
6639871c92 Fix lint 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
0ba5ec65f7 Diff view 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
8597786962 Persist and load chats properly 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
1aada6ba57 Fix thinking text 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
a9edbd71f1 Renaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
bd35dda8fa Fix thinking scroll 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
b2b06c3dd1 Streaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
29eefd8416 Plan 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
51b2297e35 Fix previews 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
aa8da99ce2 Subagent rendering 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
2b2ed6df1a Fix spacing between options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
7305ecf4fa Fixes to options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
d2ef972bbb Options select 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
90c875b895 Editor component 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4280461cb8 Add evaluator subagent 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
3be426af8e Add deploy mcp tools 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
8fcb0349d2 Fix rendering of edit subblocks 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
96dc2b7afd Lint 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
e95b6135ac Options 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
2fe0afaef4 Diff view in chat 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
d86e43945f Remove context usage code 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4fd5656c01 Diff in chat 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
54f6047dd3 Overlays 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
86f8e77293 Trigger request 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
05034edc83 Fix bugs 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
7a925ad45c Many subagents 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
442d8a1f45 Message queue 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
56366088d8 Tweaks 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
f6cd0cbc55 Edit, plan, debug subagents 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
df80309c3b Add subagents 2026-01-09 18:56:29 -08:00
Adam Gough
fd76e98f0e improvement(wand): added more wands (#2756)
* added wand configs

* fixed greptile comments
2026-01-09 18:41:51 -08:00
Waleed
1dbd16115f feat(sidebar): context menu for nav items in sidebar, toolbar blocks, added missing docs for various blocks and triggers (#2754)
* feat(sidebar): context menu for nav items in sidebar

* added toolbar context menu, fixed incorrect access pattern in old context menus and added docs for missing blocks

* fixed links
2026-01-09 17:50:10 -08:00
Vikhyath Mondreti
38e827b61a fix(docs): new router (#2755)
* fix(docs): new router

* update image
2026-01-09 17:37:04 -08:00
Waleed
1f5e8a41f8 fix(tools): fixed workflow tool for agent to respect user provided params, inject at runtime like all other tools (#2750)
* fix(tools): fixed wrokflow tool for agent to respect user provided params, inject at runtime like all other tools

* ack comments

* remove redunant if-else

* added tests
2026-01-09 17:12:58 -08:00
Adam Gough
796f73ee01 improvement(google-drive) (#2752)
* expanded metadata fields for google drive

* added tag dropdown support

* fixed greptile

* added utils func

* removed comments

* updated docs

* greptile comments

* fixed output schema

* reverted back to bas64 string
2026-01-09 16:56:07 -08:00
Waleed
d3d6012d5c fix(tools): updated memory block to throw better errors, removed deprecated posthog route, remove deprecated templates & console helpers (#2753)
* fix(tools): updated memory block to throw better errors, removed deprecated posthog route, remove deprecated templates & console helpers

* remove isDeployed in favor of deploymentStatus

* ack PR comments
2026-01-09 16:53:37 -08:00
Vikhyath Mondreti
860610b4c2 improvement(billing): team upgrade + session management (#2751)
* improvement(billng): team upgrade + session management

* remove comments

* session updates should be atomic

* make consistent for onSubscritionUpdate

* plan upgrade to refresh session

* fix var name

* remove dead code

* preserve params
2026-01-09 16:36:45 -08:00
Waleed
05bbf34265 improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations (#2738)
* improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations

* feat(i18n): update translations (#2732)

Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>

* don't allow flip handles for subflows

* ack PR comments

* more

* fix missing handler

* remove dead subflow-specific ops

* remove unused code

* fixed subflow ops

* keep edges on subflow actions intact

* fix subflow resizing

* fix remove from subflow bulk

* improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations

* don't allow flip handles for subflows

* ack PR comments

* more

* fix missing handler

* remove dead subflow-specific ops

* remove unused code

* fixed subflow ops

* fix subflow resizing

* keep edges on subflow actions intact

* fixed copy from inside subflow

* types improvement, preview fixes

* fetch varible data in deploy modal

* moved remove from subflow one position to the right

* fix subflow issues

* address greptile comment

* fix test

* improvement(preview): ui/ux

* fix(preview): subflows

* added batch add edges

* removed recovery

* use consolidated consts for sockets operations

* more

---------

Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-09 14:48:23 -08:00
Waleed
753600ed60 feat(i18n): update translations (#2749)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2026-01-09 14:11:57 -08:00
Vikhyath Mondreti
4da43d937c improvement(docs): multiplier dropped to 1.4 (#2748) 2026-01-09 11:41:04 -08:00
Waleed
9502227fd4 fix(sso): add missing deps to db container for running script (#2746) 2026-01-09 09:42:13 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Adam Gough
13981549d1 fix(grain): grain trigger update (#2739)
* grain trigger new requirements

* removed comment

* made it generic for all triggers

* fire only for specific trigger type

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

* edited subblock UI

* removed comments

* removed carrot

* updated styling to match existing subblocks

* enriched workflow preview

* fix connetion in log preview

* cleanup

* ack PR comments

* more PR comments

* more

* cleanup

* use reactquery cache in deploy modal

* ack comments

* ack PR comment

---------

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

* fixed linear bugs
2026-01-08 20:42:09 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
649 changed files with 47290 additions and 14760 deletions

View File

@@ -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' })
```

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -2,16 +2,15 @@
title: Router
---
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 +31,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 +67,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 +77,43 @@ 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)
```
## Error Handling
When the Router cannot determine an appropriate route for the given context, it will route to the **error path** instead of arbitrarily selecting a route. This happens when:
- The context doesn't clearly match any of the defined route descriptions
- The AI determines that none of the available routes are appropriate
## 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.
- **Connect an error path**: Handle cases where no route matches by connecting an error handler for graceful fallback behavior.
- **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.

View File

@@ -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.

View File

@@ -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>

View 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.

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "basics", "api", "logging", "costs"]
"pages": ["index", "basics", "api", "form", "logging", "costs"]
}

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -44,7 +44,7 @@ Reference structured values downstream with expressions such as <code>&lt;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>&lt;start.fieldName&gt;</code> (for example <code>&lt;start.sampleField&gt;</code>).
@@ -64,6 +64,13 @@ Reference structured values downstream with expressions such as <code>&lt;start.
If you launch chat with additional structured context (for example from an embed), it merges into the corresponding <code>&lt;start.fieldName&gt;</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>&lt;start.fieldName&gt;</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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View 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'

View File

@@ -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'
)

View 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>
)
}

View 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>
)
}

View File

@@ -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')
}
}

View File

@@ -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')
}
}

View File

@@ -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')
}
}

View File

@@ -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')
}
}

View File

@@ -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')
}
}

View File

@@ -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>

View File

@@ -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'
/>
</>
)
}

View File

@@ -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') {

View File

@@ -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'

View File

@@ -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;
}
/**

View File

@@ -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')

View File

@@ -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) => {

View File

@@ -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({

View File

@@ -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) => {

View File

@@ -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' },

View File

@@ -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 }
}

View File

@@ -17,25 +17,30 @@ const logger = createLogger('CopilotChatUpdateAPI')
const UpdateMessagesSchema = z.object({
chatId: z.string(),
messages: z.array(
z.object({
id: z.string(),
role: z.enum(['user', 'assistant']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(z.any()).optional(),
contentBlocks: z.array(z.any()).optional(),
fileAttachments: z
.array(
z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
)
.optional(),
})
z
.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(z.any()).optional(),
contentBlocks: z.array(z.any()).optional(),
fileAttachments: z
.array(
z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
)
.optional(),
contexts: z.array(z.any()).optional(),
citations: z.array(z.any()).optional(),
errorType: z.string().optional(),
})
.passthrough() // Preserve any additional fields for future compatibility
),
planArtifact: z.string().nullable().optional(),
config: z
@@ -57,6 +62,19 @@ export async function POST(req: NextRequest) {
}
const body = await req.json()
// Debug: Log what we received
const lastMsg = body.messages?.[body.messages.length - 1]
if (lastMsg?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Received messages to save`, {
messageCount: body.messages?.length,
lastMsgId: lastMsg.id,
lastMsgContentLength: lastMsg.content?.length || 0,
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
})
}
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
// Verify that the chat belongs to the user

View File

@@ -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 }
)
}
}

View File

@@ -1,134 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ContextUsageAPI')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const ContextUsageRequestSchema = z.object({
chatId: z.string(),
model: z.string(),
workflowId: z.string(),
provider: z.any().optional(),
})
/**
* POST /api/copilot/context-usage
* Fetch context usage from sim-agent API
*/
export async function POST(req: NextRequest) {
try {
logger.info('[Context Usage API] Request received')
const session = await getSession()
if (!session?.user?.id) {
logger.warn('[Context Usage API] No session/user ID')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
logger.info('[Context Usage API] Request body', body)
const parsed = ContextUsageRequestSchema.safeParse(body)
if (!parsed.success) {
logger.warn('[Context Usage API] Invalid request body', parsed.error.errors)
return NextResponse.json(
{ error: 'Invalid request body', details: parsed.error.errors },
{ status: 400 }
)
}
const { chatId, model, workflowId, provider } = parsed.data
const userId = session.user.id // Get userId from session, not from request
logger.info('[Context Usage API] Request validated', { chatId, model, userId, workflowId })
// Build provider config similar to chat route
let providerConfig: CopilotProviderConfig | undefined = provider
if (!providerConfig) {
const defaults = getCopilotModel('chat')
const modelToUse = env.COPILOT_MODEL || defaults.model
const providerEnv = env.COPILOT_PROVIDER as any
if (providerEnv) {
if (providerEnv === 'azure-openai') {
providerConfig = {
provider: 'azure-openai',
model: modelToUse,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: env.AZURE_OPENAI_API_VERSION,
endpoint: env.AZURE_OPENAI_ENDPOINT,
}
} else if (providerEnv === 'vertex') {
providerConfig = {
provider: 'vertex',
model: modelToUse,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
}
} else {
providerConfig = {
provider: providerEnv,
model: modelToUse,
apiKey: env.COPILOT_API_KEY,
}
}
}
}
// Call sim-agent API
const requestPayload = {
chatId,
model,
userId,
workflowId,
...(providerConfig ? { provider: providerConfig } : {}),
}
logger.info('[Context Usage API] Calling sim-agent', {
url: `${SIM_AGENT_API_URL}/api/get-context-usage`,
payload: requestPayload,
})
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/get-context-usage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(requestPayload),
})
logger.info('[Context Usage API] Sim-agent response', {
status: simAgentResponse.status,
ok: simAgentResponse.ok,
})
if (!simAgentResponse.ok) {
const errorText = await simAgentResponse.text().catch(() => '')
logger.warn('[Context Usage API] Sim agent request failed', {
status: simAgentResponse.status,
error: errorText,
})
return NextResponse.json(
{ error: 'Failed to fetch context usage from sim-agent' },
{ status: simAgentResponse.status }
)
}
const data = await simAgentResponse.json()
logger.info('[Context Usage API] Sim-agent data received', data)
return NextResponse.json(data)
} catch (error) {
logger.error('Error fetching context usage:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -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')

View 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)
}

View 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)
}
}

View 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)
}
}

View 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.',
})
})
})
})

View 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.',
}

View 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)
}
}

View File

@@ -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(),

View File

@@ -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',

View File

@@ -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(),

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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),
})
}

View File

@@ -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')

View File

@@ -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'

View File

@@ -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'

View File

@@ -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(),
}

View File

@@ -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

View File

@@ -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')
})

View 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')
}
})

View File

@@ -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'

View File

@@ -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) {

View File

@@ -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

View File

@@ -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(

View File

@@ -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')
}
})

View File

@@ -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

View File

@@ -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')

View File

@@ -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')

View File

@@ -744,7 +744,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
const grainResult = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
@@ -754,11 +754,12 @@ export async function POST(request: NextRequest) {
requestId
)
if (grainHookId) {
// Update the webhook record with the external Grain hook ID
if (grainResult) {
// Update the webhook record with the external Grain hook ID and event types for filtering
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
externalId: grainResult.id,
eventTypes: grainResult.eventTypes,
}
await db
.update(webhook)
@@ -770,7 +771,8 @@ export async function POST(request: NextRequest) {
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
grainHookId: grainResult.id,
eventTypes: grainResult.eventTypes,
webhookId: savedWebhook.id,
})
}
@@ -1176,10 +1178,10 @@ async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
): Promise<{ id: string; eventTypes: string[] } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
@@ -1191,12 +1193,53 @@ async function createGrainWebhookSubscription(
)
}
// Map trigger IDs to Grain API hook_type (only 2 options: recording_added, upload_status)
const hookTypeMap: Record<string, string> = {
grain_webhook: 'recording_added',
grain_recording_created: 'recording_added',
grain_recording_updated: 'recording_added',
grain_highlight_created: 'recording_added',
grain_highlight_updated: 'recording_added',
grain_story_created: 'recording_added',
grain_upload_status: 'upload_status',
}
const eventTypeMap: Record<string, string[]> = {
grain_webhook: [],
grain_recording_created: ['recording_added'],
grain_recording_updated: ['recording_updated'],
grain_highlight_created: ['highlight_created'],
grain_highlight_updated: ['highlight_updated'],
grain_story_created: ['story_created'],
grain_upload_status: ['upload_status'],
}
const hookType = hookTypeMap[triggerId] ?? 'recording_added'
const eventTypes = eventTypeMap[triggerId] ?? []
if (!hookTypeMap[triggerId]) {
logger.warn(
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`,
{
webhookId: webhookData.id,
}
)
}
logger.info(`[${requestId}] Creating Grain webhook`, {
triggerId,
hookType,
eventTypes,
webhookId: webhookData.id,
})
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
hook_type: hookType,
}
// Build include object based on configuration
@@ -1226,8 +1269,10 @@ async function createGrainWebhookSubscription(
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error) {
if (!grainResponse.ok || responseBody.error || responseBody.errors) {
logger.warn('[App] Grain response body:', responseBody)
const errorMessage =
responseBody.errors?.detail ||
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
@@ -1255,10 +1300,11 @@ async function createGrainWebhookSubscription(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
eventTypes,
}
)
return responseBody.id
return { id: responseBody.id, eventTypes }
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,

View File

@@ -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,

View 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)
}
}

View File

@@ -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) =>

View File

@@ -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', {

View File

@@ -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 })
}
}

View File

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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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,
},
]}
/>

View 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>
)
}

View 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>
)
}

View 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'

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
}

View File

@@ -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',
},
]}
/>

View File

@@ -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>

View File

@@ -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' />
</>
)
}

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