diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx index e091849c82..a9c50bb21e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx @@ -26,16 +26,21 @@ export interface CanvasMenuProps { onOpenLogs: () => void onToggleVariables: () => void onToggleChat: () => void + onToggleWorkflowLock?: () => void isVariablesOpen?: boolean isChatOpen?: boolean hasClipboard?: boolean disableEdit?: boolean - disableAdmin?: boolean + canAdmin?: boolean canUndo?: boolean canRedo?: boolean isInvitationsDisabled?: boolean /** Whether the workflow has locked blocks (disables auto-layout) */ hasLockedBlocks?: boolean + /** Whether all blocks in the workflow are locked */ + allBlocksLocked?: boolean + /** Whether the workflow has any blocks */ + hasBlocks?: boolean } /** @@ -56,13 +61,17 @@ export function CanvasMenu({ onOpenLogs, onToggleVariables, onToggleChat, + onToggleWorkflowLock, isVariablesOpen = false, isChatOpen = false, hasClipboard = false, disableEdit = false, + canAdmin = false, canUndo = false, canRedo = false, hasLockedBlocks = false, + allBlocksLocked = false, + hasBlocks = false, }: CanvasMenuProps) { return ( Auto-layout ⇧L + {canAdmin && onToggleWorkflowLock && ( + { + onToggleWorkflowLock() + onClose() + }} + > + {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} + + )} { onFitToView() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index ddd25134fc..cd5d8095b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -61,6 +61,9 @@ export const Notifications = memo(function Notifications() { case 'refresh': window.location.reload() break + case 'unlock-workflow': + window.dispatchEvent(new CustomEvent('unlock-workflow')) + break default: logger.warn('Unknown action type', { notificationId, actionType: action.type }) } @@ -175,7 +178,9 @@ export const Notifications = memo(function Notifications() { ? 'Fix in Copilot' : notification.action!.type === 'refresh' ? 'Refresh' - : 'Take action'} + : notification.action!.type === 'unlock-workflow' + ? 'Unlock Workflow' + : 'Take action'} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 03510936d7..e07976e4e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowUp, Square } from 'lucide-react' +import { ArrowUp, Lock, Square, Unlock } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { @@ -42,7 +42,9 @@ import { import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables' import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useChatStore } from '@/stores/chat/store' import { useNotificationStore } from '@/stores/notifications/store' @@ -126,6 +128,15 @@ export const Panel = memo(function Panel() { Object.values(state.blocks).some((block) => block.locked) ) + const allBlocksLocked = useWorkflowStore((state) => { + const blockList = Object.values(state.blocks) + return blockList.length > 0 && blockList.every((block) => block.locked) + }) + + const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0) + + const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow() + // Delete workflow hook const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ workspaceId, @@ -329,6 +340,17 @@ export const Panel = memo(function Panel() { workspaceId, ]) + /** + * Toggles the locked state of all blocks in the workflow + */ + const handleToggleWorkflowLock = useCallback(() => { + const blocks = useWorkflowStore.getState().blocks + const allLocked = Object.values(blocks).every((b) => b.locked) + const ids = getWorkflowLockToggleIds(blocks, !allLocked) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + setIsMenuOpen(false) + }, [collaborativeBatchToggleLocked]) + // Compute run button state const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading @@ -399,6 +421,16 @@ export const Panel = memo(function Panel() { Auto layout + {userPermissions.canAdmin && !currentWorkflow?.isSnapshotView && ( + + {allBlocksLocked ? ( + + ) : ( + + )} + {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} + + )} { setVariablesOpen(!isVariablesOpen)}> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 07bf5e1430..a20c1c356f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ )} - {!isEnabled && disabled} + {!isEnabled && !isLocked && disabled} {isLocked && locked} {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts index d86f1b3dce..602a8d784a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts @@ -71,3 +71,38 @@ export function filterProtectedBlocks( allProtected: protectedIds.length === blockIds.length && blockIds.length > 0, } } + +/** + * Returns block IDs ordered so that `batchToggleLocked` will target the desired state. + * + * `batchToggleLocked` determines its target locked state from `!firstBlock.locked`. + * When `targetLocked` is true (lock all), an unlocked block must come first. + * When `targetLocked` is false (unlock all), a locked block must come first. + * + * Returns an empty array when there are no blocks or all blocks already match `targetLocked`. + * + * @param blocks - Record of all blocks in the workflow + * @param targetLocked - The desired locked state for all blocks + * @returns Sorted block IDs, or empty array if no toggle is needed + */ +export function getWorkflowLockToggleIds( + blocks: Record, + targetLocked: boolean +): string[] { + const ids = Object.keys(blocks) + if (ids.length === 0) return [] + + // No-op if all blocks already match the desired state + const allMatch = Object.values(blocks).every((b) => Boolean(b.locked) === targetLocked) + if (allMatch) return [] + + ids.sort((a, b) => { + const aVal = blocks[a].locked ? 1 : 0 + const bVal = blocks[b].locked ? 1 : 0 + // To lock all (targetLocked=true): unlocked first (aVal - bVal) + // To unlock all (targetLocked=false): locked first (bVal - aVal) + return targetLocked ? aVal - bVal : bVal - aVal + }) + + return ids +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f88b9d9122..451e5a709f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -57,6 +57,7 @@ import { estimateBlockDimensions, filterProtectedBlocks, getClampedPositionForNode, + getWorkflowLockToggleIds, isBlockProtected, isEdgeProtected, isInEditableElement, @@ -393,6 +394,15 @@ const WorkflowContent = React.memo(() => { const { blocks, edges, lastSaved } = currentWorkflow + const allBlocksLocked = useMemo(() => { + const blockList = Object.values(blocks) + return blockList.length > 0 && blockList.every((b) => b.locked) + }, [blocks]) + + const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks]) + + const hasLockedBlocks = useMemo(() => Object.values(blocks).some((b) => b.locked), [blocks]) + const isWorkflowReady = useMemo( () => hydration.phase === 'ready' && @@ -1175,6 +1185,91 @@ const WorkflowContent = React.memo(() => { collaborativeBatchToggleLocked(blockIds) }, [contextMenuBlocks, collaborativeBatchToggleLocked]) + const handleToggleWorkflowLock = useCallback(() => { + const currentBlocks = useWorkflowStore.getState().blocks + const allLocked = Object.values(currentBlocks).every((b) => b.locked) + const ids = getWorkflowLockToggleIds(currentBlocks, !allLocked) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + }, [collaborativeBatchToggleLocked]) + + // Show notification when all blocks in the workflow are locked + const lockNotificationIdRef = useRef(null) + + const clearLockNotification = useCallback(() => { + if (lockNotificationIdRef.current) { + useNotificationStore.getState().removeNotification(lockNotificationIdRef.current) + lockNotificationIdRef.current = null + } + }, []) + + // Clear persisted lock notifications on mount/workflow change (prevents duplicates after reload) + useEffect(() => { + // Reset ref so the main effect creates a fresh notification for the new workflow + clearLockNotification() + + if (!activeWorkflowId) return + const store = useNotificationStore.getState() + const stale = store.notifications.filter( + (n) => + n.workflowId === activeWorkflowId && + (n.action?.type === 'unlock-workflow' || n.message.startsWith('This workflow is locked')) + ) + for (const n of stale) { + store.removeNotification(n.id) + } + }, [activeWorkflowId, clearLockNotification]) + + const prevCanAdminRef = useRef(effectivePermissions.canAdmin) + useEffect(() => { + if (!isWorkflowReady) return + + const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin + prevCanAdminRef.current = effectivePermissions.canAdmin + + // Clear stale notification when admin status changes so it recreates with correct message + if (canAdminChanged) { + clearLockNotification() + } + + if (allBlocksLocked) { + if (lockNotificationIdRef.current) return + + const isAdmin = effectivePermissions.canAdmin + lockNotificationIdRef.current = addNotification({ + level: 'info', + message: isAdmin + ? 'This workflow is locked' + : 'This workflow is locked. Ask an admin to unlock it.', + workflowId: activeWorkflowId || undefined, + ...(isAdmin ? { action: { type: 'unlock-workflow' as const, message: '' } } : {}), + }) + } else { + clearLockNotification() + } + }, [ + allBlocksLocked, + isWorkflowReady, + effectivePermissions.canAdmin, + addNotification, + activeWorkflowId, + clearLockNotification, + ]) + + // Clean up notification on unmount + useEffect(() => clearLockNotification, [clearLockNotification]) + + // Listen for unlock-workflow events from notification action button + useEffect(() => { + const handleUnlockWorkflow = () => { + const currentBlocks = useWorkflowStore.getState().blocks + const ids = getWorkflowLockToggleIds(currentBlocks, false) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + } + + window.addEventListener('unlock-workflow', handleUnlockWorkflow) + return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow) + }, [collaborativeBatchToggleLocked]) + const handleContextRemoveFromSubflow = useCallback(() => { const blocksToRemove = contextMenuBlocks.filter( (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') @@ -3699,7 +3794,11 @@ const WorkflowContent = React.memo(() => { disableEdit={!effectivePermissions.canEdit} canUndo={canUndo} canRedo={canRedo} - hasLockedBlocks={Object.values(blocks).some((b) => b.locked)} + hasLockedBlocks={hasLockedBlocks} + onToggleWorkflowLock={handleToggleWorkflowLock} + allBlocksLocked={allBlocksLocked} + canAdmin={effectivePermissions.canAdmin} + hasBlocks={hasBlocks} /> )} diff --git a/apps/sim/blocks/blocks/google_translate.ts b/apps/sim/blocks/blocks/google_translate.ts index 881feea0e5..81815f890f 100644 --- a/apps/sim/blocks/blocks/google_translate.ts +++ b/apps/sim/blocks/blocks/google_translate.ts @@ -1,6 +1,142 @@ import { GoogleTranslateIcon } from '@/components/icons' import { AuthMode, type BlockConfig } from '@/blocks/types' +const SUPPORTED_LANGUAGES = [ + { label: 'Afrikaans', id: 'af' }, + { label: 'Albanian', id: 'sq' }, + { label: 'Amharic', id: 'am' }, + { label: 'Arabic', id: 'ar' }, + { label: 'Armenian', id: 'hy' }, + { label: 'Assamese', id: 'as' }, + { label: 'Aymara', id: 'ay' }, + { label: 'Azerbaijani', id: 'az' }, + { label: 'Bambara', id: 'bm' }, + { label: 'Basque', id: 'eu' }, + { label: 'Belarusian', id: 'be' }, + { label: 'Bengali', id: 'bn' }, + { label: 'Bhojpuri', id: 'bho' }, + { label: 'Bosnian', id: 'bs' }, + { label: 'Bulgarian', id: 'bg' }, + { label: 'Catalan', id: 'ca' }, + { label: 'Cebuano', id: 'ceb' }, + { label: 'Chinese (Simplified)', id: 'zh-CN' }, + { label: 'Chinese (Traditional)', id: 'zh-TW' }, + { label: 'Corsican', id: 'co' }, + { label: 'Croatian', id: 'hr' }, + { label: 'Czech', id: 'cs' }, + { label: 'Danish', id: 'da' }, + { label: 'Dhivehi', id: 'dv' }, + { label: 'Dogri', id: 'doi' }, + { label: 'Dutch', id: 'nl' }, + { label: 'English', id: 'en' }, + { label: 'Esperanto', id: 'eo' }, + { label: 'Estonian', id: 'et' }, + { label: 'Ewe', id: 'ee' }, + { label: 'Filipino', id: 'tl' }, + { label: 'Finnish', id: 'fi' }, + { label: 'French', id: 'fr' }, + { label: 'Frisian', id: 'fy' }, + { label: 'Galician', id: 'gl' }, + { label: 'Georgian', id: 'ka' }, + { label: 'German', id: 'de' }, + { label: 'Greek', id: 'el' }, + { label: 'Guarani', id: 'gn' }, + { label: 'Gujarati', id: 'gu' }, + { label: 'Haitian Creole', id: 'ht' }, + { label: 'Hausa', id: 'ha' }, + { label: 'Hawaiian', id: 'haw' }, + { label: 'Hebrew', id: 'he' }, + { label: 'Hindi', id: 'hi' }, + { label: 'Hmong', id: 'hmn' }, + { label: 'Hungarian', id: 'hu' }, + { label: 'Icelandic', id: 'is' }, + { label: 'Igbo', id: 'ig' }, + { label: 'Ilocano', id: 'ilo' }, + { label: 'Indonesian', id: 'id' }, + { label: 'Irish', id: 'ga' }, + { label: 'Italian', id: 'it' }, + { label: 'Japanese', id: 'ja' }, + { label: 'Javanese', id: 'jv' }, + { label: 'Kannada', id: 'kn' }, + { label: 'Kazakh', id: 'kk' }, + { label: 'Khmer', id: 'km' }, + { label: 'Kinyarwanda', id: 'rw' }, + { label: 'Konkani', id: 'gom' }, + { label: 'Korean', id: 'ko' }, + { label: 'Krio', id: 'kri' }, + { label: 'Kurdish', id: 'ku' }, + { label: 'Kurdish (Sorani)', id: 'ckb' }, + { label: 'Kyrgyz', id: 'ky' }, + { label: 'Lao', id: 'lo' }, + { label: 'Latin', id: 'la' }, + { label: 'Latvian', id: 'lv' }, + { label: 'Lingala', id: 'ln' }, + { label: 'Lithuanian', id: 'lt' }, + { label: 'Luganda', id: 'lg' }, + { label: 'Luxembourgish', id: 'lb' }, + { label: 'Macedonian', id: 'mk' }, + { label: 'Maithili', id: 'mai' }, + { label: 'Malagasy', id: 'mg' }, + { label: 'Malay', id: 'ms' }, + { label: 'Malayalam', id: 'ml' }, + { label: 'Maltese', id: 'mt' }, + { label: 'Maori', id: 'mi' }, + { label: 'Marathi', id: 'mr' }, + { label: 'Meiteilon (Manipuri)', id: 'mni-Mtei' }, + { label: 'Mizo', id: 'lus' }, + { label: 'Mongolian', id: 'mn' }, + { label: 'Myanmar (Burmese)', id: 'my' }, + { label: 'Nepali', id: 'ne' }, + { label: 'Norwegian', id: 'no' }, + { label: 'Nyanja (Chichewa)', id: 'ny' }, + { label: 'Odia (Oriya)', id: 'or' }, + { label: 'Oromo', id: 'om' }, + { label: 'Pashto', id: 'ps' }, + { label: 'Persian', id: 'fa' }, + { label: 'Polish', id: 'pl' }, + { label: 'Portuguese', id: 'pt' }, + { label: 'Punjabi', id: 'pa' }, + { label: 'Quechua', id: 'qu' }, + { label: 'Romanian', id: 'ro' }, + { label: 'Russian', id: 'ru' }, + { label: 'Samoan', id: 'sm' }, + { label: 'Sanskrit', id: 'sa' }, + { label: 'Scots Gaelic', id: 'gd' }, + { label: 'Sepedi', id: 'nso' }, + { label: 'Serbian', id: 'sr' }, + { label: 'Sesotho', id: 'st' }, + { label: 'Shona', id: 'sn' }, + { label: 'Sindhi', id: 'sd' }, + { label: 'Sinhala', id: 'si' }, + { label: 'Slovak', id: 'sk' }, + { label: 'Slovenian', id: 'sl' }, + { label: 'Somali', id: 'so' }, + { label: 'Spanish', id: 'es' }, + { label: 'Sundanese', id: 'su' }, + { label: 'Swahili', id: 'sw' }, + { label: 'Swedish', id: 'sv' }, + { label: 'Tajik', id: 'tg' }, + { label: 'Tamil', id: 'ta' }, + { label: 'Tatar', id: 'tt' }, + { label: 'Telugu', id: 'te' }, + { label: 'Thai', id: 'th' }, + { label: 'Tigrinya', id: 'ti' }, + { label: 'Tsonga', id: 'ts' }, + { label: 'Turkish', id: 'tr' }, + { label: 'Turkmen', id: 'tk' }, + { label: 'Twi (Akan)', id: 'ak' }, + { label: 'Ukrainian', id: 'uk' }, + { label: 'Urdu', id: 'ur' }, + { label: 'Uyghur', id: 'ug' }, + { label: 'Uzbek', id: 'uz' }, + { label: 'Vietnamese', id: 'vi' }, + { label: 'Welsh', id: 'cy' }, + { label: 'Xhosa', id: 'xh' }, + { label: 'Yiddish', id: 'yi' }, + { label: 'Yoruba', id: 'yo' }, + { label: 'Zulu', id: 'zu' }, +] as const + export const GoogleTranslateBlock: BlockConfig = { type: 'google_translate', name: 'Google Translate', @@ -35,42 +171,8 @@ export const GoogleTranslateBlock: BlockConfig = { title: 'Target Language', type: 'dropdown', condition: { field: 'operation', value: 'text' }, - options: [ - { label: 'English', id: 'en' }, - { label: 'Spanish', id: 'es' }, - { label: 'French', id: 'fr' }, - { label: 'German', id: 'de' }, - { label: 'Italian', id: 'it' }, - { label: 'Portuguese', id: 'pt' }, - { label: 'Russian', id: 'ru' }, - { label: 'Japanese', id: 'ja' }, - { label: 'Korean', id: 'ko' }, - { label: 'Chinese (Simplified)', id: 'zh-CN' }, - { label: 'Chinese (Traditional)', id: 'zh-TW' }, - { label: 'Arabic', id: 'ar' }, - { label: 'Hindi', id: 'hi' }, - { label: 'Turkish', id: 'tr' }, - { label: 'Dutch', id: 'nl' }, - { label: 'Polish', id: 'pl' }, - { label: 'Swedish', id: 'sv' }, - { label: 'Thai', id: 'th' }, - { label: 'Vietnamese', id: 'vi' }, - { label: 'Indonesian', id: 'id' }, - { label: 'Ukrainian', id: 'uk' }, - { label: 'Czech', id: 'cs' }, - { label: 'Greek', id: 'el' }, - { label: 'Hebrew', id: 'he' }, - { label: 'Romanian', id: 'ro' }, - { label: 'Hungarian', id: 'hu' }, - { label: 'Danish', id: 'da' }, - { label: 'Finnish', id: 'fi' }, - { label: 'Norwegian', id: 'no' }, - { label: 'Bengali', id: 'bn' }, - { label: 'Malay', id: 'ms' }, - { label: 'Filipino', id: 'tl' }, - { label: 'Swahili', id: 'sw' }, - { label: 'Urdu', id: 'ur' }, - ], + searchable: true, + options: SUPPORTED_LANGUAGES, value: () => 'es', required: { field: 'operation', value: 'text' }, }, @@ -79,25 +181,8 @@ export const GoogleTranslateBlock: BlockConfig = { title: 'Source Language', type: 'dropdown', condition: { field: 'operation', value: 'text' }, - options: [ - { label: 'Auto-detect', id: '' }, - { label: 'English', id: 'en' }, - { label: 'Spanish', id: 'es' }, - { label: 'French', id: 'fr' }, - { label: 'German', id: 'de' }, - { label: 'Italian', id: 'it' }, - { label: 'Portuguese', id: 'pt' }, - { label: 'Russian', id: 'ru' }, - { label: 'Japanese', id: 'ja' }, - { label: 'Korean', id: 'ko' }, - { label: 'Chinese (Simplified)', id: 'zh-CN' }, - { label: 'Chinese (Traditional)', id: 'zh-TW' }, - { label: 'Arabic', id: 'ar' }, - { label: 'Hindi', id: 'hi' }, - { label: 'Turkish', id: 'tr' }, - { label: 'Dutch', id: 'nl' }, - { label: 'Polish', id: 'pl' }, - ], + searchable: true, + options: [{ label: 'Auto-detect', id: '' }, ...SUPPORTED_LANGUAGES], value: () => '', }, { diff --git a/apps/sim/lib/audit/log.ts b/apps/sim/lib/audit/log.ts index a4300b82bd..af3cf23bcd 100644 --- a/apps/sim/lib/audit/log.ts +++ b/apps/sim/lib/audit/log.ts @@ -131,6 +131,8 @@ export const AuditAction = { WORKFLOW_DUPLICATED: 'workflow.duplicated', WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_LOCKED: 'workflow.locked', + WORKFLOW_UNLOCKED: 'workflow.unlocked', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', // Workspaces diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index d677466cba..26ed43a325 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -207,6 +208,17 @@ export async function persistWorkflowOperation(workflowId: string, operation: an } }) + // Audit workflow-level lock/unlock operations + if ( + target === OPERATION_TARGETS.BLOCKS && + op === BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED && + userId + ) { + auditWorkflowLockToggle(workflowId, userId).catch((error) => { + logger.error('Failed to audit workflow lock toggle', { error, workflowId }) + }) + } + const duration = Date.now() - startTime if (duration > 100) { logger.warn('Slow socket DB operation:', { @@ -226,6 +238,45 @@ export async function persistWorkflowOperation(workflowId: string, operation: an } } +/** + * Records an audit log entry when all blocks in a workflow are locked or unlocked. + * Only audits workflow-level transitions (all locked or all unlocked), not partial toggles. + */ +async function auditWorkflowLockToggle(workflowId: string, actorId: string): Promise { + const [wf] = await db + .select({ name: workflow.name, workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + + if (!wf) return + + const blocks = await db + .select({ locked: workflowBlocks.locked }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)) + + if (blocks.length === 0) return + + const allLocked = blocks.every((b) => b.locked) + const allUnlocked = blocks.every((b) => !b.locked) + + // Only audit workflow-level transitions, not partial toggles + if (!allLocked && !allUnlocked) return + + recordAudit({ + workspaceId: wf.workspaceId, + actorId, + action: allLocked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: wf.name, + description: allLocked + ? `Locked workflow "${wf.name}"` + : `Unlocked workflow "${wf.name}"`, + metadata: { blockCount: blocks.length }, + }) +} + async function handleBlockOperationTx( tx: any, workflowId: string, diff --git a/apps/sim/stores/notifications/types.ts b/apps/sim/stores/notifications/types.ts index 678f540ce8..627b09bb66 100644 --- a/apps/sim/stores/notifications/types.ts +++ b/apps/sim/stores/notifications/types.ts @@ -6,7 +6,7 @@ export interface NotificationAction { /** * Action type identifier for handler reconstruction */ - type: 'copilot' | 'refresh' + type: 'copilot' | 'refresh' | 'unlock-workflow' /** * Message or data to pass to the action handler.