diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index d67a8a3628..8bf0d74f10 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -1,60 +1,57 @@ --- -description: Testing patterns with Vitest +description: Testing patterns with Vitest and @sim/testing globs: ["apps/sim/**/*.test.ts", "apps/sim/**/*.test.tsx"] --- # Testing Patterns -Use Vitest. Test files live next to source: `feature.ts` → `feature.test.ts` +Use Vitest. Test files: `feature.ts` → `feature.test.ts` ## Structure ```typescript /** - * Tests for [feature name] - * * @vitest-environment node */ +import { databaseMock, loggerMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' -// 1. Mocks BEFORE imports -vi.mock('@sim/db', () => ({ db: { select: vi.fn() } })) +vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/logger', () => loggerMock) -// 2. Imports AFTER mocks -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { createSession, loggerMock } from '@sim/testing' import { myFunction } from '@/lib/feature' describe('myFunction', () => { beforeEach(() => vi.clearAllMocks()) - - it('should do something', () => { - expect(myFunction()).toBe(expected) - }) - - it.concurrent('runs in parallel', () => { ... }) + it.concurrent('isolated tests run in parallel', () => { ... }) }) ``` ## @sim/testing Package -```typescript -// Factories - create test data -import { createBlock, createWorkflow, createSession } from '@sim/testing' +Always prefer over local mocks. -// Mocks - pre-configured mocks -import { loggerMock, databaseMock, fetchMock } from '@sim/testing' - -// Builders - fluent API for complex objects -import { ExecutionBuilder, WorkflowBuilder } from '@sim/testing' -``` +| Category | Utilities | +|----------|-----------| +| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | ## Rules 1. `@vitest-environment node` directive at file top -2. **Mocks before imports** - `vi.mock()` calls must come first -3. Use `@sim/testing` factories over manual test data -4. `it.concurrent` for independent tests (faster) +2. `vi.mock()` calls before importing mocked modules +3. `@sim/testing` utilities over local mocks +4. `it.concurrent` for isolated tests (no shared mutable state) 5. `beforeEach(() => vi.clearAllMocks())` to reset state -6. Group related tests with nested `describe` blocks -7. Test file naming: `*.test.ts` (not `*.spec.ts`) + +## Hoisted Mocks + +For mutable mock references: + +```typescript +const mockFn = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/module', () => ({ myFunction: mockFn })) +mockFn.mockResolvedValue({ data: 'test' }) +``` diff --git a/CLAUDE.md b/CLAUDE.md index 8ef5434d21..c9621ff178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,13 +173,13 @@ Use Vitest. Test files: `feature.ts` → `feature.test.ts` /** * @vitest-environment node */ +import { databaseMock, loggerMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' -// Mocks BEFORE imports -vi.mock('@sim/db', () => ({ db: { select: vi.fn() } })) +vi.mock('@sim/db', () => databaseMock) +vi.mock('@sim/logger', () => loggerMock) -// Imports AFTER mocks -import { describe, expect, it, vi } from 'vitest' -import { createSession, loggerMock } from '@sim/testing' +import { myFunction } from '@/lib/feature' describe('feature', () => { beforeEach(() => vi.clearAllMocks()) @@ -187,7 +187,7 @@ describe('feature', () => { }) ``` -Use `@sim/testing` factories over manual test data. +Use `@sim/testing` mocks/factories over local test data. See `.cursor/rules/sim-testing.mdc` for details. ## Utils Rules diff --git a/apps/docs/content/docs/en/execution/form.mdx b/apps/docs/content/docs/en/execution/form.mdx new file mode 100644 index 0000000000..3df49004e5 --- /dev/null +++ b/apps/docs/content/docs/en/execution/form.mdx @@ -0,0 +1,136 @@ +--- +title: Form Deployment +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Deploy your workflow as an embeddable form that users can fill out on your website or share via link. Form submissions trigger your workflow with the `form` trigger type. + +## Overview + +Form deployment turns your workflow's Input Format into a responsive form that can be: +- Shared via a direct link (e.g., `https://sim.ai/form/my-survey`) +- Embedded in any website using an iframe + +When a user submits the form, it triggers your workflow with the form data. + + +Forms derive their fields from your workflow's Start block Input Format. Each field becomes a form input with the appropriate type. + + +## Creating a Form + +1. Open your workflow and click **Deploy** +2. Select the **Form** tab +3. Configure: + - **URL**: Unique identifier (e.g., `contact-form` → `sim.ai/form/contact-form`) + - **Title**: Form heading + - **Description**: Optional subtitle + - **Form Fields**: Customize labels and descriptions for each field + - **Authentication**: Public, password-protected, or email whitelist + - **Thank You Message**: Shown after submission +4. Click **Launch** + +## Field Type Mapping + +| Input Format Type | Form Field | +|------------------|------------| +| `string` | Text input | +| `number` | Number input | +| `boolean` | Toggle switch | +| `object` | JSON editor | +| `array` | JSON array editor | +| `files` | File upload | + +## Access Control + +| Mode | Description | +|------|-------------| +| **Public** | Anyone with the link can submit | +| **Password** | Users must enter a password | +| **Email Whitelist** | Only specified emails/domains can submit | + +For email whitelist: +- Exact: `user@example.com` +- Domain: `@example.com` (all emails from domain) + +## Embedding + +### Direct Link + +``` +https://sim.ai/form/your-identifier +``` + +### Iframe + +```html + +``` + +## API Submission + +Submit forms programmatically: + + + +```bash +curl -X POST https://sim.ai/api/form/your-identifier \ + -H "Content-Type: application/json" \ + -d '{ + "formData": { + "name": "John Doe", + "email": "john@example.com" + } + }' +``` + + +```typescript +const response = await fetch('https://sim.ai/api/form/your-identifier', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + formData: { + name: 'John Doe', + email: 'john@example.com' + } + }) +}); + +const result = await response.json(); +// { success: true, data: { executionId: '...' } } +``` + + + +### Protected Forms + +For password-protected forms: +```bash +curl -X POST https://sim.ai/api/form/your-identifier \ + -H "Content-Type: application/json" \ + -d '{ "password": "secret", "formData": { "name": "John" } }' +``` + +For email-protected forms: +```bash +curl -X POST https://sim.ai/api/form/your-identifier \ + -H "Content-Type: application/json" \ + -d '{ "email": "allowed@example.com", "formData": { "name": "John" } }' +``` + +## Troubleshooting + +**"No input fields configured"** - Add Input Format fields to your Start block. + +**Form not loading in iframe** - Check your site's CSP allows iframes from `sim.ai`. + +**Submissions failing** - Verify the identifier is correct and required fields are filled. diff --git a/apps/docs/content/docs/en/execution/meta.json b/apps/docs/content/docs/en/execution/meta.json index 37cac68f5a..02f2c537db 100644 --- a/apps/docs/content/docs/en/execution/meta.json +++ b/apps/docs/content/docs/en/execution/meta.json @@ -1,3 +1,3 @@ { - "pages": ["index", "basics", "api", "logging", "costs"] + "pages": ["index", "basics", "api", "form", "logging", "costs"] } diff --git a/apps/docs/content/docs/en/triggers/start.mdx b/apps/docs/content/docs/en/triggers/start.mdx index 10610afe48..8997372e8e 100644 --- a/apps/docs/content/docs/en/triggers/start.mdx +++ b/apps/docs/content/docs/en/triggers/start.mdx @@ -44,7 +44,7 @@ Reference structured values downstream with expressions such as <start. ## How it behaves per entry point - + When you click Run in the editor, the Start block renders the Input Format as a form. Default values make it easy to retest without retyping data. Submitting the form triggers the workflow immediately and the values become available on <start.fieldName> (for example <start.sampleField>). @@ -64,6 +64,13 @@ Reference structured values downstream with expressions such as <start. If you launch chat with additional structured context (for example from an embed), it merges into the corresponding <start.fieldName> outputs, keeping downstream blocks consistent with API and manual runs. + + Form deployments render the Input Format as a standalone, embeddable form page. Each field becomes a form input with appropriate UI controls—text inputs for strings, number inputs for numbers, toggle switches for booleans, and file upload zones for files. + + When a user submits the form, values become available on <start.fieldName> just like other entry points. The workflow executes with trigger type form, and submitters see a customizable thank-you message upon completion. + + Forms can be embedded via iframe or shared as direct links, making them ideal for surveys, contact forms, and data collection workflows. + ## Referencing Start data downstream diff --git a/apps/sim/app/(auth)/components/branded-button.tsx b/apps/sim/app/(auth)/components/branded-button.tsx new file mode 100644 index 0000000000..2b7c8e9702 --- /dev/null +++ b/apps/sim/app/(auth)/components/branded-button.tsx @@ -0,0 +1,100 @@ +'use client' + +import { forwardRef, useState } from 'react' +import { ArrowRight, ChevronRight, Loader2 } from 'lucide-react' +import { Button, type ButtonProps as EmcnButtonProps } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { useBrandedButtonClass } from '@/hooks/use-branded-button-class' + +export interface BrandedButtonProps extends Omit { + /** Shows loading spinner and disables button */ + loading?: boolean + /** Text to show when loading (appends "..." automatically) */ + loadingText?: string + /** Show arrow animation on hover (default: true) */ + showArrow?: boolean + /** Make button full width (default: true) */ + fullWidth?: boolean +} + +/** + * Branded button for auth and status pages. + * Automatically detects whitelabel customization and applies appropriate styling. + * + * @example + * ```tsx + * // Primary branded button with arrow + * Sign In + * + * // Loading state + * Sign In + * + * // Without arrow animation + * Continue + * ``` + */ +export const BrandedButton = forwardRef( + ( + { + children, + loading = false, + loadingText, + showArrow = true, + fullWidth = true, + className, + disabled, + onMouseEnter, + onMouseLeave, + ...props + }, + ref + ) => { + const buttonClass = useBrandedButtonClass() + const [isHovered, setIsHovered] = useState(false) + + const handleMouseEnter = (e: React.MouseEvent) => { + setIsHovered(true) + onMouseEnter?.(e) + } + + const handleMouseLeave = (e: React.MouseEvent) => { + setIsHovered(false) + onMouseLeave?.(e) + } + + return ( + + ) + } +) + +BrandedButton.displayName = 'BrandedButton' diff --git a/apps/sim/app/(auth)/components/sso-login-button.tsx b/apps/sim/app/(auth)/components/sso-login-button.tsx index 395a8d4541..df758576c2 100644 --- a/apps/sim/app/(auth)/components/sso-login-button.tsx +++ b/apps/sim/app/(auth)/components/sso-login-button.tsx @@ -34,7 +34,7 @@ export function SSOLoginButton({ } const primaryBtnClasses = cn( - primaryClassName || 'auth-button-gradient', + primaryClassName || 'branded-button-gradient', 'flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200' ) diff --git a/apps/sim/app/(auth)/components/status-page-layout.tsx b/apps/sim/app/(auth)/components/status-page-layout.tsx new file mode 100644 index 0000000000..d3177b8754 --- /dev/null +++ b/apps/sim/app/(auth)/components/status-page-layout.tsx @@ -0,0 +1,74 @@ +'use client' + +import type { ReactNode } from 'react' +import { inter } from '@/app/_styles/fonts/inter/inter' +import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import AuthBackground from '@/app/(auth)/components/auth-background' +import Nav from '@/app/(landing)/components/nav/nav' +import { SupportFooter } from './support-footer' + +export interface StatusPageLayoutProps { + /** Page title displayed prominently */ + title: string + /** Description text below the title */ + description: string | ReactNode + /** Content to render below the title/description (usually buttons) */ + children?: ReactNode + /** Whether to show the support footer (default: true) */ + showSupportFooter?: boolean + /** Whether to hide the nav bar (useful for embedded forms) */ + hideNav?: boolean +} + +/** + * Unified layout for status/error pages (404, form unavailable, chat error, etc.). + * Uses AuthBackground and Nav for consistent styling with auth pages. + * + * @example + * ```tsx + * + * router.push('/')}>Return to Home + * + * ``` + */ +export function StatusPageLayout({ + title, + description, + children, + showSupportFooter = true, + hideNav = false, +}: StatusPageLayoutProps) { + return ( + +
+ {!hideNav &&
+
+ ) +} diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx new file mode 100644 index 0000000000..057334ee5f --- /dev/null +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useBrandConfig } from '@/lib/branding/branding' +import { inter } from '@/app/_styles/fonts/inter/inter' + +export interface SupportFooterProps { + /** Position style - 'fixed' for pages without AuthLayout, 'absolute' for pages with AuthLayout */ + position?: 'fixed' | 'absolute' +} + +/** + * Support footer component for auth and status pages. + * Displays a "Need help? Contact support" link using branded support email. + * + * @example + * ```tsx + * // Fixed position (for standalone pages) + * + * + * // Absolute position (for pages using AuthLayout) + * + * ``` + */ +export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { + const brandConfig = useBrandConfig() + + return ( +
+ Need help?{' '} + + Contact support + +
+ ) +} diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 10b2313bfd..c2094755a9 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -105,7 +105,7 @@ export default function LoginPage({ const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) const [callbackUrl, setCallbackUrl] = useState('/workspace') @@ -146,9 +146,9 @@ export default function LoginPage({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index 7f5b8647d5..7212b52d53 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -27,7 +27,7 @@ export function RequestResetForm({ statusMessage, className, }: RequestResetFormProps) { - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) useEffect(() => { @@ -36,9 +36,9 @@ export function RequestResetForm({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } @@ -138,7 +138,7 @@ export function SetNewPasswordForm({ const [validationMessage, setValidationMessage] = useState('') const [showPassword, setShowPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) useEffect(() => { @@ -147,9 +147,9 @@ export function SetNewPasswordForm({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 108e964909..670d4434b0 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -95,7 +95,7 @@ function SignupFormContent({ const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [redirectUrl, setRedirectUrl] = useState('') const [isInviteFlow, setIsInviteFlow] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [isButtonHovered, setIsButtonHovered] = useState(false) const [name, setName] = useState('') @@ -132,9 +132,9 @@ function SignupFormContent({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx index 4d01ebd0b1..0d371bbaff 100644 --- a/apps/sim/app/(auth)/sso/sso-form.tsx +++ b/apps/sim/app/(auth)/sso/sso-form.tsx @@ -57,7 +57,7 @@ export default function SSOForm() { const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') const [callbackUrl, setCallbackUrl] = useState('/workspace') useEffect(() => { @@ -96,9 +96,9 @@ export default function SSOForm() { const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 7259205bc8..ed05354b94 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -58,7 +58,7 @@ function VerificationForm({ setCountdown(30) } - const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [buttonClass, setButtonClass] = useState('branded-button-gradient') useEffect(() => { const checkCustomBrand = () => { @@ -66,9 +66,9 @@ function VerificationForm({ const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() if (brandAccent && brandAccent !== '#6f3dfa') { - setButtonClass('auth-button-custom') + setButtonClass('branded-button-custom') } else { - setButtonClass('auth-button-gradient') + setButtonClass('branded-button-gradient') } } diff --git a/apps/sim/app/(landing)/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 ( -
-