Compare commits

..

13 Commits

Author SHA1 Message Date
Vikhyath Mondreti
1814aef84f fix error message 2026-01-10 12:16:07 -08:00
Vikhyath Mondreti
c07dbfa06a don't show dup button for response 2026-01-10 12:08:55 -08:00
Vikhyath Mondreti
a2930446bc respect singleton triggers and blocks in copilot 2026-01-10 12:05:21 -08:00
Vikhyath Mondreti
40fc9ca504 improvement(response): only allow singleton 2026-01-10 11:55:20 -08:00
Siddharth Ganesan
283a521614 feat(copilot): subagents (#2731)
* 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.

* Add subagents

* Edit, plan, debug subagents

* Tweaks

* Message queue

* Many subagents

* Fix bugs

* Trigger request

* Overlays

* Diff in chat

* Remove context usage code

* Diff view in chat

* Options

* Lint

* Fix rendering of edit subblocks

* Add deploy mcp tools

* Add evaluator subagent

* Editor component

* Options select

* Fixes to options

* Fix spacing between options

* Subagent rendering

* Fix previews

* Plan

* Streaming

* Fix thinking scroll

* Renaming

* Fix thinking text

* Persist and load chats properly

* Diff view

* Fix lint

* Previous options should not be selectable

* Enable images

* improvement(copilot): ui/ux

* improvement(copilot): diff controls

* Fix ops bug

* Fix ops

* Stuff

* Fix config

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Martin Yankov <23098926+Lutherwaves@users.noreply.github.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.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>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-10 11:44:04 -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
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
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
Adam Gough
fd76e98f0e improvement(wand): added more wands (#2756)
* added wand configs

* fixed greptile comments
2026-01-09 18:41:51 -08:00
472 changed files with 38110 additions and 10988 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

@@ -2,7 +2,6 @@
title: Router
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Image } from '@/components/ui/image'
@@ -102,11 +101,18 @@ Input (Lead) → Router
└── [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
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
- **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.

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

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

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

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

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

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

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

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

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

@@ -1,44 +0,0 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
const logger = createLogger('ValidateMcpWorkflowsAPI')
/**
* POST /api/mcp/workflow-servers/validate
* Validates if workflows have valid start blocks for MCP usage
*/
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workflowIds } = body
if (!Array.isArray(workflowIds) || workflowIds.length === 0) {
return NextResponse.json({ error: 'workflowIds must be a non-empty array' }, { status: 400 })
}
const results: Record<string, boolean> = {}
for (const workflowId of workflowIds) {
try {
const state = await loadWorkflowFromNormalizedTables(workflowId)
results[workflowId] = hasValidStartBlockInState(state)
} catch (error) {
logger.warn(`Failed to validate workflow ${workflowId}:`, error)
results[workflowId] = false
}
}
return NextResponse.json({ data: results })
} catch (error) {
logger.error('Failed to validate workflows for MCP:', error)
return NextResponse.json({ error: 'Failed to validate workflows' }, { status: 500 })
}
}

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

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

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

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

@@ -460,43 +460,22 @@ export default function ChatClient({ identifier }: { identifier: string }) {
)
if (error) {
return <ChatErrorState error={error} starCount={starCount} />
return <ChatErrorState error={error} />
}
if (authRequired) {
const title = new URLSearchParams(window.location.search).get('title') || 'chat'
const primaryColor =
new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
// const title = new URLSearchParams(window.location.search).get('title') || 'chat'
// const primaryColor =
// new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
if (authRequired === 'password') {
return (
<PasswordAuth
identifier={identifier}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
/>
)
return <PasswordAuth identifier={identifier} onAuthSuccess={handleAuthSuccess} />
}
if (authRequired === 'email') {
return (
<EmailAuth
identifier={identifier}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
/>
)
return <EmailAuth identifier={identifier} onAuthSuccess={handleAuthSuccess} />
}
if (authRequired === 'sso') {
return (
<SSOAuth
identifier={identifier}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
/>
)
return <SSOAuth identifier={identifier} />
}
}

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

