From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/2] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From fe826913d3dfb30cb16da5219eb8d28c1fb9f0f7 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 15:49:22 -0700 Subject: [PATCH 2/2] fix(uploads): gate execution-context uploads behind write/admin permission Fallback multipart upload route (/api/files/upload) had no workspace permission check for execution-context uploads, unlike the primary presigned-upload route which requires write/admin. Mirror that gate so both paths enforce the same access control. --- apps/sim/app/api/files/upload/route.test.ts | 141 ++++++++++++++++++++ apps/sim/app/api/files/upload/route.ts | 14 +- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 356d993d610..bbc42c5e743 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => { const mockGetStorageProvider = vi.fn() const mockIsUsingCloudStorage = vi.fn() const mockUploadFile = vi.fn() + const mockUploadExecutionFile = vi.fn() return { mockVerifyFileAccess, @@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => { mockGetStorageProvider, mockIsUsingCloudStorage, mockUploadFile, + mockUploadExecutionFile, } }) @@ -82,6 +84,10 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ uploadWorkspaceFile: mocks.mockUploadWorkspaceFile, })) +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mocks.mockUploadExecutionFile, +})) + vi.mock('@/lib/uploads', () => ({ getStorageProvider: mocks.mockGetStorageProvider, isUsingCloudStorage: mocks.mockIsUsingCloudStorage, @@ -151,6 +157,17 @@ function setupFileApiMocks( expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) + mocks.mockUploadExecutionFile.mockResolvedValue({ + id: 'test-execution-file-id', + name: 'test.txt', + url: '/api/files/serve/execution/test-workspace-id/test-file.txt', + size: 100, + type: 'text/plain', + key: 'execution/test-workspace-id/1234567890-test.txt', + uploadedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }) + mocks.mockGetStorageProvider.mockReturnValue(storageProvider) mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) mocks.mockUploadFile.mockResolvedValue({ @@ -531,6 +548,130 @@ describe('File Upload Security Tests', () => { }) }) + describe('Execution Context Permission Gate', () => { + const createExecutionFormData = ( + file: File, + workspaceId: string | null = 'test-workspace-id' + ) => { + const formData = new FormData() + formData.append('file', file) + formData.append('context', 'execution') + formData.append('workflowId', 'test-workflow-id') + formData.append('executionId', 'test-execution-id') + if (workspaceId !== null) formData.append('workspaceId', workspaceId) + return formData + } + + beforeEach(() => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + }) + + it('rejects execution uploads without workspaceId', async () => { + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) + const formData = createExecutionFormData(file, null) + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req as unknown as NextRequest) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.message).toContain('workflowId, executionId, and workspaceId') + expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() + }) + + it('rejects execution uploads for a read-only workspace member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') + + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) + const formData = createExecutionFormData(file) + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req as unknown as NextRequest) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Write or Admin access required for execution uploads') + expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() + }) + + it('rejects execution uploads for a member with no workspace permission', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) + + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) + const formData = createExecutionFormData(file) + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req as unknown as NextRequest) + + expect(response.status).toBe(403) + expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() + }) + + it('allows execution uploads for a write-permission workspace member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) + const formData = createExecutionFormData(file) + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req as unknown as NextRequest) + + expect(response.status).toBe(200) + expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith( + { + workspaceId: 'test-workspace-id', + workflowId: 'test-workflow-id', + executionId: 'test-execution-id', + }, + expect.anything(), + 'test.pdf', + 'application/pdf', + 'test-user-id' + ) + }) + + it('allows execution uploads for an admin-permission workspace member', async () => { + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') + + const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) + const formData = createExecutionFormData(file) + + const req = new Request('http://localhost/api/files/upload', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req as unknown as NextRequest) + + expect(response.status).toBe(200) + expect(mocks.mockUploadExecutionFile).toHaveBeenCalled() + }) + }) + describe('Authentication Requirements', () => { it('should reject uploads without authentication', async () => { authMockFns.mockGetSession.mockResolvedValue(null) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index cb23b6ec851..a68fb9da045 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -111,16 +111,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { // Handle execution context if (context === 'execution') { - if (!workflowId || !executionId) { + if (!workflowId || !executionId || !workspaceId) { throw new InvalidRequestError( - 'Execution context requires workflowId and executionId parameters' + 'Execution context requires workflowId, executionId, and workspaceId parameters' + ) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for execution uploads' }, + { status: 403 } ) } const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') const userFile = await uploadExecutionFile( { - workspaceId: workspaceId || '', + workspaceId, workflowId, executionId, },