mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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: () => '',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user