View File

@@ -1,95 +1,18 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useBrandConfig } from '@/lib/branding/branding'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import Nav from '@/app/(landing)/components/nav/nav'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
export default function NotFound() {
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
const brandConfig = useBrandConfig()
const router = useRouter()
useEffect(() => {
const root = document.documentElement
const hadDark = root.classList.contains('dark')
const hadLight = root.classList.contains('light')
root.classList.add('light')
root.classList.remove('dark')
return () => {
if (!hadLight) root.classList.remove('light')
if (hadDark) root.classList.add('dark')
}
}, [])
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
return (
<div className='relative min-h-screen'>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
<Nav variant='auth' />
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
<div className='w-full max-w-[410px]'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
>
Page Not Found
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
The page youre looking for doesnt exist or has been moved.
</p>
</div>
<div className='mt-8 w-full space-y-3'>
<Button
type='button'
onClick={() => router.push('/')}
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
>
Return to Home
</Button>
</div>
<div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
>
Need help?{' '}
<a
href={`mailto:${brandConfig.supportEmail}`}
className='auth-link underline-offset-4 transition hover:underline'
>
Contact support
</a>
</div>
</div>
</div>
</div>
</div>
<StatusPageLayout
title='Page Not Found'
description="The page you're looking for doesn't exist or has been moved."
>
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
</StatusPageLayout>
)
}

View File

