Skip to content

Commit beb8911

Browse files
waleedlatif1claude
andcommitted
feat(workflows): add isLocked to workflows and folders with cascade lock support
Add first-class `isLocked` property to workflows and folders that makes locked items fully read-only (canvas, sidebar rename/color/move/delete). Locked folders cascade to all contained workflows and sub-folders. Lock icon shown in sidebar, admin-only toggle via context menu. Coexists with block-level `locked` for granular protection. Also excludes block-level `locked` from diff detection so locking no longer flips deploy status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 694f4a5 commit beb8911

File tree

14 files changed

+196
-56
lines changed

14 files changed

+196
-56
lines changed

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const updateFolderSchema = z.object({
1818
isExpanded: z.boolean().optional(),
1919
parentId: z.string().nullable().optional(),
2020
sortOrder: z.number().int().min(0).optional(),
21+
isLocked: z.boolean().optional(),
2122
})
2223

2324
// PUT - Update a folder
@@ -42,7 +43,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
4243
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
4344
}
4445

45-
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
46+
const { name, color, isExpanded, parentId, sortOrder, isLocked } = validationResult.data
4647

4748
// Verify the folder exists
4849
const existingFolder = await db
@@ -69,6 +70,27 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
6970
)
7071
}
7172

73+
// If toggling isLocked, require admin permission
74+
if (isLocked !== undefined && workspacePermission !== 'admin') {
75+
return NextResponse.json(
76+
{ error: 'Admin access required to lock/unlock folders' },
77+
{ status: 403 }
78+
)
79+
}
80+
81+
// If folder is locked, only allow toggling isLocked and isExpanded (by admins)
82+
if (existingFolder.isLocked && isLocked === undefined) {
83+
// Allow isExpanded toggle on locked folders (UI collapse/expand)
84+
const hasNonExpandUpdates =
85+
name !== undefined ||
86+
color !== undefined ||
87+
parentId !== undefined ||
88+
sortOrder !== undefined
89+
if (hasNonExpandUpdates) {
90+
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
91+
}
92+
}
93+
7294
// Prevent setting a folder as its own parent or creating circular references
7395
if (parentId && parentId === id) {
7496
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<{
91113
if (isExpanded !== undefined) updates.isExpanded = isExpanded
92114
if (parentId !== undefined) updates.parentId = parentId || null
93115
if (sortOrder !== undefined) updates.sortOrder = sortOrder
116+
if (isLocked !== undefined) updates.isLocked = isLocked
94117

95118
const [updatedFolder] = await db
96119
.update(workflowFolder)
@@ -144,6 +167,10 @@ export async function DELETE(
144167
)
145168
}
146169

170+
if (existingFolder.isLocked) {
171+
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
172+
}
173+
147174
const result = await performDeleteFolder({
148175
folderId: id,
149176
workspaceId: existingFolder.workspaceId,

apps/sim/app/api/workflows/[id]/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const UpdateWorkflowSchema = z.object({
1919
color: z.string().optional(),
2020
folderId: z.string().nullable().optional(),
2121
sortOrder: z.number().int().min(0).optional(),
22+
isLocked: z.boolean().optional(),
2223
})
2324

2425
/**
@@ -182,6 +183,10 @@ export async function DELETE(
182183
)
183184
}
184185

186+
if (workflowData.isLocked) {
187+
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
188+
}
189+
185190
const { searchParams } = new URL(request.url)
186191
const checkTemplates = searchParams.get('check-templates') === 'true'
187192
const deleteTemplatesParam = searchParams.get('deleteTemplates')
@@ -288,12 +293,33 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
288293
)
289294
}
290295

296+
// If toggling isLocked, require admin permission
297+
if (updates.isLocked !== undefined) {
298+
const adminAuth = await authorizeWorkflowByWorkspacePermission({
299+
workflowId,
300+
userId,
301+
action: 'admin',
302+
})
303+
if (!adminAuth.allowed) {
304+
return NextResponse.json(
305+
{ error: 'Admin access required to lock/unlock workflows' },
306+
{ status: 403 }
307+
)
308+
}
309+
}
310+
311+
// If workflow is locked, only allow toggling isLocked (by admins)
312+
if (workflowData.isLocked && updates.isLocked === undefined) {
313+
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
314+
}
315+
291316
const updateData: Record<string, unknown> = { updatedAt: new Date() }
292317
if (updates.name !== undefined) updateData.name = updates.name
293318
if (updates.description !== undefined) updateData.description = updates.description
294319
if (updates.color !== undefined) updateData.color = updates.color
295320
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
296321
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
322+
if (updates.isLocked !== undefined) updateData.isLocked = updates.isLocked
297323

298324
if (updates.name !== undefined || updates.folderId !== undefined) {
299325
const targetName = updates.name ?? workflowData.name

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
7474
import { getBlock } from '@/blocks'
7575
import { isAnnotationOnlyBlock } from '@/executor/constants'
7676
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
77+
import { useFolderMap } from '@/hooks/queries/folders'
7778
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
7879
import { useWorkflowMap } from '@/hooks/queries/workflows'
7980
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
8081
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
82+
import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock'
8183
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
8284
import { useCanvasModeStore } from '@/stores/canvas-mode'
8385
import { useChatStore } from '@/stores/chat/store'
@@ -290,6 +292,8 @@ const WorkflowContent = React.memo(
290292
isPlaceholderData: isWorkflowMapPlaceholderData,
291293
} = useWorkflowMap(workspaceId)
292294

295+
const { data: folderMap } = useFolderMap(workspaceId)
296+
293297
const {
294298
activeWorkflowId,
295299
hydration,
@@ -608,7 +612,16 @@ const WorkflowContent = React.memo(
608612

609613
const { userPermissions, workspacePermissions, permissionsError } =
610614
useWorkspacePermissionsContext()
611-
/** Returns read-only permissions when viewing snapshot, otherwise user permissions. */
615+
const activeWorkflowMetadata = activeWorkflowId ? workflows[activeWorkflowId] : undefined
616+
const isWorkflowLocked = useMemo(
617+
() =>
618+
activeWorkflowMetadata
619+
? isWorkflowEffectivelyLocked(activeWorkflowMetadata, folderMap ?? {})
620+
: false,
621+
[activeWorkflowMetadata, folderMap]
622+
)
623+
624+
/** Returns read-only permissions when viewing snapshot or locked workflow. */
612625
const effectivePermissions = useMemo(() => {
613626
if (currentWorkflow.isSnapshotView) {
614627
return {
@@ -618,8 +631,15 @@ const WorkflowContent = React.memo(
618631
canRead: userPermissions.canRead,
619632
}
620633
}
634+
if (isWorkflowLocked) {
635+
return {
636+
...userPermissions,
637+
canEdit: false,
638+
canRead: userPermissions.canRead,
639+
}
640+
}
621641
return userPermissions
622-
}, [userPermissions, currentWorkflow.isSnapshotView])
642+
}, [userPermissions, currentWorkflow.isSnapshotView, isWorkflowLocked])
623643
const {
624644
collaborativeBatchAddEdges,
625645
collaborativeBatchRemoveEdges,

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useCallback, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import clsx from 'clsx'
6-
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
6+
import { ChevronRight, Folder, FolderOpen, Lock, MoreHorizontal } from 'lucide-react'
77
import { useParams, useRouter } from 'next/navigation'
88
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
99
import { generateId } from '@/lib/core/utils/uuid'
@@ -32,10 +32,11 @@ import {
3232
useExportFolder,
3333
useExportSelection,
3434
} from '@/app/workspace/[workspaceId]/w/hooks'
35-
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
35+
import { useCreateFolder, useFolderMap, useUpdateFolder } from '@/hooks/queries/folders'
3636
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
3737
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
3838
import { useCreateWorkflow } from '@/hooks/queries/workflows'
39+
import { isFolderEffectivelyLocked } from '@/hooks/use-effective-lock'
3940
import { useFolderStore } from '@/stores/folders/store'
4041
import type { FolderTreeNode } from '@/stores/folders/types'
4142
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -76,6 +77,23 @@ export function FolderItem({
7677
const selectedFolders = useFolderStore((state) => state.selectedFolders)
7778
const isSelected = selectedFolders.has(folder.id)
7879

80+
const { data: folderMap } = useFolderMap(workspaceId)
81+
const isEffectivelyLocked = useMemo(
82+
() => isFolderEffectivelyLocked(folder.id, folderMap ?? {}),
83+
[folder.id, folderMap]
84+
)
85+
const isDirectlyLocked = folder.isLocked ?? false
86+
const isLockedByParent = isEffectivelyLocked && !isDirectlyLocked
87+
88+
const handleToggleLock = useCallback(() => {
89+
updateFolderMutation.mutate({
90+
workspaceId,
91+
id: folder.id,
92+
updates: { isLocked: !isDirectlyLocked },
93+
})
94+
// eslint-disable-next-line react-hooks/exhaustive-deps
95+
}, [workspaceId, folder.id, isDirectlyLocked])
96+
7997
const { canDeleteFolder, canDeleteWorkflows } = useCanDelete({ workspaceId })
8098

8199
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
@@ -325,11 +343,12 @@ export function FolderItem({
325343

326344
const handleDoubleClick = useCallback(
327345
(e: React.MouseEvent) => {
346+
if (isEffectivelyLocked) return
328347
e.preventDefault()
329348
e.stopPropagation()
330349
handleStartEdit()
331350
},
332-
[handleStartEdit]
351+
[handleStartEdit, isEffectivelyLocked]
333352
)
334353

335354
const handleClick = useCallback(
@@ -529,6 +548,9 @@ export function FolderItem({
529548
>
530549
{folder.name}
531550
</span>
551+
{isEffectivelyLocked && (
552+
<Lock className='h-3 w-3 flex-shrink-0 text-[var(--text-icon)]' />
553+
)}
532554
<button
533555
type='button'
534556
aria-label='Folder options'
@@ -562,14 +584,22 @@ export function FolderItem({
562584
showRename={!isMixedSelection && selectedFolders.size <= 1}
563585
showDuplicate={true}
564586
showExport={true}
565-
disableRename={!userPermissions.canEdit}
566-
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
567-
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
587+
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
588+
disableCreate={
589+
!userPermissions.canEdit || createWorkflowMutation.isPending || isEffectivelyLocked
590+
}
591+
disableCreateFolder={
592+
!userPermissions.canEdit || createFolderMutation.isPending || isEffectivelyLocked
593+
}
568594
disableDuplicate={
569595
!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent
570596
}
571597
disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent}
572-
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
598+
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}
599+
onToggleLock={handleToggleLock}
600+
showLock={!isMixedSelection && selectedFolders.size <= 1}
601+
disableLock={!userPermissions.canAdmin || isLockedByParent}
602+
isLocked={isEffectivelyLocked}
573603
/>
574604

575605
<DeleteModal

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
import { useCallback, useMemo, useRef, useState } from 'react'
44
import clsx from 'clsx'
5-
import { MoreHorizontal } from 'lucide-react'
5+
import { Lock, MoreHorizontal } from 'lucide-react'
66
import Link from 'next/link'
77
import { useParams } from 'next/navigation'
88
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
99
import { workflowBorderColor } from '@/lib/workspaces/colors'
1010
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
11-
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
1211
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
1312
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
1413
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
@@ -31,13 +30,13 @@ import {
3130
useExportSelection,
3231
useExportWorkflow,
3332
} from '@/app/workspace/[workspaceId]/w/hooks'
33+
import { useFolderMap } from '@/hooks/queries/folders'
3434
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
3535
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
3636
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
37+
import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock'
3738
import { useFolderStore } from '@/stores/folders/store'
38-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
3939
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
40-
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
4140

4241
interface WorkflowItemProps {
4342
workflow: WorkflowMetadata
@@ -180,28 +179,21 @@ export function WorkflowItem({
180179
[workflow.id, workspaceId]
181180
)
182181

183-
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
184-
const isActiveWorkflow = workflow.id === activeWorkflowId
185-
186-
const isWorkflowLocked = useWorkflowStore(
187-
useCallback(
188-
(state) => {
189-
if (!isActiveWorkflow) return false
190-
const blockValues = Object.values(state.blocks)
191-
if (blockValues.length === 0) return false
192-
return blockValues.every((block) => block.locked)
193-
},
194-
[isActiveWorkflow]
195-
)
182+
const { data: folderMap } = useFolderMap(workspaceId)
183+
const isEffectivelyLocked = useMemo(
184+
() => isWorkflowEffectivelyLocked(workflow, folderMap ?? {}),
185+
[workflow, folderMap]
196186
)
187+
const isLockedByFolder = isEffectivelyLocked && !workflow.isLocked
197188

198189
const handleToggleLock = useCallback(() => {
199-
if (!isActiveWorkflow) return
200-
const blocks = useWorkflowStore.getState().blocks
201-
const blockIds = getWorkflowLockToggleIds(blocks, !isWorkflowLocked)
202-
if (blockIds.length === 0) return
203-
window.dispatchEvent(new CustomEvent('toggle-workflow-lock', { detail: { blockIds } }))
204-
}, [isActiveWorkflow, isWorkflowLocked])
190+
updateWorkflowMutation.mutate({
191+
workspaceId,
192+
workflowId: workflow.id,
193+
metadata: { isLocked: !workflow.isLocked },
194+
})
195+
// eslint-disable-next-line react-hooks/exhaustive-deps
196+
}, [workspaceId, workflow.id, workflow.isLocked])
205197

206198
const isEditingRef = useRef(false)
207199
const dragGhostRef = useRef<HTMLElement | null>(null)
@@ -385,11 +377,12 @@ export function WorkflowItem({
385377

386378
const handleDoubleClick = useCallback(
387379
(e: React.MouseEvent) => {
380+
if (isEffectivelyLocked) return
388381
e.preventDefault()
389382
e.stopPropagation()
390383
handleStartEdit()
391384
},
392-
[handleStartEdit]
385+
[handleStartEdit, isEffectivelyLocked]
393386
)
394387

395388
const handleClick = useCallback(
@@ -473,6 +466,9 @@ export function WorkflowItem({
473466
{workflow.name}
474467
</div>
475468
)}
469+
{!isEditing && isEffectivelyLocked && (
470+
<Lock className='h-3 w-3 flex-shrink-0 text-[var(--text-icon)]' />
471+
)}
476472
{!isEditing && <Avatars workflowId={workflow.id} />}
477473
</div>
478474
</div>
@@ -512,15 +508,15 @@ export function WorkflowItem({
512508
showDuplicate={true}
513509
showExport={true}
514510
showColorChange={!isMixedSelection && selectedWorkflows.size <= 1}
515-
disableRename={!userPermissions.canEdit}
511+
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
516512
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
517513
disableExport={!userPermissions.canEdit}
518-
disableColorChange={!userPermissions.canEdit}
519-
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
514+
disableColorChange={!userPermissions.canEdit || isEffectivelyLocked}
515+
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}
520516
onToggleLock={handleToggleLock}
521-
showLock={isActiveWorkflow && !isMixedSelection && selectedWorkflows.size <= 1}
522-
disableLock={!userPermissions.canAdmin}
523-
isLocked={isWorkflowLocked}
517+
showLock={!isMixedSelection && selectedWorkflows.size <= 1}
518+
disableLock={!userPermissions.canAdmin || isLockedByFolder}
519+
isLocked={isEffectivelyLocked}
524520
/>
525521

526522
<DeleteModal

0 commit comments

Comments
 (0)