diff --git a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx index 3bd682f43ef..8dc11e1bee8 100644 --- a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx +++ b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx @@ -216,6 +216,7 @@ export function SandboxCanvasProvider({ workspaceId: SANDBOX_WORKSPACE_ID, sortOrder: 0, isSandbox: true, + isLocked: false, } useWorkflowStore.getState().replaceWorkflowState(workflowState) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index be6cea9d429..de8df70fba3 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -66,11 +67,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } const targetWorkspaceId = workspaceId || sourceFolder.workspaceId + const targetParentId = parentId ?? sourceFolder.parentId + + if (targetParentId) { + const parentLocked = await isFolderEffectivelyLockedDb(targetParentId) + if (parentLocked) { + return NextResponse.json( + { error: 'Cannot duplicate a folder into a locked folder' }, + { status: 403 } + ) + } + } const { newFolderId, folderMapping } = await db.transaction(async (tx) => { const newFolderId = clientNewId || generateId() const now = new Date() - const targetParentId = parentId ?? sourceFolder.parentId const folderParentCondition = targetParentId ? eq(workflowFolder.parentId, targetParentId) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index a4c4390b360..2c6f29c48eb 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { captureServerEvent } from '@/lib/posthog/server' +import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { performDeleteFolder } from '@/lib/workflows/orchestration' import { checkForCircularReference } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -18,6 +19,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 +44,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 +71,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 effectively locked, only allow isLocked toggle (admin) and isExpanded toggle + const effectivelyLocked = await isFolderEffectivelyLockedDb(id) + if (effectivelyLocked) { + const hasNonAllowedUpdates = + name !== undefined || + color !== undefined || + parentId !== undefined || + sortOrder !== undefined + if (hasNonAllowedUpdates) { + 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 +114,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 +168,11 @@ export async function DELETE( ) } + const effectivelyLocked = await isFolderEffectivelyLockedDb(id) + if (effectivelyLocked) { + 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/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 653d8301658..9547a80b085 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { isFolderEffectivelyLocked, type LockableFolder } from '@/lib/workflows/lock' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') @@ -44,7 +45,12 @@ export async function PUT(req: NextRequest) { const folderIds = updates.map((u) => u.id) const existingFolders = await db - .select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId }) + .select({ + id: workflowFolder.id, + workspaceId: workflowFolder.workspaceId, + parentId: workflowFolder.parentId, + isLocked: workflowFolder.isLocked, + }) .from(workflowFolder) .where(inArray(workflowFolder.id, folderIds)) @@ -58,6 +64,37 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) } + // Build folder map for cascade lock checks + const allFolders = await db + .select({ + id: workflowFolder.id, + parentId: workflowFolder.parentId, + isLocked: workflowFolder.isLocked, + }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)) + + const folderMap: Record = {} + for (const f of allFolders) { + folderMap[f.id] = f + } + + // Block if any source folder or destination parent is effectively locked + for (const update of validUpdates) { + if (isFolderEffectivelyLocked(update.id, folderMap)) { + return NextResponse.json( + { error: 'Cannot move or reorder a locked folder' }, + { status: 403 } + ) + } + if (update.parentId && isFolderEffectivelyLocked(update.parentId, folderMap)) { + return NextResponse.json( + { error: 'Cannot move folders into a locked folder' }, + { status: 403 } + ) + } + } + await db.transaction(async (tx) => { for (const update of validUpdates) { const updateData: Record = { diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 98e80f5aa3d..aa07a31bb44 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' import { captureServerEvent } from '@/lib/posthog/server' +import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') @@ -97,6 +98,16 @@ export async function POST(request: NextRequest) { ) } + if (parentId) { + const parentLocked = await isFolderEffectivelyLockedDb(parentId) + if (parentLocked) { + return NextResponse.json( + { error: 'Cannot create a folder inside a locked folder' }, + { status: 403 } + ) + } + } + const id = clientId || generateId() const newFolder = await db.transaction(async (tx) => { diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 63c230f686b..c87387879b7 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -6,6 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' +import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' const logger = createLogger('WorkflowDuplicateAPI') @@ -37,6 +38,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const { name, description, color, workspaceId, folderId, newId } = DuplicateRequestSchema.parse(body) + if (folderId) { + const folderLocked = await isFolderEffectivelyLockedDb(folderId) + if (folderLocked) { + return NextResponse.json( + { error: 'Cannot duplicate a workflow into a locked folder' }, + { status: 403 } + ) + } + } + logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) const result = await duplicateWorkflow({ diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 3d74fe527fa..21b722029ad 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' +import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' @@ -19,6 +20,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 +184,10 @@ export async function DELETE( ) } + if (await isWorkflowEffectivelyLockedDb(workflowId)) { + 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 +294,42 @@ 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 effectively locked, only allow isLocked toggle (by admins) + const effectivelyLocked = await isWorkflowEffectivelyLockedDb(workflowId) + if (effectivelyLocked) { + const hasNonLockUpdates = + updates.name !== undefined || + updates.description !== undefined || + updates.color !== undefined || + updates.folderId !== undefined || + updates.sortOrder !== undefined + if (hasNonLockUpdates) { + 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/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 26a63ecdd81..3d3626abbe6 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { loadWorkflowFromNormalizedTables, @@ -199,6 +200,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } + if (await isWorkflowEffectivelyLockedDb(workflowId)) { + return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 }) + } + // Sanitize custom tools in agent blocks before saving const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( state.blocks as Record diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 1b4cd8ab3b9..7e51b42e54d 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import type { Variable } from '@/stores/variables/types' @@ -65,6 +66,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } + if (await isWorkflowEffectivelyLockedDb(workflowId)) { + return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 }) + } + const body = await req.json() try { diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index d574ea1e663..373a64ad84c 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -1,11 +1,16 @@ import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' +import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { + isFolderEffectivelyLocked, + isWorkflowEffectivelyLocked, + type LockableFolder, +} from '@/lib/workflows/lock' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowReorderAPI') @@ -44,7 +49,12 @@ export async function PUT(req: NextRequest) { const workflowIds = updates.map((u) => u.id) const existingWorkflows = await db - .select({ id: workflow.id, workspaceId: workflow.workspaceId }) + .select({ + id: workflow.id, + workspaceId: workflow.workspaceId, + isLocked: workflow.isLocked, + folderId: workflow.folderId, + }) .from(workflow) .where(inArray(workflow.id, workflowIds)) @@ -58,6 +68,41 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 }) } + // Build folder map for cascade lock checks + const allFolders = await db + .select({ + id: workflowFolder.id, + parentId: workflowFolder.parentId, + isLocked: workflowFolder.isLocked, + }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)) + + const folderMap: Record = {} + for (const f of allFolders) { + folderMap[f.id] = f + } + + // Build workflow lookup for lock checks + const workflowLookup = new Map(existingWorkflows.map((w) => [w.id, w])) + + // Block if any source workflow or destination folder is effectively locked + for (const update of validUpdates) { + const wf = workflowLookup.get(update.id) + if (wf && isWorkflowEffectivelyLocked(wf, folderMap)) { + return NextResponse.json( + { error: 'Cannot move or reorder a locked workflow' }, + { status: 403 } + ) + } + if (update.folderId && isFolderEffectivelyLocked(update.folderId, folderMap)) { + return NextResponse.json( + { error: 'Cannot move workflows into a locked folder' }, + { status: 403 } + ) + } + } + await db.transaction(async (tx) => { for (const update of validUpdates) { const updateData: Record = { diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 3615afd0890..1f0ce197981 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -11,6 +11,7 @@ import { generateId } from '@/lib/core/utils/uuid' import { captureServerEvent } from '@/lib/posthog/server' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' @@ -163,6 +164,16 @@ export async function POST(req: NextRequest) { ) } + if (folderId) { + const folderLocked = await isFolderEffectivelyLockedDb(folderId) + if (folderLocked) { + return NextResponse.json( + { error: 'Cannot create a workflow inside a locked folder' }, + { status: 403 } + ) + } + } + const workflowId = clientId || generateId() const now = new Date() diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index ac5bf1ab167..aedb5f4b8be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -326,6 +326,7 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId workspaceId, folderId: null, sortOrder, + isLocked: false, } const queryClient = getQueryClient() const key = workflowKeys.list(workspaceId, 'active') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 90e5425966a..4b1f4a6d423 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -23,6 +23,7 @@ import { generateId } from '@/lib/core/utils/uuid' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import type { OAuthProvider } from '@/lib/oauth' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' +import { isWorkflowEffectivelyLocked } from '@/lib/workflows/lock' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -74,6 +75,7 @@ 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' @@ -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 d179568c316..f0378d9e58f 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,11 +3,12 @@ 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 { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { generateId } from '@/lib/core/utils/uuid' import { getNextWorkflowColor } from '@/lib/workflows/colors' +import { isFolderEffectivelyLocked } from '@/lib/workflows/lock' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' @@ -32,7 +33,7 @@ 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' @@ -76,6 +77,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) @@ -325,11 +343,12 @@ export function FolderItem({ const handleDoubleClick = useCallback( (e: React.MouseEvent) => { + if (isEffectivelyLocked) return e.preventDefault() e.stopPropagation() handleStartEdit() }, - [handleStartEdit] + [handleStartEdit, isEffectivelyLocked] ) const handleClick = useCallback( @@ -479,7 +498,7 @@ export function FolderItem({ onClick={handleClick} onKeyDown={handleKeyDown} onContextMenu={handleContextMenu} - draggable={!isEditing && !dragDisabled} + draggable={!isEditing && !dragDisabled && !isEffectivelyLocked} onDragStart={handleDragStart} onDragEnd={handleDragEnd} {...hoverHandlers} @@ -529,6 +548,9 @@ export function FolderItem({ > {folder.name} + {isEffectivelyLocked && ( + + )}