Skip to content

Commit de71cd7

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 0f602f7 commit de71cd7

File tree

16 files changed

+14842
-53
lines changed

16 files changed

+14842
-53
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 { generateId } from '@/lib/core/utils/uuid'
99
import { getNextWorkflowColor } from '@/lib/workflows/colors'
@@ -27,10 +27,11 @@ import {
2727
useExportFolder,
2828
useExportSelection,
2929
} from '@/app/workspace/[workspaceId]/w/hooks'
30-
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
30+
import { useCreateFolder, useFolderMap, useUpdateFolder } from '@/hooks/queries/folders'
3131
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
3232
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
3333
import { useCreateWorkflow } from '@/hooks/queries/workflows'
34+
import { isFolderEffectivelyLocked } from '@/hooks/use-effective-lock'
3435
import { useFolderStore } from '@/stores/folders/store'
3536
import type { FolderTreeNode } from '@/stores/folders/types'
3637
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -71,6 +72,23 @@ export function FolderItem({
7172
const selectedFolders = useFolderStore((state) => state.selectedFolders)
7273
const isSelected = selectedFolders.has(folder.id)
7374

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

7694
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
@@ -301,11 +319,12 @@ export function FolderItem({
301319

302320
const handleDoubleClick = useCallback(
303321
(e: React.MouseEvent) => {
322+
if (isEffectivelyLocked) return
304323
e.preventDefault()
305324
e.stopPropagation()
306325
handleStartEdit()
307326
},
308-
[handleStartEdit]
327+
[handleStartEdit, isEffectivelyLocked]
309328
)
310329

311330
const handleClick = useCallback(
@@ -505,6 +524,9 @@ export function FolderItem({
505524
>
506525
{folder.name}
507526
</span>
527+
{isEffectivelyLocked && (
528+
<Lock className='h-3 w-3 flex-shrink-0 text-[var(--text-icon)]' />
529+
)}
508530
<button
509531
type='button'
510532
aria-label='Folder options'
@@ -538,14 +560,22 @@ export function FolderItem({
538560
showRename={!isMixedSelection && selectedFolders.size <= 1}
539561
showDuplicate={true}
540562
showExport={true}
541-
disableRename={!userPermissions.canEdit}
542-
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
543-
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
563+
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
564+
disableCreate={
565+
!userPermissions.canEdit || createWorkflowMutation.isPending || isEffectivelyLocked
566+
}
567+
disableCreateFolder={
568+
!userPermissions.canEdit || createFolderMutation.isPending || isEffectivelyLocked
569+
}
544570
disableDuplicate={
545571
!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent
546572
}
547573
disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent}
548-
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
574+
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}
575+
onToggleLock={handleToggleLock}
576+
showLock={!isMixedSelection && selectedFolders.size <= 1}
577+
disableLock={!userPermissions.canAdmin || isLockedByParent}
578+
isLocked={isEffectivelyLocked}
549579
/>
550580

551581
<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,11 +2,10 @@
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 { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
9-
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
109
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
1110
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
1211
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
@@ -25,13 +24,13 @@ import {
2524
useExportSelection,
2625
useExportWorkflow,
2726
} from '@/app/workspace/[workspaceId]/w/hooks'
27+
import { useFolderMap } from '@/hooks/queries/folders'
2828
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
2929
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
3030
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
31+
import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock'
3132
import { useFolderStore } from '@/stores/folders/store'
32-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
3333
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
34-
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
3534

3635
interface WorkflowItemProps {
3736
workflow: WorkflowMetadata
@@ -174,28 +173,21 @@ export function WorkflowItem({
174173
[workflow.id, workspaceId]
175174
)
176175

177-
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
178-
const isActiveWorkflow = workflow.id === activeWorkflowId
179-
180-
const isWorkflowLocked = useWorkflowStore(
181-
useCallback(
182-
(state) => {
183-
if (!isActiveWorkflow) return false
184-
const blockValues = Object.values(state.blocks)
185-
if (blockValues.length === 0) return false
186-
return blockValues.every((block) => block.locked)
187-
},
188-
[isActiveWorkflow]
189-
)
176+
const { data: folderMap } = useFolderMap(workspaceId)
177+
const isEffectivelyLocked = useMemo(
178+
() => isWorkflowEffectivelyLocked(workflow, folderMap ?? {}),
179+
[workflow, folderMap]
190180
)
181+
const isLockedByFolder = isEffectivelyLocked && !workflow.isLocked
191182

192183
const handleToggleLock = useCallback(() => {
193-
if (!isActiveWorkflow) return
194-
const blocks = useWorkflowStore.getState().blocks
195-
const blockIds = getWorkflowLockToggleIds(blocks, !isWorkflowLocked)
196-
if (blockIds.length === 0) return
197-
window.dispatchEvent(new CustomEvent('toggle-workflow-lock', { detail: { blockIds } }))
198-
}, [isActiveWorkflow, isWorkflowLocked])
184+
updateWorkflowMutation.mutate({
185+
workspaceId,
186+
workflowId: workflow.id,
187+
metadata: { isLocked: !workflow.isLocked },
188+
})
189+
// eslint-disable-next-line react-hooks/exhaustive-deps
190+
}, [workspaceId, workflow.id, workflow.isLocked])
199191

200192
const isEditingRef = useRef(false)
201193

@@ -359,11 +351,12 @@ export function WorkflowItem({
359351

360352
const handleDoubleClick = useCallback(
361353
(e: React.MouseEvent) => {
354+
if (isEffectivelyLocked) return
362355
e.preventDefault()
363356
e.stopPropagation()
364357
handleStartEdit()
365358
},
366-
[handleStartEdit]
359+
[handleStartEdit, isEffectivelyLocked]
367360
)
368361

369362
const handleClick = useCallback(
@@ -447,6 +440,9 @@ export function WorkflowItem({
447440
{workflow.name}
448441
</div>
449442
)}
443+
{!isEditing && isEffectivelyLocked && (
444+
<Lock className='h-3 w-3 flex-shrink-0 text-[var(--text-icon)]' />
445+
)}
450446
{!isEditing && <Avatars workflowId={workflow.id} />}
451447
</div>
452448
</div>
@@ -486,15 +482,15 @@ export function WorkflowItem({
486482
showDuplicate={true}
487483
showExport={true}
488484
showColorChange={!isMixedSelection && selectedWorkflows.size <= 1}
489-
disableRename={!userPermissions.canEdit}
485+
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
490486
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
491487
disableExport={!userPermissions.canEdit}
492-
disableColorChange={!userPermissions.canEdit}
493-
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
488+
disableColorChange={!userPermissions.canEdit || isEffectivelyLocked}
489+
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}
494490
onToggleLock={handleToggleLock}
495-
showLock={isActiveWorkflow && !isMixedSelection && selectedWorkflows.size <= 1}
496-
disableLock={!userPermissions.canAdmin}
497-
isLocked={isWorkflowLocked}
491+
showLock={!isMixedSelection && selectedWorkflows.size <= 1}
492+
disableLock={!userPermissions.canAdmin || isLockedByFolder}
493+
isLocked={isEffectivelyLocked}
498494
/>
499495

500496
<DeleteModal

0 commit comments

Comments
 (0)