feat(workflow): lock/unlock workflow from context menu and panel (#3336)

* feat(workflow): lock/unlock workflow from context menu and panel

* lint

* fix(workflow): prevent duplicate lock notifications, no-op guard, fix orphaned JSDoc

* improvement(workflow): memoize hasLockedBlocks to avoid inline recomputation

* feat(google-translate): add Google Translate integration (#3337)

* feat(google-translate): add Google Translate integration

* fix(google-translate): api key as query param, fix docsLink, rename tool file

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338)

* feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar

* fix(google-drive): remove dead transformResponse from move tool

* feat(confluence): return page content in get page version tool (#3344)

* feat(confluence): return page content in get page version tool

* lint

* feat(api): audit log read endpoints for admin and enterprise (#3343)

* feat(api): audit log read endpoints for admin and enterprise

* fix(api): address PR review — boolean coercion, cursor validation, detail scope

* ran lint

* unified list of languages for google translate

* fix(workflow): respect snapshot view for panel lock toggle, remove unused disableAdmin prop

* improvement(canvas-menu): remove lock icon from workflow lock toggle

* feat(audit): record audit log for workflow lock/unlock
This commit is contained in:
Waleed
2026-02-25 15:23:30 -08:00
committed by GitHub
parent 1f3dc52d15
commit 244e1ee495
10 changed files with 390 additions and 61 deletions

View File

@@ -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 (
<Popover
@@ -142,6 +151,17 @@ export function CanvasMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
{canAdmin && onToggleWorkflowLock && (
<PopoverItem
disabled={!hasBlocks}
onClick={() => {
onToggleWorkflowLock()
onClose()
}}
>
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
<PopoverItem
onClick={() => {
onFitToView()

View File

@@ -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'}
</Button>
)}
</div>

View File

@@ -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() {
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>
</PopoverItem>
{userPermissions.canAdmin && !currentWorkflow?.isSnapshotView && (
<PopoverItem onClick={handleToggleWorkflowLock} disabled={!hasBlocks}>
{allBlocksLocked ? (
<Unlock className='h-3 w-3' />
) : (
<Lock className='h-3 w-3' />
)}
<span>{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}</span>
</PopoverItem>
)}
{
<PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
<VariableIcon className='h-3 w-3' />

View File

@@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Content>
</Tooltip.Root>
)}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (

View File

@@ -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<string, BlockState>,
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
}

View File

@@ -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<string | null>(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}
/>
</>
)}

View File

@@ -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: () => '',
},
{

View File

@@ -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

View File

@@ -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<void> {
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,

View File

@@ -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.