diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index a4c4390b360..ffcfbb7995d 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -18,6 +18,7 @@ const updateFolderSchema = z.object({ isExpanded: z.boolean().optional(), parentId: z.string().nullable().optional(), sortOrder: z.number().int().min(0).optional(), + isLocked: z.boolean().optional(), }) // PUT - Update a folder @@ -42,7 +43,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 }) } - const { name, color, isExpanded, parentId, sortOrder } = validationResult.data + const { name, color, isExpanded, parentId, sortOrder, isLocked } = validationResult.data // Verify the folder exists const existingFolder = await db @@ -69,6 +70,27 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } + // If toggling isLocked, require admin permission + if (isLocked !== undefined && workspacePermission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required to lock/unlock folders' }, + { status: 403 } + ) + } + + // If folder is locked, only allow toggling isLocked and isExpanded (by admins) + if (existingFolder.isLocked && isLocked === undefined) { + // Allow isExpanded toggle on locked folders (UI collapse/expand) + const hasNonExpandUpdates = + name !== undefined || + color !== undefined || + parentId !== undefined || + sortOrder !== undefined + if (hasNonExpandUpdates) { + return NextResponse.json({ error: 'Folder is locked' }, { status: 403 }) + } + } + // Prevent setting a folder as its own parent or creating circular references if (parentId && parentId === id) { return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) @@ -91,6 +113,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ if (isExpanded !== undefined) updates.isExpanded = isExpanded if (parentId !== undefined) updates.parentId = parentId || null if (sortOrder !== undefined) updates.sortOrder = sortOrder + if (isLocked !== undefined) updates.isLocked = isLocked const [updatedFolder] = await db .update(workflowFolder) @@ -144,6 +167,10 @@ export async function DELETE( ) } + if (existingFolder.isLocked) { + return NextResponse.json({ error: 'Folder is locked' }, { status: 403 }) + } + const result = await performDeleteFolder({ folderId: id, workspaceId: existingFolder.workspaceId, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 3d74fe527fa..ed89d3bb529 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -19,6 +19,7 @@ const UpdateWorkflowSchema = z.object({ color: z.string().optional(), folderId: z.string().nullable().optional(), sortOrder: z.number().int().min(0).optional(), + isLocked: z.boolean().optional(), }) /** @@ -182,6 +183,10 @@ export async function DELETE( ) } + if (workflowData.isLocked) { + return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 }) + } + const { searchParams } = new URL(request.url) const checkTemplates = searchParams.get('check-templates') === 'true' const deleteTemplatesParam = searchParams.get('deleteTemplates') @@ -288,12 +293,33 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } + // If toggling isLocked, require admin permission + if (updates.isLocked !== undefined) { + const adminAuth = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'admin', + }) + if (!adminAuth.allowed) { + return NextResponse.json( + { error: 'Admin access required to lock/unlock workflows' }, + { status: 403 } + ) + } + } + + // If workflow is locked, only allow toggling isLocked (by admins) + if (workflowData.isLocked && updates.isLocked === undefined) { + return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 }) + } + const updateData: Record = { updatedAt: new Date() } if (updates.name !== undefined) updateData.name = updates.name if (updates.description !== undefined) updateData.description = updates.description if (updates.color !== undefined) updateData.color = updates.color if (updates.folderId !== undefined) updateData.folderId = updates.folderId if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder + if (updates.isLocked !== undefined) updateData.isLocked = updates.isLocked if (updates.name !== undefined || updates.folderId !== undefined) { const targetName = updates.name ?? workflowData.name diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 90e5425966a..673942e8fd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -74,10 +74,12 @@ import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' import { useWorkspaceEnvironment } from '@/hooks/queries/environment' +import { useFolderMap } from '@/hooks/queries/folders' import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings' import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock' import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return' import { useCanvasModeStore } from '@/stores/canvas-mode' import { useChatStore } from '@/stores/chat/store' @@ -290,6 +292,8 @@ const WorkflowContent = React.memo( isPlaceholderData: isWorkflowMapPlaceholderData, } = useWorkflowMap(workspaceId) + const { data: folderMap } = useFolderMap(workspaceId) + const { activeWorkflowId, hydration, @@ -608,7 +612,16 @@ const WorkflowContent = React.memo( const { userPermissions, workspacePermissions, permissionsError } = useWorkspacePermissionsContext() - /** Returns read-only permissions when viewing snapshot, otherwise user permissions. */ + const activeWorkflowMetadata = activeWorkflowId ? workflows[activeWorkflowId] : undefined + const isWorkflowLocked = useMemo( + () => + activeWorkflowMetadata + ? isWorkflowEffectivelyLocked(activeWorkflowMetadata, folderMap ?? {}) + : false, + [activeWorkflowMetadata, folderMap] + ) + + /** Returns read-only permissions when viewing snapshot or locked workflow. */ const effectivePermissions = useMemo(() => { if (currentWorkflow.isSnapshotView) { return { @@ -618,8 +631,15 @@ const WorkflowContent = React.memo( canRead: userPermissions.canRead, } } + if (isWorkflowLocked) { + return { + ...userPermissions, + canEdit: false, + canRead: userPermissions.canRead, + } + } return userPermissions - }, [userPermissions, currentWorkflow.isSnapshotView]) + }, [userPermissions, currentWorkflow.isSnapshotView, isWorkflowLocked]) const { collaborativeBatchAddEdges, collaborativeBatchRemoveEdges, 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 afae818c6ac..c63ff58e1f8 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 @@ -3,7 +3,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import clsx from 'clsx' -import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react' +import { ChevronRight, Folder, FolderOpen, Lock, MoreHorizontal } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { generateId } from '@/lib/core/utils/uuid' import { getNextWorkflowColor } from '@/lib/workflows/colors' @@ -27,10 +27,11 @@ import { useExportFolder, useExportSelection, } from '@/app/workspace/[workspaceId]/w/hooks' -import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' +import { useCreateFolder, useFolderMap, useUpdateFolder } from '@/hooks/queries/folders' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { useCreateWorkflow } from '@/hooks/queries/workflows' +import { isFolderEffectivelyLocked } from '@/hooks/use-effective-lock' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -71,6 +72,23 @@ export function FolderItem({ const selectedFolders = useFolderStore((state) => state.selectedFolders) const isSelected = selectedFolders.has(folder.id) + const { data: folderMap } = useFolderMap(workspaceId) + const isEffectivelyLocked = useMemo( + () => isFolderEffectivelyLocked(folder.id, folderMap ?? {}), + [folder.id, folderMap] + ) + const isDirectlyLocked = folder.isLocked ?? false + const isLockedByParent = isEffectivelyLocked && !isDirectlyLocked + + const handleToggleLock = useCallback(() => { + updateFolderMutation.mutate({ + workspaceId, + id: folder.id, + updates: { isLocked: !isDirectlyLocked }, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId, folder.id, isDirectlyLocked]) + const { canDeleteFolder, canDeleteWorkflows } = useCanDelete({ workspaceId }) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) @@ -301,11 +319,12 @@ export function FolderItem({ const handleDoubleClick = useCallback( (e: React.MouseEvent) => { + if (isEffectivelyLocked) return e.preventDefault() e.stopPropagation() handleStartEdit() }, - [handleStartEdit] + [handleStartEdit, isEffectivelyLocked] ) const handleClick = useCallback( @@ -505,6 +524,9 @@ export function FolderItem({ > {folder.name} + {isEffectivelyLocked && ( + + )}