diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 8c42b9656..773427e23 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -141,6 +141,23 @@ export async function DELETE( ) } + // Check if deleting this folder would delete the last workflow(s) in the workspace + const workflowsInFolder = await countWorkflowsInFolderRecursively( + id, + existingFolder.workspaceId + ) + const totalWorkflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, existingFolder.workspaceId)) + + if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) { + return NextResponse.json( + { error: 'Cannot delete folder containing the only workflow(s) in the workspace' }, + { status: 400 } + ) + } + // Recursively delete folder and all its contents const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId) @@ -202,6 +219,34 @@ async function deleteFolderRecursively( return stats } +/** + * Counts the number of workflows in a folder and all its subfolders recursively. + */ +async function countWorkflowsInFolderRecursively( + folderId: string, + workspaceId: string +): Promise { + let count = 0 + + const workflowsInFolder = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) + + count += workflowsInFolder.length + + const childFolders = await db + .select({ id: workflowFolder.id }) + .from(workflowFolder) + .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) + + for (const childFolder of childFolders) { + count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId) + } + + return count +} + // Helper function to check for circular references async function checkForCircularReference(folderId: string, parentId: string): Promise { let currentParentId: string | null = parentId diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index def0eff81..abae66199 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn() const mockGetWorkflowAccessContext = vi.fn() const mockDbDelete = vi.fn() const mockDbUpdate = vi.fn() +const mockDbSelect = vi.fn() vi.mock('@/lib/auth', () => ({ getSession: () => mockGetSession(), @@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({ db: { delete: () => mockDbDelete(), update: () => mockDbUpdate(), + select: () => mockDbSelect(), }, workflow: {}, })) @@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => { isWorkspaceOwner: false, }) + // Mock db.select() to return multiple workflows so deletion is allowed + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]), + }), + }) + mockDbDelete.mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), }) @@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => { expect(data.success).toBe(true) }) + it('should prevent deletion of the last workflow in workspace', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + mockGetSession.mockResolvedValue({ + user: { id: 'user-123' }, + }) + + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockGetWorkflowAccessContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceOwnerId: 'workspace-456', + workspacePermission: 'admin', + isOwner: true, + isWorkspaceOwner: false, + }) + + // Mock db.select() to return only 1 workflow (the one being deleted) + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), + }), + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await DELETE(req, { params }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Cannot delete the only workflow in the workspace') + }) + it.concurrent('should deny deletion for non-admin users', async () => { const mockWorkflow = { id: 'workflow-123', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index e62c245da..c4bab613d 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -228,6 +228,21 @@ export async function DELETE( return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + // Check if this is the last workflow in the workspace + if (workflowData.workspaceId) { + const totalWorkflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, workflowData.workspaceId)) + + if (totalWorkflowsInWorkspace.length <= 1) { + return NextResponse.json( + { error: 'Cannot delete the only workflow in the workspace' }, + { status: 400 } + ) + } + } + // Check if workflow has published templates before deletion const { searchParams } = new URL(request.url) const checkTemplates = searchParams.get('check-templates') === 'true' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index d71a3641d..4d95232a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -339,12 +339,31 @@ export function CreateBaseModal({
- + + {/* Hidden decoy fields to prevent browser autofill */} +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index d7b5bfa69..bbe606381 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {

Enter a name for your API key to help you identify it later.

+ {/* Hidden decoy fields to prevent browser autofill */} + { @@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) { placeholder='e.g., Development, Production' className='h-9' autoFocus + name='api_key_label' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' /> {createError && (

diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 4285142fe..15b36fffb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -141,12 +141,37 @@ export function MemberInvitationCard({ {/* Main invitation input */}

+ {/* Hidden decoy fields to prevent browser autofill */} + + {emailError && (

diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx index 40e1754c8..582ca27d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -55,16 +55,31 @@ export function NoOrganizationView({ {/* Form fields - clean layout without card */}

+ {/* Hidden decoy field to prevent browser autofill */} +
-
@@ -116,31 +131,52 @@ export function NoOrganizationView({
+ {/* Hidden decoy field to prevent browser autofill */} +
-
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx index 1b955bbb6..882e59f42 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile.tsx @@ -390,11 +390,26 @@ export function TemplateProfile() { disabled={isUploadingProfilePicture} />
+ {/* Hidden decoy field to prevent browser autofill */} + updateField('name', e.target.value)} className='h-9 flex-1' + name='profile_display_name' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' />
{uploadError &&

{uploadError}

} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index c1a87b63e..f29103dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' @@ -15,7 +15,11 @@ import { useItemRename, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks' +import { + useCanDelete, + useDeleteFolder, + useDuplicateFolder, +} from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' import { useCreateWorkflow } from '@/hooks/queries/workflows' import type { FolderTreeNode } from '@/stores/folders/store' @@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { const createFolderMutation = useCreateFolder() const userPermissions = useUserPermissionsContext() + const { canDeleteFolder } = useCanDelete({ workspaceId }) + const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id]) + // Delete modal state const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) @@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending} disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending} disableDuplicate={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableDelete={!userPermissions.canEdit || !canDelete} /> {/* Delete Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index a4018c63d..ceeab77d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -14,6 +14,7 @@ import { useItemRename, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { + useCanDelete, useDeleteWorkflow, useDuplicateWorkflow, useExportWorkflow, @@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf const userPermissions = useUserPermissionsContext() const isSelected = selectedWorkflows.has(workflow.id) + // Can delete check hook + const { canDeleteWorkflows } = useCanDelete({ workspaceId }) + // Delete modal state const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState([]) const [deleteModalNames, setDeleteModalNames] = useState('') + const [canDeleteCaptured, setCanDeleteCaptured] = useState(true) // Presence avatars state const [hasAvatars, setHasAvatars] = useState(false) @@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0], } + // Check if the captured selection can be deleted + setCanDeleteCaptured(canDeleteWorkflows(workflowIds)) + // If already selected with multiple selections, keep all selections handleContextMenuBase(e) }, - [workflow.id, workflows, handleContextMenuBase] + [workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows] ) // Rename hook @@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf disableRename={!userPermissions.canEdit} disableDuplicate={!userPermissions.canEdit} disableExport={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} + disableDelete={!userPermissions.canEdit || !canDeleteCaptured} /> {/* Delete Confirmation Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index a4749cde7..3aaba70b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr Invite members to {workspaceName || 'Workspace'} -
+
+ {/* Hidden decoy fields to prevent browser autofill */} + +
{invalidEmails.map((email, index) => ( ))} setInputValue(e.target.value)} @@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr )} autoFocus={userPerms.canAdmin} disabled={isSubmitting || !userPerms.canAdmin} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + data-lpignore='true' + data-form-type='other' + aria-autocomplete='none' />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts index 5b4c1153a..1ebc8bfbf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts @@ -1,3 +1,4 @@ +export { useCanDelete } from './use-can-delete' export { useDeleteFolder } from './use-delete-folder' export { useDeleteWorkflow } from './use-delete-workflow' export { useDuplicateFolder } from './use-duplicate-folder' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts new file mode 100644 index 000000000..f206fa90c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts @@ -0,0 +1,130 @@ +import { useCallback, useMemo } from 'react' +import { useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface UseCanDeleteProps { + /** + * Current workspace ID + */ + workspaceId: string +} + +interface UseCanDeleteReturn { + /** + * Checks if the given workflow IDs can be deleted. + * Returns false if deleting them would leave no workflows in the workspace. + */ + canDeleteWorkflows: (workflowIds: string[]) => boolean + /** + * Checks if the given folder can be deleted. + * Returns false if deleting it would leave no workflows in the workspace. + */ + canDeleteFolder: (folderId: string) => boolean + /** + * Total number of workflows in the workspace. + */ + totalWorkflows: number +} + +/** + * Hook for checking if workflows or folders can be deleted. + * Prevents deletion if it would leave the workspace with no workflows. + * + * Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls. + * + * @param props - Hook configuration + * @returns Functions to check deletion eligibility + */ +export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn { + const { workflows } = useWorkflowRegistry() + const { folders } = useFolderStore() + + /** + * Pre-computed data structures for efficient lookups + */ + const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } = + useMemo(() => { + const workspaceWorkflows = Object.values(workflows).filter( + (w) => w.workspaceId === workspaceId + ) + + const idSet = new Set(workspaceWorkflows.map((w) => w.id)) + + const byFolderId = new Map() + for (const w of workspaceWorkflows) { + if (w.folderId) { + byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1) + } + } + + const childrenByParent = new Map() + for (const folder of Object.values(folders)) { + if (folder.workspaceId === workspaceId && folder.parentId) { + const children = childrenByParent.get(folder.parentId) || [] + children.push(folder.id) + childrenByParent.set(folder.parentId, children) + } + } + + return { + totalWorkflows: workspaceWorkflows.length, + workflowIdSet: idSet, + workflowsByFolderId: byFolderId, + childFoldersByParentId: childrenByParent, + } + }, [workflows, folders, workspaceId]) + + /** + * Count workflows in a folder and all its subfolders recursively. + * Uses pre-computed maps for efficient lookups. + */ + const countWorkflowsInFolder = useCallback( + (folderId: string): number => { + let count = workflowsByFolderId.get(folderId) || 0 + + const childFolders = childFoldersByParentId.get(folderId) + if (childFolders) { + for (const childId of childFolders) { + count += countWorkflowsInFolder(childId) + } + } + + return count + }, + [workflowsByFolderId, childFoldersByParentId] + ) + + /** + * Check if the given workflow IDs can be deleted. + * Returns false if deleting would remove all workflows from the workspace. + */ + const canDeleteWorkflows = useCallback( + (workflowIds: string[]): boolean => { + const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length + + // Must have at least one workflow remaining after deletion + return totalWorkflows > 0 && workflowsToDelete < totalWorkflows + }, + [totalWorkflows, workflowIdSet] + ) + + /** + * Check if the given folder can be deleted. + * Empty folders are always deletable. Folders containing all workspace workflows are not. + */ + const canDeleteFolder = useCallback( + (folderId: string): boolean => { + const workflowsInFolder = countWorkflowsInFolder(folderId) + + if (workflowsInFolder === 0) return true + return workflowsInFolder < totalWorkflows + }, + [totalWorkflows, countWorkflowsInFolder] + ) + + return { + canDeleteWorkflows, + canDeleteFolder, + totalWorkflows, + } +} diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index fce043c39..a01f30c9f 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn' * Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement. */ const POPOVER_ITEM_BASE_CLASSES = - 'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed' + 'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75' /** * Variant-specific active state styles for popover items. @@ -425,7 +425,10 @@ export interface PopoverItemProps extends React.HTMLAttributes { * ``` */ const PopoverItem = React.forwardRef( - ({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => { + ( + { className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props }, + ref + ) => { // Try to get context - if not available, we're outside Popover (shouldn't happen) const context = React.useContext(PopoverContext) const variant = context?.variant || 'default' @@ -435,18 +438,28 @@ const PopoverItem = React.forwardRef( return null } + const handleClick = (e: React.MouseEvent) => { + if (disabled) { + e.stopPropagation() + return + } + onClick?.(e) + } + return (
{children}