@@ -9,8 +9,11 @@ import {
AvatarImage,
Badge,
Breadcrumb,
BubbleChatClose,
BubbleChatPreview,
Button,
ButtonGroup,
ButtonGroupItem,
Card as CardIcon,
Checkbox,
ChevronDown,
@@ -18,6 +21,7 @@ import {
Combobox,
Connections,
Copy,
DatePicker,
DocumentAttachment,
Duplicate,
Eye,
@@ -29,6 +33,7 @@ import {
Label,
Layout,
Library,
Loader,
Modal,
ModalBody,
ModalContent,
@@ -69,10 +74,15 @@ import {
Switch,
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
Tag,
TagInput,
type TagItem,
Textarea,
TimePicker,
Tooltip,
@@ -129,6 +139,14 @@ export default function PlaygroundPage() {
const [timeValue, setTimeValue] = useState('09:30')
const [activeTab, setActiveTab] = useState('profile')
const [isDarkMode, setIsDarkMode] = useState(false)
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
const [dateValue, setDateValue] = useState('')
const [dateRangeStart, setDateRangeStart] = useState('')
const [dateRangeEnd, setDateRangeEnd] = useState('')
const [tagItems, setTagItems] = useState<TagItem[]>([
{ value: 'user@example.com', isValid: true },
{ value: 'invalid-email', isValid: false },
])
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode)
@@ -208,6 +226,57 @@ export default function PlaygroundPage() {
<VariantRow label='disabled'>
<Button disabled>Disabled</Button>
</VariantRow>
<VariantRow label='size sm'>
<Button size='sm'>Small</Button>
<Button size='sm' variant='primary'>
Small Primary
</Button>
</VariantRow>
<VariantRow label='size md'>
<Button size='md'>Medium</Button>
<Button size='md' variant='primary'>
Medium Primary
</Button>
</VariantRow>
<VariantRow label='size branded'>
<Button size='branded' variant='branded' className='branded-button-gradient'>
Branded
</Button>
</VariantRow>
</Section>
{/* ButtonGroup */}
<Section title='ButtonGroup'>
<VariantRow label='default'>
<ButtonGroup value={buttonGroupValue} onValueChange={setButtonGroupValue}>
<ButtonGroupItem value='curl'>cURL</ButtonGroupItem>
<ButtonGroupItem value='python'>Python</ButtonGroupItem>
<ButtonGroupItem value='javascript'>JavaScript</ButtonGroupItem>
</ButtonGroup>
</VariantRow>
<VariantRow label='gap none'>
<ButtonGroup value='opt1' gap='none'>
<ButtonGroupItem value='opt1'>Option 1</ButtonGroupItem>
<ButtonGroupItem value='opt2'>Option 2</ButtonGroupItem>
</ButtonGroup>
</VariantRow>
<VariantRow label='gap sm'>
<ButtonGroup value='opt1' gap='sm'>
<ButtonGroupItem value='opt1'>Option 1</ButtonGroupItem>
<ButtonGroupItem value='opt2'>Option 2</ButtonGroupItem>
</ButtonGroup>
</VariantRow>
<VariantRow label='disabled'>
<ButtonGroup value='opt1' disabled>
<ButtonGroupItem value='opt1'>Option 1</ButtonGroupItem>
<ButtonGroupItem value='opt2'>Option 2</ButtonGroupItem>
</ButtonGroup>
</VariantRow>
<VariantRow label='single item'>
<ButtonGroup value='only'>
<ButtonGroupItem value='only'>Only Option</ButtonGroupItem>
</ButtonGroup>
</VariantRow>
</Section>
{/* Badge */}
@@ -274,6 +343,46 @@ export default function PlaygroundPage() {
</VariantRow>
</Section>
{/* TagInput */}
<Section title='TagInput'>
<VariantRow label='default'>
<div className='w-80'>
<TagInput
items={tagItems}
onAdd={(value) => {
const isValid = value.includes('@') && value.includes('.')
setTagItems((prev) => [...prev, { value, isValid }])
return isValid
}}
onRemove={(_, index) => {
setTagItems((prev) => prev.filter((_, i) => i !== index))
}}
placeholder='Enter emails...'
placeholderWithTags='Add another'
/>
</div>
</VariantRow>
<VariantRow label='tag variants'>
<Tag value='valid@email.com' variant='default' />
<Tag value='invalid-email' variant='invalid' />
</VariantRow>
<VariantRow label='tag with remove'>
<Tag value='removable@tag.com' variant='default' onRemove={() => {}} />
<Tag value='invalid-removable' variant='invalid' onRemove={() => {}} />
</VariantRow>
<VariantRow label='disabled'>
<div className='w-80'>
<TagInput
items={[{ value: 'disabled@email.com', isValid: true }]}
onAdd={() => false}
onRemove={() => {}}
placeholder='Disabled input'
disabled
/>
</div>
</VariantRow>
</Section>
{/* Textarea */}
<Section title='Textarea'>
<Textarea placeholder='Enter your message...' className='max-w-md' rows={4} />
@@ -432,6 +541,53 @@ export default function PlaygroundPage() {
</TableBody>
</Table>
</VariantRow>
<VariantRow label='with footer'>
<Table className='max-w-md'>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className='text-right'>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Product A</TableCell>
<TableCell className='text-right'>$10.00</TableCell>
</TableRow>
<TableRow>
<TableCell>Product B</TableCell>
<TableCell className='text-right'>$20.00</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell className='text-right'>$30.00</TableCell>
</TableRow>
</TableFooter>
</Table>
</VariantRow>
<VariantRow label='with caption'>
<Table className='max-w-md'>
<TableCaption>A list of team members</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Department</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Alice</TableCell>
<TableCell>Engineering</TableCell>
</TableRow>
<TableRow>
<TableCell>Bob</TableCell>
<TableCell>Design</TableCell>
</TableRow>
</TableBody>
</Table>
</VariantRow>
</Section>
{/* Combobox */}
@@ -518,6 +674,43 @@ export default function PlaygroundPage() {
</VariantRow>
</Section>
{/* DatePicker */}
<Section title='DatePicker'>
<VariantRow label='single date'>
<div className='w-56'>
<DatePicker value={dateValue} onChange={setDateValue} placeholder='Select date' />
</div>
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-56'>
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />
</div>
</VariantRow>
<VariantRow label='range mode'>
<div className='w-72'>
<DatePicker
mode='range'
startDate={dateRangeStart}
endDate={dateRangeEnd}
onRangeChange={(start, end) => {
setDateRangeStart(start)
setDateRangeEnd(end)
}}
placeholder='Select date range'
/>
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-56'>
<DatePicker value='2025-01-15' disabled />
</div>
</VariantRow>
<VariantRow label='inline'>
<DatePicker inline value={dateValue} onChange={setDateValue} />
</VariantRow>
</Section>
{/* Breadcrumb */}
<Section title='Breadcrumb'>
<Breadcrumb
@@ -539,6 +732,26 @@ export default function PlaygroundPage() {
<Tooltip.Content>Tooltip content</Tooltip.Content>
</Tooltip.Root>
</VariantRow>
<VariantRow label='with shortcut'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='default'>Clear console</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</VariantRow>
<VariantRow label='shortcut only'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='default'>Save</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘S' />
</Tooltip.Content>
</Tooltip.Root>
</VariantRow>
</Section>
{/* Popover */}
@@ -760,6 +973,7 @@ export default function PlaygroundPage() {
<Section title='Icons'>
<div className='grid grid-cols-6 gap-4 sm:grid-cols-8 md:grid-cols-10'>
{[
{ Icon: BubbleChatClose, name: 'BubbleChatClose' },
{ Icon: BubbleChatPreview, name: 'BubbleChatPreview' },
{ Icon: CardIcon, name: 'Card' },
{ Icon: ChevronDown, name: 'ChevronDown' },
@@ -774,6 +988,7 @@ export default function PlaygroundPage() {
{ Icon: KeyIcon, name: 'Key' },
{ Icon: Layout, name: 'Layout' },
{ Icon: Library, name: 'Library' },
{ Icon: Loader, name: 'Loader' },
{ Icon: MoreHorizontal, name: 'MoreHorizontal' },
{ Icon: NoWrap, name: 'NoWrap' },
{ Icon: PanelLeft, name: 'PanelLeft' },

View File

@@ -123,7 +123,7 @@ export function CreateChunkModal({
<ModalHeader>Create Chunk</ModalHeader>
<form>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}

View File

@@ -22,9 +22,9 @@ import type { DocumentData } from '@/lib/knowledge/types'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
const logger = createLogger('DocumentTagsModal')
@@ -399,7 +399,7 @@ export function DocumentTagsModal({
</div>
</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>Tags</Label>

View File

@@ -260,7 +260,7 @@ export function EditChunkModal({
</ModalHeader>
<form>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}

View File

@@ -47,8 +47,8 @@ import {
import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/use-knowledge'
const logger = createLogger('Document')

View File

@@ -53,16 +53,16 @@ import {
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import {
useKnowledgeBase,
useKnowledgeBaseDocuments,
useKnowledgeBasesList,
} from '@/hooks/use-knowledge'
} from '@/hooks/kb/use-knowledge'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
const logger = createLogger('KnowledgeBase')

View File

@@ -224,7 +224,7 @@ export function AddDocumentsModal({
<ModalContent>
<ModalHeader>Add Documents</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
{fileError && (
@@ -242,8 +242,8 @@ export function AddDocumentsModal({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
isDragging && 'border-[var(--brand-primary-hex)]'
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--border-1)] border-dashed py-[10px]',
isDragging && 'border-[var(--surface-7)]'
)}
>
<input

View File

@@ -21,7 +21,7 @@ import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/compone
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
const logger = createLogger('BaseTagsModal')
@@ -313,7 +313,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
</div>
</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>
@@ -458,7 +458,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
@@ -497,7 +497,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedTagUsage?.documentCount || 0} document

View File

@@ -336,7 +336,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<ModalHeader>Create Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
@@ -436,8 +436,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
isDragging && 'border-[var(--brand-primary-hex)]'
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--border-1)] border-dashed py-[10px]',
isDragging && 'border-[var(--surface-7)]'
)}
>
<input

View File

@@ -102,7 +102,7 @@ export function EditKnowledgeBaseModal({
<ModalHeader>Edit Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='kb-name'>Name</Label>

View File

@@ -31,8 +31,8 @@ import {
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDebounce } from '@/hooks/use-debounce'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
const logger = createLogger('Knowledge')

View File

@@ -1 +1,33 @@
export { Knowledge as default } from './knowledge'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { Knowledge } from './knowledge'
interface KnowledgePageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function KnowledgePage({ params }: KnowledgePageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Knowledge />
}

View File

@@ -63,13 +63,13 @@ export function StatusBar({
hoverBrightness = 'hover:brightness-200'
} else if (segment.successRate === 100) {
color = 'bg-emerald-400/90'
hoverBrightness = 'hover:brightness-110'
hoverBrightness = 'hover:brightness-106'
} else if (segment.successRate >= 95) {
color = 'bg-amber-400/90'
hoverBrightness = 'hover:brightness-110'
hoverBrightness = 'hover:brightness-106'
} else {
color = 'bg-red-400/90'
hoverBrightness = 'hover:brightness-110'
hoverBrightness = 'hover:brightness-106'
}
return (

View File

@@ -17,6 +17,7 @@ import {
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useLogDetailsUIStore } from '@/stores/logs/store'
@@ -57,6 +58,7 @@ export const LogDetails = memo(function LogDetails({
const scrollAreaRef = useRef<HTMLDivElement>(null)
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (scrollAreaRef.current) {
@@ -264,7 +266,7 @@ export const LogDetails = memo(function LogDetails({
</div>
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && (
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
@@ -282,12 +284,14 @@ export const LogDetails = memo(function LogDetails({
)}
{/* Workflow Execution - Trace Spans */}
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{isWorkflowExecutionLog &&
log.executionData?.traceSpans &&
!permissionConfig.hideTraceSpans && (
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}
{log.files && log.files.length > 0 && (

View File

@@ -18,10 +18,11 @@ import {
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
TagInput,
type TagItem,
} from '@/components/emcn'
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -156,8 +157,7 @@ export function NotificationSettings({
errorCountThreshold: 10,
})
const [emailInputValue, setEmailInputValue] = useState('')
const [invalidEmails, setInvalidEmails] = useState<string[]>([])
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
@@ -225,8 +225,7 @@ export function NotificationSettings({
})
setFormErrors({})
setEditingId(null)
setEmailInputValue('')
setInvalidEmails([])
setEmailItems([])
}, [])
const handleClose = useCallback(() => {
@@ -243,81 +242,37 @@ export function NotificationSettings({
const normalized = email.trim().toLowerCase()
const validation = quickValidateEmail(normalized)
if (formData.emailRecipients.includes(normalized) || invalidEmails.includes(normalized)) {
if (emailItems.some((item) => item.value === normalized)) {
return false
}
if (!validation.isValid) {
setInvalidEmails((prev) => [...prev, normalized])
setEmailInputValue('')
return false
setEmailItems((prev) => [...prev, { value: normalized, isValid: validation.isValid }])
if (validation.isValid) {
setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
setFormData((prev) => ({
...prev,
emailRecipients: [...prev.emailRecipients, normalized],
}))
}
setFormErrors((prev) => ({ ...prev, emailRecipients: '' }))
setFormData((prev) => ({
...prev,
emailRecipients: [...prev.emailRecipients, normalized],
}))
setEmailInputValue('')
return true
return validation.isValid
},
[formData.emailRecipients, invalidEmails]
[emailItems]
)
const handleRemoveEmail = useCallback((emailToRemove: string) => {
setFormData((prev) => ({
...prev,
emailRecipients: prev.emailRecipients.filter((e) => e !== emailToRemove),
}))
}, [])
const handleRemoveInvalidEmail = useCallback((index: number) => {
setInvalidEmails((prev) => prev.filter((_, i) => i !== index))
}, [])
const handleEmailKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (['Enter', ',', ' '].includes(e.key) && emailInputValue.trim()) {
e.preventDefault()
addEmail(emailInputValue)
}
if (e.key === 'Backspace' && !emailInputValue) {
if (invalidEmails.length > 0) {
handleRemoveInvalidEmail(invalidEmails.length - 1)
} else if (formData.emailRecipients.length > 0) {
handleRemoveEmail(formData.emailRecipients[formData.emailRecipients.length - 1])
}
const handleRemoveEmailItem = useCallback(
(_value: string, index: number, isValid: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid && itemToRemove) {
setFormData((prev) => ({
...prev,
emailRecipients: prev.emailRecipients.filter((e) => e !== itemToRemove.value),
}))
}
},
[
emailInputValue,
addEmail,
invalidEmails,
formData.emailRecipients,
handleRemoveInvalidEmail,
handleRemoveEmail,
]
)
const handleEmailPaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean)
let addedCount = 0
pastedEmails.forEach((email) => {
if (addEmail(email)) {
addedCount++
}
})
if (addedCount === 0 && pastedEmails.length === 1) {
setEmailInputValue(emailInputValue + pastedEmails[0])
}
},
[addEmail, emailInputValue]
[emailItems]
)
const validateForm = (): boolean => {
@@ -356,8 +311,11 @@ export function NotificationSettings({
} else if (formData.emailRecipients.length > 10) {
errors.emailRecipients = 'Maximum 10 email recipients allowed'
}
if (invalidEmails.length > 0) {
errors.emailRecipients = `Invalid email addresses: ${invalidEmails.join(', ')}`
const invalidEmailValues = emailItems
.filter((item) => !item.isValid)
.map((item) => item.value)
if (invalidEmailValues.length > 0) {
errors.emailRecipients = `Invalid email addresses: ${invalidEmailValues.join(', ')}`
}
}
@@ -536,8 +494,9 @@ export function NotificationSettings({
inactivityHours: subscription.alertConfig?.inactivityHours || 24,
errorCountThreshold: subscription.alertConfig?.errorCountThreshold || 10,
})
setEmailInputValue('')
setInvalidEmails([])
setEmailItems(
(subscription.emailRecipients || []).map((email) => ({ value: email, isValid: true }))
)
setShowForm(true)
}
@@ -692,37 +651,13 @@ export function NotificationSettings({
{activeTab === 'email' && (
<div className='flex flex-col gap-[8px]'>
<Label className='text-[var(--text-secondary)]'>Email Recipients</Label>
<div className='scrollbar-hide flex max-h-32 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] focus-within:outline-none dark:bg-[var(--surface-5)]'>
{invalidEmails.map((email, index) => (
<EmailTag
key={`invalid-${index}`}
email={email}
onRemove={() => handleRemoveInvalidEmail(index)}
isInvalid={true}
/>
))}
{formData.emailRecipients.map((email, index) => (
<EmailTag
key={`valid-${index}`}
email={email}
onRemove={() => handleRemoveEmail(email)}
/>
))}
<input
type='text'
value={emailInputValue}
onChange={(e) => setEmailInputValue(e.target.value)}
onKeyDown={handleEmailKeyDown}
onPaste={handleEmailPaste}
onBlur={() => emailInputValue.trim() && addEmail(emailInputValue)}
placeholder={
formData.emailRecipients.length > 0 || invalidEmails.length > 0
? 'Add another email'
: 'Enter emails'
}
className='min-w-[180px] flex-1 border-none bg-transparent p-0 font-medium font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
<TagInput
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={handleRemoveEmailItem}
placeholder='Enter emails'
placeholderWithTags='Add email'
/>
{formErrors.emailRecipients && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
)}
@@ -1351,37 +1286,3 @@ export function NotificationSettings({
</>
)
}
interface EmailTagProps {
email: string
onRemove: () => void
isInvalid?: boolean
}
function EmailTag({ email, onRemove, isInvalid }: EmailTagProps) {
return (
<div
className={cn(
'flex w-auto items-center gap-[4px] rounded-[4px] border px-[6px] py-[2px] text-[12px]',
isInvalid
? 'border-[var(--text-error)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] text-[var(--text-error)] dark:bg-[color-mix(in_srgb,var(--text-error)_16%,transparent)]'
: 'border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='max-w-[200px] truncate'>{email}</span>
<button
type='button'
onClick={onRemove}
className={cn(
'flex-shrink-0 transition-colors focus:outline-none',
isInvalid
? 'text-[var(--text-error)] hover:text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
)}
aria-label={`Remove ${email}`}
>
<X className='h-[12px] w-[12px] translate-y-[0.2px]' />
</button>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { MIN_LOG_DETAILS_WIDTH, useLogDetailsUIStore } from '@/stores/logs/store'
import { useLogDetailsUIStore } from '@/stores/logs/store'
import { MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
/**
* Hook for handling log details panel resize via mouse drag.

View File

@@ -69,7 +69,7 @@ const STATUS_VARIANT_MAP: Record<
const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['variant']> = {
manual: 'gray-secondary',
api: 'blue',
schedule: 'teal',
schedule: 'green',
chat: 'purple',
webhook: 'orange',
}

View File

@@ -8,8 +8,7 @@ import {
updateOpenRouterProviderModels,
updateVLLMProviderModels,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { ProviderName } from '@/stores/providers/types'
import { type ProviderName, useProvidersStore } from '@/stores/providers'
const logger = createLogger('ProviderModelsLoader')

View File

@@ -1,33 +0,0 @@
import { cn } from '@/lib/core/utils/cn'
interface NavigationTab {
id: string
label: string
count?: number
}
interface NavigationTabsProps {
tabs: NavigationTab[]
activeTab?: string
onTabClick?: (tabId: string) => void
className?: string
}
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => onTabClick?.(tab.id)}
className={cn(
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
)}
>
<span>{tab.label}</span>
</button>
))}
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
interface TemplatesPageProps {
params: Promise<{
@@ -32,6 +33,12 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideTemplates) {
redirect(`/workspace/${workspaceId}`)
}
// Determine effective super user (DB flag AND UI mode enabled)
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })

View File

@@ -7,6 +7,7 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
* ad-hoc ids or shortcuts to ensure a single source of truth.
*/
export type CommandId =
| 'accept-diff-changes'
| 'add-agent'
| 'goto-templates'
| 'goto-logs'
@@ -43,6 +44,11 @@ export interface CommandDefinition {
* All global commands must be declared here to be usable.
*/
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
'accept-diff-changes': {
id: 'accept-diff-changes',
shortcut: 'Mod+Shift+Enter',
allowInEditable: true,
},
'add-agent': {
id: 'add-agent',
shortcut: 'Mod+Shift+A',

View File

@@ -48,8 +48,9 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { getChatPosition, useChatStore } from '@/stores/chat/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useChatStore } from '@/stores/chat/store'
import { getChatPosition } from '@/stores/chat/utils'
import { useExecutionStore } from '@/stores/execution'
import { useOperationQueue } from '@/stores/operation-queue/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'

View File

@@ -2,14 +2,14 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { Layout, LibraryBig, Search } from 'lucide-react'
import { Layout, Search } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { Button, Library } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useSearchModalStore } from '@/stores/search-modal/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
const logger = createLogger('WorkflowCommandList')
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
},
{
label: 'Logs',
icon: LibraryBig,
icon: Library,
shortcut: 'L',
},
{

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