+ {title} +
++ {description} +
+<start.
## How it behaves per entry point
-
+
When you click Run in the editor, the Start block renders the Input Format as a form. Default values make it easy to retest without retyping data. Submitting the form triggers the workflow immediately and the values become available on <start.fieldName> (for example <start.sampleField>).
@@ -64,6 +64,13 @@ Reference structured values downstream with expressions such as <start.
If you launch chat with additional structured context (for example from an embed), it merges into the corresponding <start.fieldName> outputs, keeping downstream blocks consistent with API and manual runs.
+
+ Form deployments render the Input Format as a standalone, embeddable form page. Each field becomes a form input with appropriate UI controls—text inputs for strings, number inputs for numbers, toggle switches for booleans, and file upload zones for files.
+
+ When a user submits the form, values become available on <start.fieldName> just like other entry points. The workflow executes with trigger type form, and submitters see a customizable thank-you message upon completion.
+
+ Forms can be embedded via iframe or shared as direct links, making them ideal for surveys, contact forms, and data collection workflows.
+
## Referencing Start data downstream
diff --git a/apps/sim/app/(auth)/components/branded-button.tsx b/apps/sim/app/(auth)/components/branded-button.tsx
new file mode 100644
index 0000000000..2b7c8e9702
--- /dev/null
+++ b/apps/sim/app/(auth)/components/branded-button.tsx
@@ -0,0 +1,100 @@
+'use client'
+
+import { forwardRef, useState } from 'react'
+import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react'
+import { Button, type ButtonProps as EmcnButtonProps } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
+
+export interface BrandedButtonProps extends Omit {
+ /** Shows loading spinner and disables button */
+ loading?: boolean
+ /** Text to show when loading (appends "..." automatically) */
+ loadingText?: string
+ /** Show arrow animation on hover (default: true) */
+ showArrow?: boolean
+ /** Make button full width (default: true) */
+ fullWidth?: boolean
+}
+
+/**
+ * Branded button for auth and status pages.
+ * Automatically detects whitelabel customization and applies appropriate styling.
+ *
+ * @example
+ * ```tsx
+ * // Primary branded button with arrow
+ * Sign In
+ *
+ * // Loading state
+ * Sign In
+ *
+ * // Without arrow animation
+ * Continue
+ * ```
+ */
+export const BrandedButton = forwardRef(
+ (
+ {
+ children,
+ loading = false,
+ loadingText,
+ showArrow = true,
+ fullWidth = true,
+ className,
+ disabled,
+ onMouseEnter,
+ onMouseLeave,
+ ...props
+ },
+ ref
+ ) => {
+ const buttonClass = useBrandedButtonClass()
+ const [isHovered, setIsHovered] = useState(false)
+
+ const handleMouseEnter = (e: React.MouseEvent) => {
+ setIsHovered(true)
+ onMouseEnter?.(e)
+ }
+
+ const handleMouseLeave = (e: React.MouseEvent) => {
+ setIsHovered(false)
+ onMouseLeave?.(e)
+ }
+
+ return (
+
+ )
+ }
+)
+
+BrandedButton.displayName = 'BrandedButton'
diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx
index 395a8d4541..df758576c2 100644
--- a/apps/sim/app/(auth)/components/sso-login-button.tsx
+++ b/apps/sim/app/(auth)/components/sso-login-button.tsx
@@ -34,7 +34,7 @@ export function SSOLoginButton({
}
const primaryBtnClasses = cn(
- primaryClassName || 'auth-button-gradient',
+ primaryClassName || 'branded-button-gradient',
'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200'
)
diff --git a/apps/sim/app/(auth)/components/status-page-layout.tsx b/apps/sim/app/(auth)/components/status-page-layout.tsx
new file mode 100644
index 0000000000..d3177b8754
--- /dev/null
+++ b/apps/sim/app/(auth)/components/status-page-layout.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { inter } from '@/app/_styles/fonts/inter/inter'
+import { soehne } from '@/app/_styles/fonts/soehne/soehne'
+import AuthBackground from '@/app/(auth)/components/auth-background'
+import Nav from '@/app/(landing)/components/nav/nav'
+import { SupportFooter } from './support-footer'
+
+export interface StatusPageLayoutProps {
+ /** Page title displayed prominently */
+ title: string
+ /** Description text below the title */
+ description: string | ReactNode
+ /** Content to render below the title/description (usually buttons) */
+ children?: ReactNode
+ /** Whether to show the support footer (default: true) */
+ showSupportFooter?: boolean
+ /** Whether to hide the nav bar (useful for embedded forms) */
+ hideNav?: boolean
+}
+
+/**
+ * Unified layout for status/error pages (404, form unavailable, chat error, etc.).
+ * Uses AuthBackground and Nav for consistent styling with auth pages.
+ *
+ * @example
+ * ```tsx
+ *
+ * router.push('/')}>Return to Home
+ *
+ * ```
+ */
+export function StatusPageLayout({
+ title,
+ description,
+ children,
+ showSupportFooter = true,
+ hideNav = false,
+}: StatusPageLayoutProps) {
+ return (
+
+
+ {!hideNav && }
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+ {showSupportFooter && }
+
+
+ )
+}
diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx
new file mode 100644
index 0000000000..057334ee5f
--- /dev/null
+++ b/apps/sim/app/(auth)/components/support-footer.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { useBrandConfig } from '@/lib/branding/branding'
+import { inter } from '@/app/_styles/fonts/inter/inter'
+
+export interface SupportFooterProps {
+ /** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */
+ position?: 'fixed' | 'absolute'
+}
+
+/**
+ * Support footer component for auth and status pages.
+ * Displays a "Need help? Contact support" link using branded support email.
+ *
+ * @example
+ * ```tsx
+ * // Fixed position (for standalone pages)
+ *
+ *
+ * // Absolute position (for pages using AuthLayout)
+ *
+ * ```
+ */
+export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
+ const brandConfig = useBrandConfig()
+
+ return (
+
+ Need help?{' '}
+
+ Contact support
+
+
+ )
+}
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index 10b2313bfd..c2094755a9 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -105,7 +105,7 @@ export default function LoginPage({
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState([])
const [showValidationError, setShowValidationError] = useState(false)
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
+ const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace')
@@ -146,9 +146,9 @@ export default function LoginPage({
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
+ setButtonClass('branded-button-custom')
} else {
- setButtonClass('auth-button-gradient')
+ setButtonClass('branded-button-gradient')
}
}
diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx
index 7f5b8647d5..7212b52d53 100644
--- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx
+++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx
@@ -27,7 +27,7 @@ export function RequestResetForm({
statusMessage,
className,
}: RequestResetFormProps) {
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
+ const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
useEffect(() => {
@@ -36,9 +36,9 @@ export function RequestResetForm({
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
+ setButtonClass('branded-button-custom')
} else {
- setButtonClass('auth-button-gradient')
+ setButtonClass('branded-button-gradient')
}
}
@@ -138,7 +138,7 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
+ const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
useEffect(() => {
@@ -147,9 +147,9 @@ export function SetNewPasswordForm({
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
+ setButtonClass('branded-button-custom')
} else {
- setButtonClass('auth-button-gradient')
+ setButtonClass('branded-button-gradient')
}
}
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 108e964909..670d4434b0 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -95,7 +95,7 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
+ const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [name, setName] = useState('')
@@ -132,9 +132,9 @@ function SignupFormContent({
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
+ setButtonClass('branded-button-custom')
} else {
- setButtonClass('auth-button-gradient')
+ setButtonClass('branded-button-gradient')
}
}
diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx
index 4d01ebd0b1..0d371bbaff 100644
--- a/apps/sim/app/(auth)/sso/sso-form.tsx
+++ b/apps/sim/app/(auth)/sso/sso-form.tsx
@@ -57,7 +57,7 @@ export default function SSOForm() {
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
+ const [buttonClass, setButtonClass] = useState('branded-button-gradient')
const [callbackUrl, setCallbackUrl] = useState('/workspace')
useEffect(() => {
@@ -96,9 +96,9 @@ export default function SSOForm() {
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
+ setButtonClass('branded-button-custom')
} else {
- setButtonClass('auth-button-gradient')
+ setButtonClass('branded-button-gradient')
}
}
diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx
index 7259205bc8..ed05354b94 100644
--- a/apps/sim/app/(auth)/verify/verify-content.tsx
+++ b/apps/sim/app/(auth)/verify/verify-content.tsx
@@ -58,7 +58,7 @@ function VerificationForm({
setCountdown(30)
}
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
+ const [buttonClass, setButtonClass] = useState('branded-button-gradient')
useEffect(() => {
const checkCustomBrand = () => {
@@ -66,9 +66,9 @@ function VerificationForm({
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
+ setButtonClass('branded-button-custom')
} else {
- setButtonClass('auth-button-gradient')
+ setButtonClass('branded-button-gradient')
}
}
diff --git a/apps/sim/app/(landing)/studio/head.tsx b/apps/sim/app/(landing)/studio/head.tsx
deleted file mode 100644
index c528800775..0000000000
--- a/apps/sim/app/(landing)/studio/head.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-export default function Head() {
- return (
- <>
-
-
- >
- )
-}
diff --git a/apps/sim/app/_shell/providers/theme-provider.tsx b/apps/sim/app/_shell/providers/theme-provider.tsx
index dae3071b5c..6b3c7f315e 100644
--- a/apps/sim/app/_shell/providers/theme-provider.tsx
+++ b/apps/sim/app/_shell/providers/theme-provider.tsx
@@ -22,12 +22,13 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat') ||
pathname.startsWith('/studio') ||
- pathname.startsWith('/resume')
+ pathname.startsWith('/resume') ||
+ pathname.startsWith('/form')
return (
{
validateAuthToken: vi.fn().mockReturnValue(true),
}))
- vi.doMock('@sim/logger', () => ({
- createLogger: vi.fn().mockReturnValue({
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- }),
- }))
+ // Mock logger - use loggerMock from @sim/testing
+ vi.doMock('@sim/logger', () => loggerMock)
vi.doMock('@sim/db', () => {
const mockSelect = vi.fn().mockImplementation((fields) => {
diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts
index ac9a1c3206..57041c4cc5 100644
--- a/apps/sim/app/api/chat/[identifier]/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.ts
@@ -5,16 +5,12 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
+import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment'
import { generateRequestId } from '@/lib/core/utils/request'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ChatFiles } from '@/lib/uploads'
-import {
- addCorsHeaders,
- setChatAuthCookie,
- validateAuthToken,
- validateChatAuth,
-} from '@/app/api/chat/utils'
+import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatIdentifierAPI')
diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts
index 1be5f483b2..12e6b01a9c 100644
--- a/apps/sim/app/api/chat/manage/[id]/route.test.ts
+++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts
@@ -1,9 +1,10 @@
-import { NextRequest } from 'next/server'
/**
* Tests for chat edit API route
*
* @vitest-environment node
*/
+import { loggerMock } from '@sim/testing'
+import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/feature-flags', () => ({
@@ -50,14 +51,8 @@ describe('Chat Edit API Route', () => {
chat: { id: 'id', identifier: 'identifier', userId: 'userId' },
}))
- vi.doMock('@sim/logger', () => ({
- createLogger: vi.fn().mockReturnValue({
- info: vi.fn(),
- error: vi.fn(),
- warn: vi.fn(),
- debug: vi.fn(),
- }),
- }))
+ // Mock logger - use loggerMock from @sim/testing
+ vi.doMock('@sim/logger', () => loggerMock)
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts
index 70d92990b4..b6678fb53e 100644
--- a/apps/sim/app/api/chat/utils.test.ts
+++ b/apps/sim/app/api/chat/utils.test.ts
@@ -1,3 +1,4 @@
+import { databaseMock, loggerMock } from '@sim/testing'
import type { NextResponse } from 'next/server'
/**
* Tests for chat API utils
@@ -5,14 +6,9 @@ import type { NextResponse } from 'next/server'
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { env } from '@/lib/core/config/env'
-vi.mock('@sim/db', () => ({
- db: {
- select: vi.fn(),
- update: vi.fn(),
- },
-}))
+vi.mock('@sim/db', () => databaseMock)
+vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/logs/execution/logging-session', () => ({
LoggingSession: vi.fn().mockImplementation(() => ({
@@ -52,19 +48,10 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
describe('Chat API Utils', () => {
beforeEach(() => {
- vi.doMock('@sim/logger', () => ({
- createLogger: vi.fn().mockReturnValue({
- info: vi.fn(),
- error: vi.fn(),
- warn: vi.fn(),
- debug: vi.fn(),
- }),
- }))
-
vi.stubGlobal('process', {
...process,
env: {
- ...env,
+ ...process.env,
NODE_ENV: 'development',
},
})
@@ -75,8 +62,8 @@ describe('Chat API Utils', () => {
})
describe('Auth token utils', () => {
- it('should validate auth tokens', async () => {
- const { validateAuthToken } = await import('@/app/api/chat/utils')
+ it.concurrent('should validate auth tokens', async () => {
+ const { validateAuthToken } = await import('@/lib/core/security/deployment')
const chatId = 'test-chat-id'
const type = 'password'
@@ -92,8 +79,8 @@ describe('Chat API Utils', () => {
expect(isInvalidChat).toBe(false)
})
- it('should reject expired tokens', async () => {
- const { validateAuthToken } = await import('@/app/api/chat/utils')
+ it.concurrent('should reject expired tokens', async () => {
+ const { validateAuthToken } = await import('@/lib/core/security/deployment')
const chatId = 'test-chat-id'
const expiredToken = Buffer.from(
@@ -136,7 +123,7 @@ describe('Chat API Utils', () => {
describe('CORS handling', () => {
it('should add CORS headers for localhost in development', async () => {
- const { addCorsHeaders } = await import('@/app/api/chat/utils')
+ const { addCorsHeaders } = await import('@/lib/core/security/deployment')
const mockRequest = {
headers: {
@@ -343,7 +330,7 @@ describe('Chat API Utils', () => {
})
describe('Execution Result Processing', () => {
- it('should process logs regardless of overall success status', () => {
+ it.concurrent('should process logs regardless of overall success status', () => {
const executionResult = {
success: false,
output: {},
@@ -381,7 +368,7 @@ describe('Chat API Utils', () => {
expect(executionResult.logs[1].error).toBe('Agent 2 failed')
})
- it('should handle ExecutionResult vs StreamingExecution types correctly', () => {
+ it.concurrent('should handle ExecutionResult vs StreamingExecution types correctly', () => {
const executionResult = {
success: true,
output: { content: 'test' },
diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts
index 712886a2ff..654c36c8ba 100644
--- a/apps/sim/app/api/chat/utils.ts
+++ b/apps/sim/app/api/chat/utils.ts
@@ -1,17 +1,25 @@
-import { createHash } from 'crypto'
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
-import { isDev } from '@/lib/core/config/feature-flags'
+import {
+ isEmailAllowed,
+ setDeploymentAuthCookie,
+ validateAuthToken,
+} from '@/lib/core/security/deployment'
import { decryptSecret } from '@/lib/core/security/encryption'
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('ChatAuthUtils')
-function hashPassword(encryptedPassword: string): string {
- return createHash('sha256').update(encryptedPassword).digest('hex').substring(0, 8)
+export function setChatAuthCookie(
+ response: NextResponse,
+ chatId: string,
+ type: string,
+ encryptedPassword?: string | null
+): void {
+ setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
}
/**
@@ -82,77 +90,6 @@ export async function checkChatAccess(
return { hasAccess: false }
}
-function encryptAuthToken(chatId: string, type: string, encryptedPassword?: string | null): string {
- const pwHash = encryptedPassword ? hashPassword(encryptedPassword) : ''
- return Buffer.from(`${chatId}:${type}:${Date.now()}:${pwHash}`).toString('base64')
-}
-
-export function validateAuthToken(
- token: string,
- chatId: string,
- encryptedPassword?: string | null
-): boolean {
- try {
- const decoded = Buffer.from(token, 'base64').toString()
- const parts = decoded.split(':')
- const [storedId, _type, timestamp, storedPwHash] = parts
-
- if (storedId !== chatId) {
- return false
- }
-
- const createdAt = Number.parseInt(timestamp)
- const now = Date.now()
- const expireTime = 24 * 60 * 60 * 1000
-
- if (now - createdAt > expireTime) {
- return false
- }
-
- if (encryptedPassword) {
- const currentPwHash = hashPassword(encryptedPassword)
- if (storedPwHash !== currentPwHash) {
- return false
- }
- }
-
- return true
- } catch (_e) {
- return false
- }
-}
-
-export function setChatAuthCookie(
- response: NextResponse,
- chatId: string,
- type: string,
- encryptedPassword?: string | null
-): void {
- const token = encryptAuthToken(chatId, type, encryptedPassword)
- response.cookies.set({
- name: `chat_auth_${chatId}`,
- value: token,
- httpOnly: true,
- secure: !isDev,
- sameSite: 'lax',
- path: '/',
- maxAge: 60 * 60 * 24,
- })
-}
-
-export function addCorsHeaders(response: NextResponse, request: NextRequest) {
- const origin = request.headers.get('origin') || ''
-
- if (isDev && origin.includes('localhost')) {
- response.headers.set('Access-Control-Allow-Origin', origin)
- response.headers.set('Access-Control-Allow-Credentials', 'true')
- response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
- response.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With')
- }
-
- return response
-}
-
export async function validateChatAuth(
requestId: string,
deployment: any,
@@ -231,12 +168,7 @@ export async function validateChatAuth(
const allowedEmails = deployment.allowedEmails || []
- if (allowedEmails.includes(email)) {
- return { authorized: false, error: 'otp_required' }
- }
-
- const domain = email.split('@')[1]
- if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
+ if (isEmailAllowed(email, allowedEmails)) {
return { authorized: false, error: 'otp_required' }
}
@@ -270,12 +202,7 @@ export async function validateChatAuth(
const allowedEmails = deployment.allowedEmails || []
- if (allowedEmails.includes(email)) {
- return { authorized: true }
- }
-
- const domain = email.split('@')[1]
- if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
+ if (isEmailAllowed(email, allowedEmails)) {
return { authorized: true }
}
@@ -296,12 +223,7 @@ export async function validateChatAuth(
const allowedEmails = deployment.allowedEmails || []
- if (allowedEmails.includes(userEmail)) {
- return { authorized: true }
- }
-
- const domain = userEmail.split('@')[1]
- if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
+ if (isEmailAllowed(userEmail, allowedEmails)) {
return { authorized: true }
}
diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts
index 5e7fa4006e..ad2818b0d1 100644
--- a/apps/sim/app/api/environment/route.ts
+++ b/apps/sim/app/api/environment/route.ts
@@ -7,7 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
-import type { EnvironmentVariable } from '@/stores/settings/environment/types'
+import type { EnvironmentVariable } from '@/stores/settings/environment'
const logger = createLogger('EnvironmentAPI')
diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts
new file mode 100644
index 0000000000..bfae3e36e0
--- /dev/null
+++ b/apps/sim/app/api/form/[identifier]/route.ts
@@ -0,0 +1,414 @@
+import { randomUUID } from 'crypto'
+import { db } from '@sim/db'
+import { form, workflow, workflowBlocks } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { z } from 'zod'
+import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { preprocessExecution } from '@/lib/execution/preprocessing'
+import { LoggingSession } from '@/lib/logs/execution/logging-session'
+import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
+import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
+import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
+
+const logger = createLogger('FormIdentifierAPI')
+
+const formPostBodySchema = z.object({
+ formData: z.record(z.unknown()).optional(),
+ password: z.string().optional(),
+ email: z.string().email('Invalid email format').optional().or(z.literal('')),
+})
+
+export const dynamic = 'force-dynamic'
+export const runtime = 'nodejs'
+
+/**
+ * Get the input format schema from the workflow's start block
+ */
+async function getWorkflowInputSchema(workflowId: string): Promise {
+ try {
+ const blocks = await db
+ .select()
+ .from(workflowBlocks)
+ .where(eq(workflowBlocks.workflowId, workflowId))
+
+ // Find the start block (starter or start_trigger type)
+ const startBlock = blocks.find(
+ (block) => block.type === 'starter' || block.type === 'start_trigger'
+ )
+
+ if (!startBlock) {
+ return []
+ }
+
+ // Extract inputFormat from subBlocks
+ const subBlocks = startBlock.subBlocks as Record | null
+ if (!subBlocks?.inputFormat?.value) {
+ return []
+ }
+
+ return Array.isArray(subBlocks.inputFormat.value) ? subBlocks.inputFormat.value : []
+ } catch (error) {
+ logger.error('Error fetching workflow input schema:', error)
+ return []
+ }
+}
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ identifier: string }> }
+) {
+ const { identifier } = await params
+ const requestId = generateRequestId()
+
+ try {
+ logger.debug(`[${requestId}] Processing form submission for identifier: ${identifier}`)
+
+ let parsedBody
+ try {
+ const rawBody = await request.json()
+ const validation = formPostBodySchema.safeParse(rawBody)
+
+ if (!validation.success) {
+ const errorMessage = validation.error.errors
+ .map((err) => `${err.path.join('.')}: ${err.message}`)
+ .join(', ')
+ logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
+ return addCorsHeaders(
+ createErrorResponse(`Invalid request body: ${errorMessage}`, 400),
+ request
+ )
+ }
+
+ parsedBody = validation.data
+ } catch (_error) {
+ return addCorsHeaders(createErrorResponse('Invalid request body', 400), request)
+ }
+
+ const deploymentResult = await db
+ .select({
+ id: form.id,
+ workflowId: form.workflowId,
+ userId: form.userId,
+ isActive: form.isActive,
+ authType: form.authType,
+ password: form.password,
+ allowedEmails: form.allowedEmails,
+ customizations: form.customizations,
+ })
+ .from(form)
+ .where(eq(form.identifier, identifier))
+ .limit(1)
+
+ if (deploymentResult.length === 0) {
+ logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
+ return addCorsHeaders(createErrorResponse('Form not found', 404), request)
+ }
+
+ const deployment = deploymentResult[0]
+
+ if (!deployment.isActive) {
+ logger.warn(`[${requestId}] Form is not active: ${identifier}`)
+
+ const [workflowRecord] = await db
+ .select({ workspaceId: workflow.workspaceId })
+ .from(workflow)
+ .where(eq(workflow.id, deployment.workflowId))
+ .limit(1)
+
+ const workspaceId = workflowRecord?.workspaceId
+ if (!workspaceId) {
+ logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`)
+ return addCorsHeaders(
+ createErrorResponse('This form is currently unavailable', 403),
+ request
+ )
+ }
+
+ const executionId = randomUUID()
+ const loggingSession = new LoggingSession(
+ deployment.workflowId,
+ executionId,
+ 'form',
+ requestId
+ )
+
+ await loggingSession.safeStart({
+ userId: deployment.userId,
+ workspaceId,
+ variables: {},
+ })
+
+ await loggingSession.safeCompleteWithError({
+ error: {
+ message: 'This form is currently unavailable. The form has been disabled.',
+ stackTrace: undefined,
+ },
+ traceSpans: [],
+ })
+
+ return addCorsHeaders(createErrorResponse('This form is currently unavailable', 403), request)
+ }
+
+ const authResult = await validateFormAuth(requestId, deployment, request, parsedBody)
+ if (!authResult.authorized) {
+ return addCorsHeaders(
+ createErrorResponse(authResult.error || 'Authentication required', 401),
+ request
+ )
+ }
+
+ const { formData, password, email } = parsedBody
+
+ // If only authentication credentials provided (no form data), just return authenticated
+ if ((password || email) && !formData) {
+ const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
+ setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password)
+ return response
+ }
+
+ if (!formData || Object.keys(formData).length === 0) {
+ return addCorsHeaders(createErrorResponse('No form data provided', 400), request)
+ }
+
+ const executionId = randomUUID()
+ const loggingSession = new LoggingSession(deployment.workflowId, executionId, 'form', requestId)
+
+ const preprocessResult = await preprocessExecution({
+ workflowId: deployment.workflowId,
+ userId: deployment.userId,
+ triggerType: 'form',
+ executionId,
+ requestId,
+ checkRateLimit: true,
+ checkDeployment: true,
+ loggingSession,
+ })
+
+ if (!preprocessResult.success) {
+ logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
+ return addCorsHeaders(
+ createErrorResponse(
+ preprocessResult.error?.message || 'Failed to process request',
+ preprocessResult.error?.statusCode || 500
+ ),
+ request
+ )
+ }
+
+ const { actorUserId, workflowRecord } = preprocessResult
+ const workspaceOwnerId = actorUserId!
+ const workspaceId = workflowRecord?.workspaceId
+ if (!workspaceId) {
+ logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
+ return addCorsHeaders(
+ createErrorResponse('Workflow has no associated workspace', 500),
+ request
+ )
+ }
+
+ try {
+ const workflowForExecution = {
+ id: deployment.workflowId,
+ userId: deployment.userId,
+ workspaceId,
+ isDeployed: workflowRecord?.isDeployed ?? false,
+ variables: (workflowRecord?.variables ?? {}) as Record,
+ }
+
+ // Pass form data as the workflow input
+ const workflowInput = {
+ input: formData,
+ ...formData, // Spread form fields at top level for convenience
+ }
+
+ // Execute workflow using streaming (for consistency with chat)
+ const stream = await createStreamingResponse({
+ requestId,
+ workflow: workflowForExecution,
+ input: workflowInput,
+ executingUserId: workspaceOwnerId,
+ streamConfig: {
+ selectedOutputs: [],
+ isSecureMode: true,
+ workflowTriggerType: 'api', // Use 'api' type since form is similar
+ },
+ executionId,
+ })
+
+ // For forms, we don't stream back - we wait for completion and return success
+ // Consume the stream to wait for completion
+ const reader = stream.getReader()
+ let lastOutput: any = null
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ // Parse SSE data if present
+ const text = new TextDecoder().decode(value)
+ const lines = text.split('\n')
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ try {
+ const data = JSON.parse(line.slice(6))
+ if (data.type === 'complete' || data.output) {
+ lastOutput = data.output || data
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock()
+ }
+
+ logger.info(`[${requestId}] Form submission successful for ${identifier}`)
+
+ // Return success with customizations for thank you screen
+ const customizations = deployment.customizations as Record | null
+ return addCorsHeaders(
+ createSuccessResponse({
+ success: true,
+ executionId,
+ thankYouTitle: customizations?.thankYouTitle || 'Thank you!',
+ thankYouMessage:
+ customizations?.thankYouMessage || 'Your response has been submitted successfully.',
+ }),
+ request
+ )
+ } catch (error: any) {
+ logger.error(`[${requestId}] Error processing form submission:`, error)
+ return addCorsHeaders(
+ createErrorResponse(error.message || 'Failed to process form submission', 500),
+ request
+ )
+ }
+ } catch (error: any) {
+ logger.error(`[${requestId}] Error processing form submission:`, error)
+ return addCorsHeaders(
+ createErrorResponse(error.message || 'Failed to process form submission', 500),
+ request
+ )
+ }
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ identifier: string }> }
+) {
+ const { identifier } = await params
+ const requestId = generateRequestId()
+
+ try {
+ logger.debug(`[${requestId}] Fetching form info for identifier: ${identifier}`)
+
+ const deploymentResult = await db
+ .select({
+ id: form.id,
+ title: form.title,
+ description: form.description,
+ customizations: form.customizations,
+ isActive: form.isActive,
+ workflowId: form.workflowId,
+ authType: form.authType,
+ password: form.password,
+ allowedEmails: form.allowedEmails,
+ showBranding: form.showBranding,
+ })
+ .from(form)
+ .where(eq(form.identifier, identifier))
+ .limit(1)
+
+ if (deploymentResult.length === 0) {
+ logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
+ return addCorsHeaders(createErrorResponse('Form not found', 404), request)
+ }
+
+ const deployment = deploymentResult[0]
+
+ if (!deployment.isActive) {
+ logger.warn(`[${requestId}] Form is not active: ${identifier}`)
+ return addCorsHeaders(createErrorResponse('This form is currently unavailable', 403), request)
+ }
+
+ // Get the workflow's input schema
+ const inputSchema = await getWorkflowInputSchema(deployment.workflowId)
+
+ const cookieName = `form_auth_${deployment.id}`
+ const authCookie = request.cookies.get(cookieName)
+
+ // If authenticated (via cookie), return full form config
+ if (
+ deployment.authType !== 'public' &&
+ authCookie &&
+ validateAuthToken(authCookie.value, deployment.id, deployment.password)
+ ) {
+ return addCorsHeaders(
+ createSuccessResponse({
+ id: deployment.id,
+ title: deployment.title,
+ description: deployment.description,
+ customizations: deployment.customizations,
+ authType: deployment.authType,
+ showBranding: deployment.showBranding,
+ inputSchema,
+ }),
+ request
+ )
+ }
+
+ // Check authentication requirement
+ const authResult = await validateFormAuth(requestId, deployment, request)
+ if (!authResult.authorized) {
+ // Return limited info for auth required forms
+ logger.info(
+ `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}`
+ )
+ return addCorsHeaders(
+ NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ authType: deployment.authType,
+ title: deployment.title,
+ customizations: {
+ primaryColor: (deployment.customizations as any)?.primaryColor,
+ logoUrl: (deployment.customizations as any)?.logoUrl,
+ },
+ },
+ { status: 401 }
+ ),
+ request
+ )
+ }
+
+ return addCorsHeaders(
+ createSuccessResponse({
+ id: deployment.id,
+ title: deployment.title,
+ description: deployment.description,
+ customizations: deployment.customizations,
+ authType: deployment.authType,
+ showBranding: deployment.showBranding,
+ inputSchema,
+ }),
+ request
+ )
+ } catch (error: any) {
+ logger.error(`[${requestId}] Error fetching form info:`, error)
+ return addCorsHeaders(
+ createErrorResponse(error.message || 'Failed to fetch form information', 500),
+ request
+ )
+ }
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return addCorsHeaders(new NextResponse(null, { status: 204 }), request)
+}
diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts
new file mode 100644
index 0000000000..f2f1cbd1fb
--- /dev/null
+++ b/apps/sim/app/api/form/manage/[id]/route.ts
@@ -0,0 +1,233 @@
+import { db } from '@sim/db'
+import { form } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+import { encryptSecret } from '@/lib/core/security/encryption'
+import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
+import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
+
+const logger = createLogger('FormManageAPI')
+
+const fieldConfigSchema = z.object({
+ name: z.string(),
+ type: z.string(),
+ label: z.string(),
+ description: z.string().optional(),
+ required: z.boolean().optional(),
+})
+
+const updateFormSchema = z.object({
+ identifier: z
+ .string()
+ .min(1, 'Identifier is required')
+ .max(100, 'Identifier must be 100 characters or less')
+ .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
+ .optional(),
+ title: z
+ .string()
+ .min(1, 'Title is required')
+ .max(200, 'Title must be 200 characters or less')
+ .optional(),
+ description: z.string().max(1000, 'Description must be 1000 characters or less').optional(),
+ customizations: z
+ .object({
+ primaryColor: z.string().optional(),
+ welcomeMessage: z
+ .string()
+ .max(500, 'Welcome message must be 500 characters or less')
+ .optional(),
+ thankYouTitle: z
+ .string()
+ .max(100, 'Thank you title must be 100 characters or less')
+ .optional(),
+ thankYouMessage: z
+ .string()
+ .max(500, 'Thank you message must be 500 characters or less')
+ .optional(),
+ logoUrl: z.string().url('Logo URL must be a valid URL').optional().or(z.literal('')),
+ fieldConfigs: z.array(fieldConfigSchema).optional(),
+ })
+ .optional(),
+ authType: z.enum(['public', 'password', 'email']).optional(),
+ password: z
+ .string()
+ .min(6, 'Password must be at least 6 characters')
+ .optional()
+ .or(z.literal('')),
+ allowedEmails: z.array(z.string()).optional(),
+ showBranding: z.boolean().optional(),
+ isActive: z.boolean().optional(),
+})
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ const session = await getSession()
+
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+
+ const { id } = await params
+
+ const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
+
+ if (!hasAccess || !formRecord) {
+ return createErrorResponse('Form not found or access denied', 404)
+ }
+
+ const { password: _password, ...formWithoutPassword } = formRecord
+
+ return createSuccessResponse({
+ form: {
+ ...formWithoutPassword,
+ hasPassword: !!formRecord.password,
+ },
+ })
+ } catch (error: any) {
+ logger.error('Error fetching form:', error)
+ return createErrorResponse(error.message || 'Failed to fetch form', 500)
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ const session = await getSession()
+
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+
+ const { id } = await params
+
+ const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
+
+ if (!hasAccess || !formRecord) {
+ return createErrorResponse('Form not found or access denied', 404)
+ }
+
+ const body = await request.json()
+
+ try {
+ const validatedData = updateFormSchema.parse(body)
+
+ const {
+ identifier,
+ title,
+ description,
+ customizations,
+ authType,
+ password,
+ allowedEmails,
+ showBranding,
+ isActive,
+ } = validatedData
+
+ if (identifier && identifier !== formRecord.identifier) {
+ const existingIdentifier = await db
+ .select()
+ .from(form)
+ .where(eq(form.identifier, identifier))
+ .limit(1)
+
+ if (existingIdentifier.length > 0) {
+ return createErrorResponse('Identifier already in use', 400)
+ }
+ }
+
+ if (authType === 'password' && !password && !formRecord.password) {
+ return createErrorResponse('Password is required when using password protection', 400)
+ }
+
+ if (
+ authType === 'email' &&
+ (!allowedEmails || allowedEmails.length === 0) &&
+ (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0)
+ ) {
+ return createErrorResponse(
+ 'At least one email or domain is required when using email access control',
+ 400
+ )
+ }
+
+ const updateData: Record = {
+ updatedAt: new Date(),
+ }
+
+ if (identifier !== undefined) updateData.identifier = identifier
+ if (title !== undefined) updateData.title = title
+ if (description !== undefined) updateData.description = description
+ if (showBranding !== undefined) updateData.showBranding = showBranding
+ if (isActive !== undefined) updateData.isActive = isActive
+ if (authType !== undefined) updateData.authType = authType
+ if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails
+
+ if (customizations !== undefined) {
+ const existingCustomizations = (formRecord.customizations as Record) || {}
+ updateData.customizations = {
+ ...DEFAULT_FORM_CUSTOMIZATIONS,
+ ...existingCustomizations,
+ ...customizations,
+ }
+ }
+
+ if (password) {
+ const { encrypted } = await encryptSecret(password)
+ updateData.password = encrypted
+ } else if (authType && authType !== 'password') {
+ updateData.password = null
+ }
+
+ await db.update(form).set(updateData).where(eq(form.id, id))
+
+ logger.info(`Form ${id} updated successfully`)
+
+ return createSuccessResponse({
+ message: 'Form updated successfully',
+ })
+ } catch (validationError) {
+ if (validationError instanceof z.ZodError) {
+ const errorMessage = validationError.errors[0]?.message || 'Invalid request data'
+ return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR')
+ }
+ throw validationError
+ }
+ } catch (error: any) {
+ logger.error('Error updating form:', error)
+ return createErrorResponse(error.message || 'Failed to update form', 500)
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const session = await getSession()
+
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+
+ const { id } = await params
+
+ const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
+
+ if (!hasAccess || !formRecord) {
+ return createErrorResponse('Form not found or access denied', 404)
+ }
+
+ await db.update(form).set({ isActive: false, updatedAt: new Date() }).where(eq(form.id, id))
+
+ logger.info(`Form ${id} deleted (soft delete)`)
+
+ return createSuccessResponse({
+ message: 'Form deleted successfully',
+ })
+ } catch (error: any) {
+ logger.error('Error deleting form:', error)
+ return createErrorResponse(error.message || 'Failed to delete form', 500)
+ }
+}
diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts
new file mode 100644
index 0000000000..ada13f5ee1
--- /dev/null
+++ b/apps/sim/app/api/form/route.ts
@@ -0,0 +1,214 @@
+import { db } from '@sim/db'
+import { form } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { v4 as uuidv4 } from 'uuid'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+import { isDev } from '@/lib/core/config/feature-flags'
+import { encryptSecret } from '@/lib/core/security/encryption'
+import { getEmailDomain } from '@/lib/core/utils/urls'
+import { deployWorkflow } from '@/lib/workflows/persistence/utils'
+import {
+ checkWorkflowAccessForFormCreation,
+ DEFAULT_FORM_CUSTOMIZATIONS,
+} from '@/app/api/form/utils'
+import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
+
+const logger = createLogger('FormAPI')
+
+const fieldConfigSchema = z.object({
+ name: z.string(),
+ type: z.string(),
+ label: z.string(),
+ description: z.string().optional(),
+ required: z.boolean().optional(),
+})
+
+const formSchema = z.object({
+ workflowId: z.string().min(1, 'Workflow ID is required'),
+ identifier: z
+ .string()
+ .min(1, 'Identifier is required')
+ .max(100, 'Identifier must be 100 characters or less')
+ .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
+ title: z.string().min(1, 'Title is required').max(200, 'Title must be 200 characters or less'),
+ description: z.string().max(1000, 'Description must be 1000 characters or less').optional(),
+ customizations: z
+ .object({
+ primaryColor: z.string().optional(),
+ welcomeMessage: z
+ .string()
+ .max(500, 'Welcome message must be 500 characters or less')
+ .optional(),
+ thankYouTitle: z
+ .string()
+ .max(100, 'Thank you title must be 100 characters or less')
+ .optional(),
+ thankYouMessage: z
+ .string()
+ .max(500, 'Thank you message must be 500 characters or less')
+ .optional(),
+ logoUrl: z.string().url('Logo URL must be a valid URL').optional().or(z.literal('')),
+ fieldConfigs: z.array(fieldConfigSchema).optional(),
+ })
+ .optional(),
+ authType: z.enum(['public', 'password', 'email']).default('public'),
+ password: z
+ .string()
+ .min(6, 'Password must be at least 6 characters')
+ .optional()
+ .or(z.literal('')),
+ allowedEmails: z.array(z.string()).optional().default([]),
+ showBranding: z.boolean().optional().default(true),
+})
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getSession()
+
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+
+ const deployments = await db.select().from(form).where(eq(form.userId, session.user.id))
+
+ return createSuccessResponse({ deployments })
+ } catch (error: any) {
+ logger.error('Error fetching form deployments:', error)
+ return createErrorResponse(error.message || 'Failed to fetch form deployments', 500)
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getSession()
+
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+
+ const body = await request.json()
+
+ try {
+ const validatedData = formSchema.parse(body)
+
+ const {
+ workflowId,
+ identifier,
+ title,
+ description = '',
+ customizations,
+ authType = 'public',
+ password,
+ allowedEmails = [],
+ showBranding = true,
+ } = validatedData
+
+ if (authType === 'password' && !password) {
+ return createErrorResponse('Password is required when using password protection', 400)
+ }
+
+ if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
+ return createErrorResponse(
+ 'At least one email or domain is required when using email access control',
+ 400
+ )
+ }
+
+ const existingIdentifier = await db
+ .select()
+ .from(form)
+ .where(eq(form.identifier, identifier))
+ .limit(1)
+
+ if (existingIdentifier.length > 0) {
+ return createErrorResponse('Identifier already in use', 400)
+ }
+
+ const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForFormCreation(
+ workflowId,
+ session.user.id
+ )
+
+ if (!hasAccess || !workflowRecord) {
+ return createErrorResponse('Workflow not found or access denied', 404)
+ }
+
+ const result = await deployWorkflow({
+ workflowId,
+ deployedBy: session.user.id,
+ })
+
+ if (!result.success) {
+ return createErrorResponse(result.error || 'Failed to deploy workflow', 500)
+ }
+
+ logger.info(
+ `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})`
+ )
+
+ let encryptedPassword = null
+ if (authType === 'password' && password) {
+ const { encrypted } = await encryptSecret(password)
+ encryptedPassword = encrypted
+ }
+
+ const id = uuidv4()
+
+ logger.info('Creating form deployment with values:', {
+ workflowId,
+ identifier,
+ title,
+ authType,
+ hasPassword: !!encryptedPassword,
+ emailCount: allowedEmails?.length || 0,
+ showBranding,
+ })
+
+ const mergedCustomizations = {
+ ...DEFAULT_FORM_CUSTOMIZATIONS,
+ ...(customizations || {}),
+ }
+
+ await db.insert(form).values({
+ id,
+ workflowId,
+ userId: session.user.id,
+ identifier,
+ title,
+ description: description || '',
+ customizations: mergedCustomizations,
+ isActive: true,
+ authType,
+ password: encryptedPassword,
+ allowedEmails: authType === 'email' ? allowedEmails : [],
+ showBranding,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+
+ const baseDomain = getEmailDomain()
+ const protocol = isDev ? 'http' : 'https'
+ const formUrl = `${protocol}://${baseDomain}/form/${identifier}`
+
+ logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
+
+ return createSuccessResponse({
+ id,
+ formUrl,
+ message: 'Form deployment created successfully',
+ })
+ } catch (validationError) {
+ if (validationError instanceof z.ZodError) {
+ const errorMessage = validationError.errors[0]?.message || 'Invalid request data'
+ return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR')
+ }
+ throw validationError
+ }
+ } catch (error: any) {
+ logger.error('Error creating form deployment:', error)
+ return createErrorResponse(error.message || 'Failed to create form deployment', 500)
+ }
+}
diff --git a/apps/sim/app/api/form/utils.test.ts b/apps/sim/app/api/form/utils.test.ts
new file mode 100644
index 0000000000..4c5a220eae
--- /dev/null
+++ b/apps/sim/app/api/form/utils.test.ts
@@ -0,0 +1,367 @@
+import { databaseMock, loggerMock } from '@sim/testing'
+import type { NextResponse } from 'next/server'
+/**
+ * Tests for form API utils
+ *
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@sim/db', () => databaseMock)
+vi.mock('@sim/logger', () => loggerMock)
+
+const mockDecryptSecret = vi.fn()
+
+vi.mock('@/lib/core/security/encryption', () => ({
+ decryptSecret: mockDecryptSecret,
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isDev: true,
+ isHosted: false,
+ isProd: false,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ hasAdminPermission: vi.fn(),
+}))
+
+describe('Form API Utils', () => {
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Auth token utils', () => {
+ it.concurrent('should validate auth tokens', async () => {
+ const { validateAuthToken } = await import('@/lib/core/security/deployment')
+
+ const formId = 'test-form-id'
+ const type = 'password'
+
+ const token = Buffer.from(`${formId}:${type}:${Date.now()}`).toString('base64')
+ expect(typeof token).toBe('string')
+ expect(token.length).toBeGreaterThan(0)
+
+ const isValid = validateAuthToken(token, formId)
+ expect(isValid).toBe(true)
+
+ const isInvalidForm = validateAuthToken(token, 'wrong-form-id')
+ expect(isInvalidForm).toBe(false)
+ })
+
+ it.concurrent('should reject expired tokens', async () => {
+ const { validateAuthToken } = await import('@/lib/core/security/deployment')
+
+ const formId = 'test-form-id'
+ const expiredToken = Buffer.from(
+ `${formId}:password:${Date.now() - 25 * 60 * 60 * 1000}`
+ ).toString('base64')
+
+ const isValid = validateAuthToken(expiredToken, formId)
+ expect(isValid).toBe(false)
+ })
+
+ it.concurrent('should validate tokens with password hash', async () => {
+ const { validateAuthToken } = await import('@/lib/core/security/deployment')
+ const crypto = await import('crypto')
+
+ const formId = 'test-form-id'
+ const encryptedPassword = 'encrypted-password-value'
+ const pwHash = crypto
+ .createHash('sha256')
+ .update(encryptedPassword)
+ .digest('hex')
+ .substring(0, 8)
+
+ const token = Buffer.from(`${formId}:password:${Date.now()}:${pwHash}`).toString('base64')
+
+ const isValid = validateAuthToken(token, formId, encryptedPassword)
+ expect(isValid).toBe(true)
+
+ const isInvalidPassword = validateAuthToken(token, formId, 'different-password')
+ expect(isInvalidPassword).toBe(false)
+ })
+ })
+
+ describe('Cookie handling', () => {
+ it('should set auth cookie correctly', async () => {
+ const { setFormAuthCookie } = await import('@/app/api/form/utils')
+
+ const mockSet = vi.fn()
+ const mockResponse = {
+ cookies: {
+ set: mockSet,
+ },
+ } as unknown as NextResponse
+
+ const formId = 'test-form-id'
+ const type = 'password'
+
+ setFormAuthCookie(mockResponse, formId, type)
+
+ expect(mockSet).toHaveBeenCalledWith({
+ name: `form_auth_${formId}`,
+ value: expect.any(String),
+ httpOnly: true,
+ secure: false, // Development mode
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 60 * 60 * 24,
+ })
+ })
+ })
+
+ describe('CORS handling', () => {
+ it.concurrent('should add CORS headers for any origin', async () => {
+ const { addCorsHeaders } = await import('@/lib/core/security/deployment')
+
+ const mockRequest = {
+ headers: {
+ get: vi.fn().mockReturnValue('http://localhost:3000'),
+ },
+ } as any
+
+ const mockResponse = {
+ headers: {
+ set: vi.fn(),
+ },
+ } as unknown as NextResponse
+
+ addCorsHeaders(mockResponse, mockRequest)
+
+ expect(mockResponse.headers.set).toHaveBeenCalledWith(
+ 'Access-Control-Allow-Origin',
+ 'http://localhost:3000'
+ )
+ expect(mockResponse.headers.set).toHaveBeenCalledWith(
+ 'Access-Control-Allow-Credentials',
+ 'true'
+ )
+ expect(mockResponse.headers.set).toHaveBeenCalledWith(
+ 'Access-Control-Allow-Methods',
+ 'GET, POST, OPTIONS'
+ )
+ expect(mockResponse.headers.set).toHaveBeenCalledWith(
+ 'Access-Control-Allow-Headers',
+ 'Content-Type, X-Requested-With'
+ )
+ })
+
+ it.concurrent('should not set CORS headers when no origin', async () => {
+ const { addCorsHeaders } = await import('@/lib/core/security/deployment')
+
+ const mockRequest = {
+ headers: {
+ get: vi.fn().mockReturnValue(''),
+ },
+ } as any
+
+ const mockResponse = {
+ headers: {
+ set: vi.fn(),
+ },
+ } as unknown as NextResponse
+
+ addCorsHeaders(mockResponse, mockRequest)
+
+ expect(mockResponse.headers.set).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Form auth validation', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' })
+ })
+
+ it('should allow access to public forms', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'public',
+ }
+
+ const mockRequest = {
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ const result = await validateFormAuth('request-id', deployment, mockRequest)
+
+ expect(result.authorized).toBe(true)
+ })
+
+ it('should request password auth for GET requests', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'password',
+ }
+
+ const mockRequest = {
+ method: 'GET',
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ const result = await validateFormAuth('request-id', deployment, mockRequest)
+
+ expect(result.authorized).toBe(false)
+ expect(result.error).toBe('auth_required_password')
+ })
+
+ it('should validate password for POST requests', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+ const { decryptSecret } = await import('@/lib/core/security/encryption')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'password',
+ password: 'encrypted-password',
+ }
+
+ const mockRequest = {
+ method: 'POST',
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ const parsedBody = {
+ password: 'correct-password',
+ }
+
+ const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody)
+
+ expect(decryptSecret).toHaveBeenCalledWith('encrypted-password')
+ expect(result.authorized).toBe(true)
+ })
+
+ it('should reject incorrect password', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'password',
+ password: 'encrypted-password',
+ }
+
+ const mockRequest = {
+ method: 'POST',
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ const parsedBody = {
+ password: 'wrong-password',
+ }
+
+ const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody)
+
+ expect(result.authorized).toBe(false)
+ expect(result.error).toBe('Invalid password')
+ })
+
+ it('should request email auth for email-protected forms', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'email',
+ allowedEmails: ['user@example.com', '@company.com'],
+ }
+
+ const mockRequest = {
+ method: 'GET',
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ const result = await validateFormAuth('request-id', deployment, mockRequest)
+
+ expect(result.authorized).toBe(false)
+ expect(result.error).toBe('auth_required_email')
+ })
+
+ it('should check allowed emails for email auth', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'email',
+ allowedEmails: ['user@example.com', '@company.com'],
+ }
+
+ const mockRequest = {
+ method: 'POST',
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ // Exact email match should authorize
+ const result1 = await validateFormAuth('request-id', deployment, mockRequest, {
+ email: 'user@example.com',
+ })
+ expect(result1.authorized).toBe(true)
+
+ // Domain match should authorize
+ const result2 = await validateFormAuth('request-id', deployment, mockRequest, {
+ email: 'other@company.com',
+ })
+ expect(result2.authorized).toBe(true)
+
+ // Unknown email should not authorize
+ const result3 = await validateFormAuth('request-id', deployment, mockRequest, {
+ email: 'user@unknown.com',
+ })
+ expect(result3.authorized).toBe(false)
+ expect(result3.error).toBe('Email not authorized for this form')
+ })
+
+ it('should require password when formData is present without password', async () => {
+ const { validateFormAuth } = await import('@/app/api/form/utils')
+
+ const deployment = {
+ id: 'form-id',
+ authType: 'password',
+ password: 'encrypted-password',
+ }
+
+ const mockRequest = {
+ method: 'POST',
+ cookies: {
+ get: vi.fn().mockReturnValue(null),
+ },
+ } as any
+
+ const parsedBody = {
+ formData: { field1: 'value1' },
+ // No password provided
+ }
+
+ const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody)
+
+ expect(result.authorized).toBe(false)
+ expect(result.error).toBe('auth_required_password')
+ })
+ })
+
+ describe('Default customizations', () => {
+ it.concurrent('should have correct default values', async () => {
+ const { DEFAULT_FORM_CUSTOMIZATIONS } = await import('@/app/api/form/utils')
+
+ expect(DEFAULT_FORM_CUSTOMIZATIONS).toEqual({
+ welcomeMessage: '',
+ thankYouTitle: 'Thank you!',
+ thankYouMessage: 'Your response has been submitted successfully.',
+ })
+ })
+ })
+})
diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts
new file mode 100644
index 0000000000..34255df600
--- /dev/null
+++ b/apps/sim/app/api/form/utils.ts
@@ -0,0 +1,204 @@
+import { db } from '@sim/db'
+import { form, workflow } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import type { NextRequest, NextResponse } from 'next/server'
+import {
+ isEmailAllowed,
+ setDeploymentAuthCookie,
+ validateAuthToken,
+} from '@/lib/core/security/deployment'
+import { decryptSecret } from '@/lib/core/security/encryption'
+import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('FormAuthUtils')
+
+export function setFormAuthCookie(
+ response: NextResponse,
+ formId: string,
+ type: string,
+ encryptedPassword?: string | null
+): void {
+ setDeploymentAuthCookie(response, 'form', formId, type, encryptedPassword)
+}
+
+/**
+ * Check if user has permission to create a form for a specific workflow
+ * Either the user owns the workflow directly OR has admin permission for the workflow's workspace
+ */
+export async function checkWorkflowAccessForFormCreation(
+ workflowId: string,
+ userId: string
+): Promise<{ hasAccess: boolean; workflow?: any }> {
+ const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1)
+
+ if (workflowData.length === 0) {
+ return { hasAccess: false }
+ }
+
+ const workflowRecord = workflowData[0]
+
+ if (workflowRecord.userId === userId) {
+ return { hasAccess: true, workflow: workflowRecord }
+ }
+
+ if (workflowRecord.workspaceId) {
+ const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId)
+ if (hasAdmin) {
+ return { hasAccess: true, workflow: workflowRecord }
+ }
+ }
+
+ return { hasAccess: false }
+}
+
+/**
+ * Check if user has access to view/edit/delete a specific form
+ * Either the user owns the form directly OR has admin permission for the workflow's workspace
+ */
+export async function checkFormAccess(
+ formId: string,
+ userId: string
+): Promise<{ hasAccess: boolean; form?: any }> {
+ const formData = await db
+ .select({
+ form: form,
+ workflowWorkspaceId: workflow.workspaceId,
+ })
+ .from(form)
+ .innerJoin(workflow, eq(form.workflowId, workflow.id))
+ .where(eq(form.id, formId))
+ .limit(1)
+
+ if (formData.length === 0) {
+ return { hasAccess: false }
+ }
+
+ const { form: formRecord, workflowWorkspaceId } = formData[0]
+
+ if (formRecord.userId === userId) {
+ return { hasAccess: true, form: formRecord }
+ }
+
+ if (workflowWorkspaceId) {
+ const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId)
+ if (hasAdmin) {
+ return { hasAccess: true, form: formRecord }
+ }
+ }
+
+ return { hasAccess: false }
+}
+
+export async function validateFormAuth(
+ requestId: string,
+ deployment: any,
+ request: NextRequest,
+ parsedBody?: any
+): Promise<{ authorized: boolean; error?: string }> {
+ const authType = deployment.authType || 'public'
+
+ if (authType === 'public') {
+ return { authorized: true }
+ }
+
+ const cookieName = `form_auth_${deployment.id}`
+ const authCookie = request.cookies.get(cookieName)
+
+ if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
+ return { authorized: true }
+ }
+
+ if (authType === 'password') {
+ if (request.method === 'GET') {
+ return { authorized: false, error: 'auth_required_password' }
+ }
+
+ try {
+ if (!parsedBody) {
+ return { authorized: false, error: 'Password is required' }
+ }
+
+ const { password, formData } = parsedBody
+
+ if (formData && !password) {
+ return { authorized: false, error: 'auth_required_password' }
+ }
+
+ if (!password) {
+ return { authorized: false, error: 'Password is required' }
+ }
+
+ if (!deployment.password) {
+ logger.error(`[${requestId}] No password set for password-protected form: ${deployment.id}`)
+ return { authorized: false, error: 'Authentication configuration error' }
+ }
+
+ const { decrypted } = await decryptSecret(deployment.password)
+ if (password !== decrypted) {
+ return { authorized: false, error: 'Invalid password' }
+ }
+
+ return { authorized: true }
+ } catch (error) {
+ logger.error(`[${requestId}] Error validating password:`, error)
+ return { authorized: false, error: 'Authentication error' }
+ }
+ }
+
+ if (authType === 'email') {
+ if (request.method === 'GET') {
+ return { authorized: false, error: 'auth_required_email' }
+ }
+
+ try {
+ if (!parsedBody) {
+ return { authorized: false, error: 'Email is required' }
+ }
+
+ const { email, formData } = parsedBody
+
+ if (formData && !email) {
+ return { authorized: false, error: 'auth_required_email' }
+ }
+
+ if (!email) {
+ return { authorized: false, error: 'Email is required' }
+ }
+
+ const allowedEmails: string[] = deployment.allowedEmails || []
+
+ if (isEmailAllowed(email, allowedEmails)) {
+ return { authorized: true }
+ }
+
+ return { authorized: false, error: 'Email not authorized for this form' }
+ } catch (error) {
+ logger.error(`[${requestId}] Error validating email:`, error)
+ return { authorized: false, error: 'Authentication error' }
+ }
+ }
+
+ return { authorized: false, error: 'Unsupported authentication type' }
+}
+
+/**
+ * Form customizations interface
+ */
+export interface FormCustomizations {
+ primaryColor?: string
+ welcomeMessage?: string
+ thankYouTitle?: string
+ thankYouMessage?: string
+ logoUrl?: string
+}
+
+/**
+ * Default form customizations
+ * Note: primaryColor is intentionally undefined to allow thank you screen to use its green default
+ */
+export const DEFAULT_FORM_CUSTOMIZATIONS: FormCustomizations = {
+ welcomeMessage: '',
+ thankYouTitle: 'Thank you!',
+ thankYouMessage: 'Your response has been submitted successfully.',
+}
diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts
new file mode 100644
index 0000000000..8352149fd9
--- /dev/null
+++ b/apps/sim/app/api/form/validate/route.ts
@@ -0,0 +1,71 @@
+import { db } from '@sim/db'
+import { form } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
+
+const logger = createLogger('FormValidateAPI')
+
+const validateQuerySchema = z.object({
+ identifier: z
+ .string()
+ .min(1, 'Identifier is required')
+ .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
+ .max(100, 'Identifier must be 100 characters or less'),
+})
+
+/**
+ * GET endpoint to validate form identifier availability
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+ const { searchParams } = new URL(request.url)
+ const identifier = searchParams.get('identifier')
+
+ const validation = validateQuerySchema.safeParse({ identifier })
+
+ if (!validation.success) {
+ const errorMessage = validation.error.errors[0]?.message || 'Invalid identifier'
+ logger.warn(`Validation error: ${errorMessage}`)
+
+ if (identifier && !/^[a-z0-9-]+$/.test(identifier)) {
+ return createSuccessResponse({
+ available: false,
+ error: errorMessage,
+ })
+ }
+
+ return createErrorResponse(errorMessage, 400)
+ }
+
+ const { identifier: validatedIdentifier } = validation.data
+
+ const existingForm = await db
+ .select({ id: form.id })
+ .from(form)
+ .where(eq(form.identifier, validatedIdentifier))
+ .limit(1)
+
+ const isAvailable = existingForm.length === 0
+
+ logger.debug(
+ `Identifier "${validatedIdentifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
+ )
+
+ return createSuccessResponse({
+ available: isAvailable,
+ error: isAvailable ? null : 'This identifier is already in use',
+ })
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : 'Failed to validate identifier'
+ logger.error('Error validating form identifier:', error)
+ return createErrorResponse(message, 500)
+ }
+}
diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts
index 12bf26a7ab..783b89d1b2 100644
--- a/apps/sim/app/api/function/execute/route.test.ts
+++ b/apps/sim/app/api/function/execute/route.test.ts
@@ -3,6 +3,7 @@
*
* @vitest-environment node
*/
+import { loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
@@ -82,14 +83,7 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
}),
}))
-vi.mock('@sim/logger', () => ({
- createLogger: vi.fn(() => ({
- info: vi.fn(),
- error: vi.fn(),
- warn: vi.fn(),
- debug: vi.fn(),
- })),
-}))
+vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: vi.fn(),
diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts
index e5ebe22a8e..279f7e56e7 100644
--- a/apps/sim/app/api/knowledge/search/utils.test.ts
+++ b/apps/sim/app/api/knowledge/search/utils.test.ts
@@ -4,18 +4,15 @@
*
* @vitest-environment node
*/
-import { createEnvMock } from '@sim/testing'
+import { createEnvMock, createMockLogger } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-vi.mock('drizzle-orm')
-vi.mock('@sim/logger', () => ({
- createLogger: vi.fn(() => ({
- info: vi.fn(),
- debug: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- })),
+const loggerMock = vi.hoisted(() => ({
+ createLogger: () => createMockLogger(),
}))
+
+vi.mock('drizzle-orm')
+vi.mock('@sim/logger', () => loggerMock)
vi.mock('@sim/db')
vi.mock('@/lib/knowledge/documents/utils', () => ({
retryWithExponentialBackoff: (fn: any) => fn(),
diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts
index 35b045fc94..807c19d900 100644
--- a/apps/sim/app/api/proxy/tts/stream/route.ts
+++ b/apps/sim/app/api/proxy/tts/stream/route.ts
@@ -4,8 +4,8 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
+import { validateAuthToken } from '@/lib/core/security/deployment'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
-import { validateAuthToken } from '@/app/api/chat/utils'
const logger = createLogger('ProxyTTSStreamAPI')
diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts
index 0ab1195884..b7ce032a4b 100644
--- a/apps/sim/app/api/schedules/[id]/route.test.ts
+++ b/apps/sim/app/api/schedules/[id]/route.test.ts
@@ -3,6 +3,7 @@
*
* @vitest-environment node
*/
+import { loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -43,14 +44,7 @@ vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: () => 'test-request-id',
}))
-vi.mock('@sim/logger', () => ({
- createLogger: () => ({
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- debug: vi.fn(),
- }),
-}))
+vi.mock('@sim/logger', () => loggerMock)
import { PUT } from './route'
diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts
index 986e731138..608a1eb068 100644
--- a/apps/sim/app/api/schedules/route.test.ts
+++ b/apps/sim/app/api/schedules/route.test.ts
@@ -3,6 +3,7 @@
*
* @vitest-environment node
*/
+import { loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -40,13 +41,7 @@ vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: () => 'test-request-id',
}))
-vi.mock('@sim/logger', () => ({
- createLogger: () => ({
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- }),
-}))
+vi.mock('@sim/logger', () => loggerMock)
import { GET } from '@/app/api/schedules/route'
diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts
index 88f61ca129..da83f66153 100644
--- a/apps/sim/app/api/tools/custom/route.test.ts
+++ b/apps/sim/app/api/tools/custom/route.test.ts
@@ -1,14 +1,14 @@
-import { NextRequest } from 'next/server'
/**
* Tests for custom tools API routes
*
* @vitest-environment node
*/
+import { loggerMock } from '@sim/testing'
+import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Custom Tools API Routes', () => {
- // Sample data for testing
const sampleTools = [
{
id: 'tool-1',
@@ -66,7 +66,6 @@ describe('Custom Tools API Routes', () => {
},
]
- // Mock implementation stubs
const mockSelect = vi.fn()
const mockFrom = vi.fn()
const mockWhere = vi.fn()
@@ -82,13 +81,9 @@ describe('Custom Tools API Routes', () => {
beforeEach(() => {
vi.resetModules()
- // Reset all mock implementations
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
- // where() can be called with orderBy(), limit(), or directly awaited
- // Create a mock query builder that supports all patterns
mockWhere.mockImplementation((condition) => {
- // Return an object that is both awaitable and has orderBy() and limit() methods
const queryBuilder = {
orderBy: mockOrderBy,
limit: mockLimit,
@@ -101,7 +96,6 @@ describe('Custom Tools API Routes', () => {
return queryBuilder
})
mockOrderBy.mockImplementation(() => {
- // orderBy returns an awaitable query builder
const queryBuilder = {
limit: mockLimit,
then: (resolve: (value: typeof sampleTools) => void) => {
@@ -119,7 +113,6 @@ describe('Custom Tools API Routes', () => {
mockSet.mockReturnValue({ where: mockWhere })
mockDelete.mockReturnValue({ where: mockWhere })
- // Mock database
vi.doMock('@sim/db', () => ({
db: {
select: mockSelect,
@@ -127,14 +120,11 @@ describe('Custom Tools API Routes', () => {
update: mockUpdate,
delete: mockDelete,
transaction: vi.fn().mockImplementation(async (callback) => {
- // Execute the callback with a transaction object that has the same methods
- // Create transaction-specific mocks that follow the same pattern
const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom })
const txMockInsert = vi.fn().mockReturnValue({ values: mockValues })
const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet })
const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere })
- // Transaction where() should also support the query builder pattern with orderBy
const txMockOrderBy = vi.fn().mockImplementation(() => {
const queryBuilder = {
limit: mockLimit,
@@ -160,7 +150,6 @@ describe('Custom Tools API Routes', () => {
return queryBuilder
})
- // Update mockFrom to return txMockWhere for transaction queries
const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere })
txMockSelect.mockReturnValue({ from: txMockFrom })
@@ -174,7 +163,6 @@ describe('Custom Tools API Routes', () => {
},
}))
- // Mock schema
vi.doMock('@sim/db/schema', () => ({
customTools: {
id: 'id',
@@ -189,12 +177,10 @@ describe('Custom Tools API Routes', () => {
},
}))
- // Mock authentication
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(mockSession),
}))
- // Mock hybrid auth
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: true,
@@ -203,22 +189,12 @@ describe('Custom Tools API Routes', () => {
}),
}))
- // Mock permissions
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
}))
- // Mock logger
- vi.doMock('@sim/logger', () => ({
- createLogger: vi.fn().mockReturnValue({
- info: vi.fn(),
- error: vi.fn(),
- warn: vi.fn(),
- debug: vi.fn(),
- }),
- }))
+ vi.doMock('@sim/logger', () => loggerMock)
- // Mock drizzle-orm functions
vi.doMock('drizzle-orm', async () => {
const actual = await vi.importActual('drizzle-orm')
return {
@@ -232,12 +208,10 @@ describe('Custom Tools API Routes', () => {
}
})
- // Mock utils
vi.doMock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
- // Mock custom tools operations
vi.doMock('@/lib/workflows/custom-tools/operations', () => ({
upsertCustomTools: vi.fn().mockResolvedValue(sampleTools),
}))
@@ -252,29 +226,23 @@ describe('Custom Tools API Routes', () => {
*/
describe('GET /api/tools/custom', () => {
it('should return tools for authenticated user with workspaceId', async () => {
- // Create mock request with workspaceId
const req = new NextRequest(
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
)
- // Simulate DB returning tools with orderBy chain
mockWhere.mockReturnValueOnce({
orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)),
})
- // Import handler after mocks are set up
const { GET } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await GET(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(200)
expect(data).toHaveProperty('data')
expect(data.data).toEqual(sampleTools)
- // Verify DB query
expect(mockSelect).toHaveBeenCalled()
expect(mockFrom).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
@@ -282,12 +250,10 @@ describe('Custom Tools API Routes', () => {
})
it('should handle unauthorized access', async () => {
- // Create mock request
const req = new NextRequest(
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
)
- // Mock hybrid auth to return unauthorized
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: false,
@@ -295,26 +261,20 @@ describe('Custom Tools API Routes', () => {
}),
}))
- // Import handler after mocks are set up
const { GET } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await GET(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('should handle workflowId parameter', async () => {
- // Create mock request with workflowId parameter
const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123')
- // Mock workflow lookup to return workspaceId (for limit(1) call)
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
- // Mock the where() call for fetching tools (returns awaitable query builder)
mockWhere.mockImplementationOnce((condition) => {
const queryBuilder = {
limit: mockLimit,
@@ -327,18 +287,14 @@ describe('Custom Tools API Routes', () => {
return queryBuilder
})
- // Import handler after mocks are set up
const { GET } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await GET(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(200)
expect(data).toHaveProperty('data')
- // Verify DB query was called
expect(mockWhere).toHaveBeenCalled()
})
})
@@ -348,7 +304,6 @@ describe('Custom Tools API Routes', () => {
*/
describe('POST /api/tools/custom', () => {
it('should reject unauthorized requests', async () => {
- // Mock hybrid auth to return unauthorized
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: false,
@@ -356,39 +311,29 @@ describe('Custom Tools API Routes', () => {
}),
}))
- // Create mock request
const req = createMockRequest('POST', { tools: [], workspaceId: 'workspace-123' })
- // Import handler after mocks are set up
const { POST } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await POST(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('should validate request data', async () => {
- // Create invalid tool data (missing required fields)
const invalidTool = {
- // Missing title, schema
code: 'return "invalid";',
}
- // Create mock request with invalid tool and workspaceId
const req = createMockRequest('POST', { tools: [invalidTool], workspaceId: 'workspace-123' })
- // Import handler after mocks are set up
const { POST } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await POST(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Invalid request data')
expect(data).toHaveProperty('details')
@@ -400,96 +345,74 @@ describe('Custom Tools API Routes', () => {
*/
describe('DELETE /api/tools/custom', () => {
it('should delete a workspace-scoped tool by ID', async () => {
- // Mock finding existing workspace-scoped tool
mockLimit.mockResolvedValueOnce([sampleTools[0]])
- // Create mock request with ID and workspaceId parameters
const req = new NextRequest(
'http://localhost:3000/api/tools/custom?id=tool-1&workspaceId=workspace-123'
)
- // Import handler after mocks are set up
const { DELETE } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await DELETE(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(200)
expect(data).toHaveProperty('success', true)
- // Verify delete was called with correct parameters
expect(mockDelete).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
})
it('should reject requests missing tool ID', async () => {
- // Create mock request without ID parameter
const req = createMockRequest('DELETE')
- // Import handler after mocks are set up
const { DELETE } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await DELETE(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Tool ID is required')
})
it('should handle tool not found', async () => {
- // Mock tool not found
mockLimit.mockResolvedValueOnce([])
- // Create mock request with non-existent ID
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=non-existent')
- // Import handler after mocks are set up
const { DELETE } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await DELETE(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Tool not found')
})
it('should prevent unauthorized deletion of user-scoped tool', async () => {
- // Mock hybrid auth for the DELETE request
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: true,
- userId: 'user-456', // Different user
+ userId: 'user-456',
authType: 'session',
}),
}))
- // Mock finding user-scoped tool (no workspaceId) that belongs to user-123
const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' }
mockLimit.mockResolvedValueOnce([userScopedTool])
- // Create mock request (no workspaceId for user-scoped tool)
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1')
- // Import handler after mocks are set up
const { DELETE } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await DELETE(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Access denied')
})
it('should reject unauthorized requests', async () => {
- // Mock hybrid auth to return unauthorized
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: false,
@@ -497,17 +420,13 @@ describe('Custom Tools API Routes', () => {
}),
}))
- // Create mock request
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1')
- // Import handler after mocks are set up
const { DELETE } = await import('@/app/api/tools/custom/route')
- // Call the handler
const response = await DELETE(req)
const data = await response.json()
- // Verify response
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
})
diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts
index db83f52d07..7c3dd58ad6 100644
--- a/apps/sim/app/api/v1/admin/workflows/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts
@@ -19,6 +19,7 @@ import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
+import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
@@ -31,7 +32,6 @@ import {
type WorkflowImportRequest,
type WorkflowVariable,
} from '@/app/api/v1/admin/types'
-import { parseWorkflowJson } from '@/stores/workflows/json/importer'
const logger = createLogger('AdminWorkflowImportAPI')
diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
index fa569b7f24..6bb6a4db66 100644
--- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
@@ -31,6 +31,7 @@ import { NextResponse } from 'next/server'
import {
extractWorkflowName,
extractWorkflowsFromZip,
+ parseWorkflowJson,
} from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
@@ -46,7 +47,6 @@ import {
type WorkspaceImportRequest,
type WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
-import { parseWorkflowJson } from '@/stores/workflows/json/importer'
const logger = createLogger('AdminWorkspaceImportAPI')
diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.ts b/apps/sim/app/api/workflows/[id]/form/status/route.ts
new file mode 100644
index 0000000000..a14abe736f
--- /dev/null
+++ b/apps/sim/app/api/workflows/[id]/form/status/route.ts
@@ -0,0 +1,47 @@
+import { db } from '@sim/db'
+import { form } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq } from 'drizzle-orm'
+import type { NextRequest } from 'next/server'
+import { getSession } from '@/lib/auth'
+import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
+
+const logger = createLogger('FormStatusAPI')
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ const session = await getSession()
+
+ if (!session) {
+ return createErrorResponse('Unauthorized', 401)
+ }
+
+ const { id: workflowId } = await params
+
+ const formResult = await db
+ .select({
+ id: form.id,
+ identifier: form.identifier,
+ title: form.title,
+ isActive: form.isActive,
+ })
+ .from(form)
+ .where(and(eq(form.workflowId, workflowId), eq(form.isActive, true)))
+ .limit(1)
+
+ if (formResult.length === 0) {
+ return createSuccessResponse({
+ isDeployed: false,
+ form: null,
+ })
+ }
+
+ return createSuccessResponse({
+ isDeployed: true,
+ form: formResult[0],
+ })
+ } catch (error: any) {
+ logger.error('Error fetching form status:', error)
+ return createErrorResponse(error.message || 'Failed to fetch form status', 500)
+ }
+}
diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts
index 12ea444173..35f3d3473c 100644
--- a/apps/sim/app/api/workflows/[id]/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/route.test.ts
@@ -5,6 +5,7 @@
* @vitest-environment node
*/
+import { loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -20,14 +21,7 @@ vi.mock('@/lib/auth', () => ({
getSession: () => mockGetSession(),
}))
-vi.mock('@sim/logger', () => ({
- createLogger: vi.fn(() => ({
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- })),
-}))
+vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/workflows/persistence/utils', () => ({
loadWorkflowFromNormalizedTables: (workflowId: string) =>
diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx
index 0a43ea1849..926f96a064 100644
--- a/apps/sim/app/chat/[identifier]/chat.tsx
+++ b/apps/sim/app/chat/[identifier]/chat.tsx
@@ -460,43 +460,22 @@ export default function ChatClient({ identifier }: { identifier: string }) {
)
if (error) {
- return
+ return
}
if (authRequired) {
- const title = new URLSearchParams(window.location.search).get('title') || 'chat'
- const primaryColor =
- new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
+ // const title = new URLSearchParams(window.location.search).get('title') || 'chat'
+ // const primaryColor =
+ // new URLSearchParams(window.location.search).get('color') || 'var(--brand-primary-hover-hex)'
if (authRequired === 'password') {
- return (
-
- )
+ return
}
if (authRequired === 'email') {
- return (
-
- )
+ return
}
if (authRequired === 'sso') {
- return (
-
- )
+ return
}
}
diff --git a/apps/sim/app/chat/components/auth/email/email-auth.tsx b/apps/sim/app/chat/components/auth/email/email-auth.tsx
index fb2a5d8036..d6ba3de532 100644
--- a/apps/sim/app/chat/components/auth/email/email-auth.tsx
+++ b/apps/sim/app/chat/components/auth/email/email-auth.tsx
@@ -2,14 +2,16 @@
import { type KeyboardEvent, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
+import { Input } from '@/components/emcn'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
+import AuthBackground from '@/app/(auth)/components/auth-background'
+import { BrandedButton } from '@/app/(auth)/components/branded-button'
+import { SupportFooter } from '@/app/(auth)/components/support-footer'
import Nav from '@/app/(landing)/components/nav/nav'
const logger = createLogger('EmailAuth')
@@ -17,8 +19,6 @@ const logger = createLogger('EmailAuth')
interface EmailAuthProps {
identifier: string
onAuthSuccess: () => void
- title?: string
- primaryColor?: string
}
const validateEmailField = (emailValue: string): string[] => {
@@ -37,57 +37,19 @@ const validateEmailField = (emailValue: string): string[] => {
return errors
}
-export default function EmailAuth({
- identifier,
- onAuthSuccess,
- title = 'chat',
- primaryColor = 'var(--brand-primary-hover-hex)',
-}: EmailAuthProps) {
- // Email auth state
+export default function EmailAuth({ identifier, onAuthSuccess }: EmailAuthProps) {
const [email, setEmail] = useState('')
const [authError, setAuthError] = useState(null)
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isVerifyingOtp, setIsVerifyingOtp] = useState(false)
const [emailErrors, setEmailErrors] = useState([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
- // OTP verification state
const [showOtpVerification, setShowOtpVerification] = useState(false)
const [otpValue, setOtpValue] = useState('')
const [countdown, setCountdown] = useState(0)
const [isResendDisabled, setIsResendDisabled] = useState(false)
- useEffect(() => {
- // Check if CSS variable has been customized
- const checkCustomBrand = () => {
- const computedStyle = getComputedStyle(document.documentElement)
- const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
-
- // Check if the CSS variable exists and is different from the default
- if (brandAccent && brandAccent !== '#6f3dfa') {
- setButtonClass('auth-button-custom')
- } else {
- setButtonClass('auth-button-gradient')
- }
- }
-
- checkCustomBrand()
-
- // Also check on window resize or theme changes
- window.addEventListener('resize', checkCustomBrand)
- const observer = new MutationObserver(checkCustomBrand)
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ['style', 'class'],
- })
-
- return () => {
- window.removeEventListener('resize', checkCustomBrand)
- observer.disconnect()
- }
- }, [])
-
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
@@ -98,7 +60,6 @@ export default function EmailAuth({
}
}, [countdown, isResendDisabled])
- // Handle email input key down
const handleEmailKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -109,21 +70,16 @@ export default function EmailAuth({
const handleEmailChange = (e: React.ChangeEvent) => {
const newEmail = e.target.value
setEmail(newEmail)
-
- // Silently validate but don't show errors until submit
const errors = validateEmailField(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
}
- // Handle sending OTP
const handleSendOtp = async () => {
- // Validate email on submit
const emailValidationErrors = validateEmailField(email)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
- // If there are validation errors, stop submission
if (emailValidationErrors.length > 0) {
return
}
@@ -217,7 +173,6 @@ export default function EmailAuth({
return
}
- // Don't show success message in error state, just reset OTP
setOtpValue('')
} catch (error) {
logger.error('Error resending OTP:', error)
@@ -230,36 +185,34 @@ export default function EmailAuth({
}
return (
-
-
-
-
-
- {/* Header */}
-
-
- {showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
-
-
- {showOtpVerification
- ? `A verification code has been sent to ${email}`
- : 'This chat requires email verification'}
-
-
-
- {/* Form */}
-
- {!showOtpVerification ? (
-
-
-
+
+
+
)
}
diff --git a/apps/sim/app/chat/components/auth/password/password-auth.tsx b/apps/sim/app/chat/components/auth/password/password-auth.tsx
index f99847f73f..4074e85ad4 100644
--- a/apps/sim/app/chat/components/auth/password/password-auth.tsx
+++ b/apps/sim/app/chat/components/auth/password/password-auth.tsx
@@ -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(null)
- const [isAuthenticating, setIsAuthenticating] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showValidationError, setShowValidationError] = useState(false)
const [passwordErrors, setPasswordErrors] = useState([])
- const [buttonClass, setButtonClass] = useState('auth-button-gradient')
-
- 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()
- }
- }, [])
+ const [isAuthenticating, setIsAuthenticating] = useState(false)
- // Handle keyboard input for auth forms
const handleKeyDown = (e: KeyboardEvent) => {
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 (
-
-
-
-
-
- {/* Header */}
-
-
- Password Required
-
-
- This chat is password-protected
-
-
+
+
+
+
+
+
+
+
+ Password Required
+
+
+ This chat is password-protected
+
+
- {/* Form */}
- {
- e.preventDefault()
- handleAuthenticate()
- }}
- className={`${inter.className} mt-8 w-full space-y-8`}
- >
-
+ {
+ e.preventDefault()
+ handleAuthenticate()
+ }}
+ className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
+ >
@@ -194,19 +148,21 @@ export default function PasswordAuth({
)}
-
-
-
+
+ Continue
+
+
+
-
-
+
+
+
)
}
diff --git a/apps/sim/app/chat/components/auth/sso/sso-auth.tsx b/apps/sim/app/chat/components/auth/sso/sso-auth.tsx
index 8ceb4bf557..0ee76c5a85 100644
--- a/apps/sim/app/chat/components/auth/sso/sso-auth.tsx
+++ b/apps/sim/app/chat/components/auth/sso/sso-auth.tsx
@@ -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([])
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) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -133,32 +99,30 @@ export default function SSOAuth({
}
return (
-
-
-
-
-
- {/* Header */}
-
-
- SSO Authentication
-
-
- This chat requires SSO authentication
-
-
+
+
+
+
+
+
+
+
+ SSO Authentication
+
+
+ This chat requires SSO authentication
+
+
- {/* Form */}
- {
- e.preventDefault()
- handleAuthenticate()
- }}
- className={`${inter.className} mt-8 w-full space-y-8`}
- >
-
+ {
+ e.preventDefault()
+ handleAuthenticate()
+ }}
+ className={`${inter.className} mt-8 w-full max-w-[410px] space-y-6`}
+ >
@@ -191,19 +155,16 @@ export default function SSOAuth({
)}
-
-
-
+
+ Continue with SSO
+
+
+
-
-
+
+
+
)
}
diff --git a/apps/sim/app/chat/components/components/markdown-renderer/markdown-renderer.tsx b/apps/sim/app/chat/components/components/markdown-renderer/markdown-renderer.tsx
deleted file mode 100644
index a3424d63cb..0000000000
--- a/apps/sim/app/chat/components/components/markdown-renderer/markdown-renderer.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import ReactMarkdown from 'react-markdown'
-
-export default function MarkdownRenderer({ content }: { content: string }) {
- const customComponents = {
- // Paragraph
- p: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
-
- // Headings
- h1: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
- h2: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
- h3: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
- h4: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
-
- // Lists
- ul: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
- ol: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
- li: ({ children }: React.HTMLAttributes) => (
- {children}
- ),
-
- // Code blocks
- pre: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
-
- // Inline code
- code: ({
- inline,
- className,
- children,
- ...props
- }: React.HTMLAttributes & { className?: string; inline?: boolean }) => {
- if (inline) {
- return (
-
- {children}
-
- )
- }
-
- // Extract language from className (format: language-xxx)
- const match = /language-(\w+)/.exec(className || '')
- const language = match ? match[1] : ''
-
- return (
-
- {language && (
-
- {language}
-
- )}
-
- {children}
-
-
- )
- },
-
- // Blockquotes
- blockquote: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
-
- // Horizontal rule
- hr: () =>
,
-
- // Links
- a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => (
-
- {children}
-
- ),
-
- // Tables
- table: ({ children }: React.TableHTMLAttributes) => (
-
- {children}
-
- ),
- thead: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- tbody: ({ children }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- tr: ({ children, ...props }: React.HTMLAttributes) => (
-
- {children}
-
- ),
- th: ({ children }: React.ThHTMLAttributes) => (
-
- {children}
-
- ),
- td: ({ children }: React.TdHTMLAttributes) => (
- {children}
- ),
-
- // Images
- img: ({ src, alt, ...props }: React.ImgHTMLAttributes) => (
-
- ),
- }
-
- // 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 (
-
- {processedContent}
-
- )
-}
diff --git a/apps/sim/app/chat/components/error-state/error-state.tsx b/apps/sim/app/chat/components/error-state/error-state.tsx
index 0f222beb00..ae40b1751a 100644
--- a/apps/sim/app/chat/components/error-state/error-state.tsx
+++ b/apps/sim/app/chat/components/error-state/error-state.tsx
@@ -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 (
-
-
-
-
-
- {/* Error content */}
-
-
- Chat Unavailable
-
-
- {error}
-
-
-
- {/* Action button - matching login form */}
-
-
-
-
-
-
-
- Need help?{' '}
-
- Contact support
-
-
-
+
+ router.push('/workspace')}>Return to Workspace
+
)
}
diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx
index 4bddd16265..4f782725b2 100644
--- a/apps/sim/app/credential-account/[token]/page.tsx
+++ b/apps/sim/app/credential-account/[token]/page.tsx
@@ -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,
},
]}
/>
diff --git a/apps/sim/app/form/[identifier]/components/error-state.tsx b/apps/sim/app/form/[identifier]/components/error-state.tsx
new file mode 100644
index 0000000000..ec1314517a
--- /dev/null
+++ b/apps/sim/app/form/[identifier]/components/error-state.tsx
@@ -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 (
+
+ router.push('/workspace')}>Return to Workspace
+
+ )
+}
diff --git a/apps/sim/app/form/[identifier]/components/form-field.tsx b/apps/sim/app/form/[identifier]/components/form-field.tsx
new file mode 100644
index 0000000000..4e5213863c
--- /dev/null
+++ b/apps/sim/app/form/[identifier]/components/form-field.tsx
@@ -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(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) => {
+ 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 (
+
+
+
+ {value ? 'Yes' : 'No'}
+
+
+ )
+
+ case 'number':
+ return (
+ {
+ 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 (
+
-
-
- {isExpiredError && (
-
- )}
-
- {actions.map((action, index) => {
- const isPrimary = (action.variant || 'default') === 'default'
- const isHovered = hoveredButtonIndex === index
-
- if (isPrimary) {
- return (
-
- )
- }
+
+ {isExpiredError && (
+ router.push('/')}>Request New Invitation
+ )}
- return (
-
- )
- })}
-
+ {actions.map((action, index) => (
+
+ {action.label}
+
+ ))}
-
- Need help?{' '}
-
- Contact support
-
-
+
>
)
}
diff --git a/apps/sim/app/not-found.tsx b/apps/sim/app/not-found.tsx
index 2b853fca3b..e34f172ddd 100644
--- a/apps/sim/app/not-found.tsx
+++ b/apps/sim/app/not-found.tsx
@@ -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 (
-
-
-
-
-
-
-
-
- Page Not Found
-
-
- The page you’re looking for doesn’t exist or has been moved.
-
-
-
-
-
-
-
-
- Need help?{' '}
-
- Contact support
-
-
-
-
-
-
+
+ router.push('/')}>Return to Home
+
)
}
diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx
index 844007d81a..14e08c5378 100644
--- a/apps/sim/app/playground/page.tsx
+++ b/apps/sim/app/playground/page.tsx
@@ -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([
+ { value: 'user@example.com', isValid: true },
+ { value: 'invalid-email', isValid: false },
+ ])
const toggleDarkMode = () => {
setIsDarkMode(!isDarkMode)
@@ -208,6 +226,57 @@ export default function PlaygroundPage() {
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ButtonGroup */}
+
+
+
+ cURL
+ Python
+ JavaScript
+
+
+
+
+ Option 1
+ Option 2
+
+
+
+
+ Option 1
+ Option 2
+
+
+
+
+ Option 1
+ Option 2
+
+
+
+
+ Only Option
+
+
{/* Badge */}
@@ -274,6 +343,46 @@ export default function PlaygroundPage() {
+ {/* TagInput */}
+
+
+
+ {
+ 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'
+ />
+
+
+
+
+
+
+
+ {}} />
+ {}} />
+
+
+
+ false}
+ onRemove={() => {}}
+ placeholder='Disabled input'
+ disabled
+ />
+
+
+
+
{/* Textarea */}
@@ -432,6 +541,53 @@ export default function PlaygroundPage() {
+
+
+
+
+ Item
+ Price
+
+
+
+
+ Product A
+ $10.00
+
+
+ Product B
+ $20.00
+
+
+
+
+ Total
+ $30.00
+
+
+
+
+
+
+ A list of team members
+
+
+ Name
+ Department
+
+
+
+
+ Alice
+ Engineering
+
+
+ Bob
+ Design
+
+
+
+
{/* Combobox */}
@@ -518,6 +674,43 @@ export default function PlaygroundPage() {
+ {/* DatePicker */}
+
+
+
+
+
+ {dateValue || 'No date'}
+
+
+
+ {}} />
+
+
+
+
+ {
+ setDateRangeStart(start)
+ setDateRangeEnd(end)
+ }}
+ placeholder='Select date range'
+ />
+
+
+
+
+
+
+
+
+
+
+
+
{/* Breadcrumb */}
Tooltip content
+
+
+
+
+
+
+ Clear console
+
+
+
+
+
+
+
+
+
+
+
+
+
{/* Popover */}
@@ -760,6 +973,7 @@ export default function PlaygroundPage() {
{[
+ { 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' },
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx
index 585e524cd5..0bce7c5885 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx
@@ -123,7 +123,7 @@ export function CreateChunkModal({
Create Chunk
-
+
{error && {error}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
index fda11582ae..182274a2c6 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
@@ -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({
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx
index beba06c8d3..60aa328f31 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx
@@ -260,7 +260,7 @@ export function EditChunkModal({
-
+
{error && {error}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index d6675928e3..03be428892 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -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')
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index b56fc4ec45..5dec641494 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -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')
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
index 4c101f5c2f..17659dfe96 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
@@ -224,7 +224,7 @@ export function AddDocumentsModal({
Add Documents
-
+
{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)]'
)}
>
-
+