diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index afce835..1447892 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -1,25 +1,33 @@ "use client" -import { X, Info, Copy, ShieldCheck } from 'lucide-react' +import { X } from 'lucide-react' import { useEffect, useState, useRef } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { CredentialSelector } from '@/components/ui/credential-selector' import { useWorkflowStore } from '@/hooks/use-workflow-store' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { MobileSheet } from '@/components/ui/mobile-sheet' import { useToast } from '@/components/ui/toaster' import { WorkflowNode, NodeType, ActionType, TriggerType, HttpNodeConfig, ScheduleNodeConfig } from '@/types/workflow' import { EMAIL_NODE_DEFINITION, EmailNodeConfig } from '@/nodes/EmailNode' -import { CredentialType, toCredentialType } from '@/types/credentials' import { WebhookNodeConfig } from '@/nodes/WebhookNode' import { findNodeDefinition } from '@/lib/node-definitions' -import { SECURITY_WARNINGS, getSecurityStatus } from '@/lib/security' -import { getArrayValue, getObjectValue, pathValueEquals, getTypedParameterValue, getSafeDescription, getSafePlaceholder, getValueAtPath, getSafeDefaultValue } from '@/lib/type-safe-utils' import { validateWorkflowId } from '@/lib/workflow-id-validation' +// Import modular components +import { + ParameterRenderer, + SecurityWarning, + HttpNodeConfiguration, + EmailActionNodeConfiguration, + ScheduleNodeConfiguration, + WebhookNodeConfiguration, + ManualNodeConfiguration, + useParameterState, + useNodeConfig +} from './node-config' + /** * Safely gets the workflowId from URL search params * @returns A validated and URI-encoded workflowId @@ -28,11 +36,11 @@ function getSafeWorkflowIdFromUrl(): string { if (typeof window === 'undefined') { return encodeURIComponent('') } - + const urlParams = new URLSearchParams(window.location.search) const workflowId = urlParams.get('workflowId') const validatedWorkflowId = validateWorkflowId(workflowId) - + return encodeURIComponent(validatedWorkflowId) } @@ -40,29 +48,15 @@ export function NodeConfigPanel() { const { nodes, selectedNodeId, isConfigPanelOpen, setConfigPanelOpen, setSelectedNodeId, updateNode, deleteNode, pendingDeleteNodeId, clearPendingDelete } = useWorkflowStore() const { toast } = useToast() const [confirmOpen, setConfirmOpen] = useState(false) - const [isMobile, setIsMobile] = useState(false) - const [jsonTextByPath, setJsonTextByPath] = useState>({}) - const [kvStateByPath, setKvStateByPath] = useState>({}) - // Detect screen size - useEffect(() => { - const checkScreenSize = () => { - setIsMobile(window.innerWidth < 640) // 640px is the 'sm' breakpoint - } - - checkScreenSize() - window.addEventListener('resize', checkScreenSize) - return () => window.removeEventListener('resize', checkScreenSize) - }, []) + // Use modular hooks + const { jsonTextByPath, kvStateByPath, updateJsonText, updateKvState, resetStates } = useParameterState() + const { selectedNode, isMobile, confirmOpenedFromPendingRef, handleConfigChange } = useNodeConfig(selectedNodeId) // Reset transient JSON editors when switching nodes useEffect(() => { - setJsonTextByPath({}) - setKvStateByPath({}) - }, [selectedNodeId]) - - // Track whether confirm dialog was opened due to a pending delete request from node header - const confirmOpenedFromPendingRef = useRef(false) + resetStates() + }, [selectedNodeId, resetStates]) // Open confirm dialog if a delete was requested from node header useEffect(() => { @@ -79,8 +73,8 @@ export function NodeConfigPanel() { setConfirmOpen(false) confirmOpenedFromPendingRef.current = false } - }, [pendingDeleteNodeId, selectedNodeId, setSelectedNodeId]) - + }, [pendingDeleteNodeId, selectedNodeId, setSelectedNodeId, confirmOpenedFromPendingRef]) + // If a delete has been requested, show dialog only and no side panel if (pendingDeleteNodeId) { return ( @@ -112,12 +106,13 @@ export function NodeConfigPanel() { ) } + if (!selectedNodeId && !pendingDeleteNodeId) return null - + const nodeId = selectedNodeId ?? pendingDeleteNodeId! - const selectedNode = nodes.find(n => n.id === nodeId) as WorkflowNode | undefined - if (!selectedNode) return null - + const selectedNodeData = nodes.find(n => n.id === nodeId) as WorkflowNode | undefined + if (!selectedNodeData) return null + const handleClose = () => { setSelectedNodeId(null); setConfigPanelOpen(false); clearPendingDelete() } const handleDelete = () => { if (!nodeId) return @@ -131,890 +126,57 @@ export function NodeConfigPanel() { variant: 'success', }) } - - const handleConfigChange = (path: string, value: unknown) => { - const setDeep = (obj: Record, p: string, v: unknown): Record => { - const parts = p.split('.') - - // Validate path segments to prevent prototype pollution - const dangerousSegments = ['__proto__', 'constructor', 'prototype'] - for (const part of parts) { - if (dangerousSegments.includes(part.toLowerCase())) { - throw new Error(`Invalid path segment: "${part}" - potential prototype pollution attempt`) - } - } - - // Type guard to check if value is a valid object - const isValidObject = (val: unknown): val is Record => { - return val !== null && typeof val === 'object' && !Array.isArray(val) - } - - // Create a safe clone using Object.create(null) for the root to avoid prototype chain - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Object.create(null) creates object without prototype - const clone: Record = Object.create(null) - - // Safely copy properties from the original object - for (const [key, val] of Object.entries(obj)) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - clone[key] = val - } - } - - let cur: Record = clone - for (let i = 0; i < parts.length - 1; i += 1) { - const key = parts[i] - const next = cur[key] - if (!isValidObject(next)) { - // Create safe object without prototype chain - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Object.create(null) creates object without prototype - cur[key] = Object.create(null) - } else { - // Safely clone the nested object - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Object.create(null) creates object without prototype - const nestedClone: Record = Object.create(null) - for (const [nestedKey, nestedVal] of Object.entries(next)) { - if (Object.prototype.hasOwnProperty.call(next, nestedKey)) { - nestedClone[nestedKey] = nestedVal - } - } - cur[key] = nestedClone - } - cur = cur[key] as Record - } - - // Set the final value only if the key is safe - const finalKey = parts[parts.length - 1] - cur[finalKey] = v - - return clone - } - const nextConfig = setDeep((selectedNode.data.config as Record) || {}, path, value) - updateNode(nodeId, { - config: nextConfig, - }) - } - // Type-safe path utilities are now imported from lib/type-safe-utils - - // Inline type-safe parameter helpers to avoid ESLint unsafe operations - const safeString = (value: unknown): string => typeof value === 'string' ? value : '' - const safeNumber = (value: unknown): number => typeof value === 'number' ? value : 0 - const safeBoolean = (value: unknown): boolean => typeof value === 'boolean' ? value : false - const safeObject = (value: unknown): Record => - (value && typeof value === 'object' && !Array.isArray(value)) ? value as Record : {} - - const getParamValue = (path: string, paramType: 'string' | 'number' | 'boolean', defaultVal: unknown): string | number | boolean => { - const config = (selectedNode.data.config as Record) || {} - try { - if (paramType === 'string') { - return getTypedParameterValue(config, path, defaultVal, 'string') - } else if (paramType === 'number') { - return getTypedParameterValue(config, path, defaultVal, 'number') - } else { - return getTypedParameterValue(config, path, defaultVal, 'boolean') - } - } catch { - // Fallback for type safety - switch (paramType) { - case 'string': - return '' - case 'number': - return 0 - case 'boolean': - return false - default: - return '' - } - } - } - + // Render configuration based on node type const renderConfig = () => { - const { data } = selectedNode - const def = findNodeDefinition(selectedNode) + const { data } = selectedNodeData + const def = findNodeDefinition(selectedNodeData) + + // Use parameter renderer for nodes with parameters if (def?.parameters && def.parameters.length > 0) { - // Define a proper interface for parameter definition - interface ExtendedParameterDefinition { - type: string - label: string - path: string - default?: unknown - description?: unknown - placeholder?: unknown - options?: Array<{ label: string; value: string }> | (() => Array<{ label: string; value: string }>) - showIf?: Array<{ path?: string; name?: string; equals: string | number | boolean }> - credentialType?: CredentialType - } - - // Type guard function to check if parameter has required properties - const isValidParameter = (param: unknown): param is ExtendedParameterDefinition => { - if (!param || typeof param !== 'object') return false - const p = param as Record - return typeof p.type === 'string' && - typeof p.label === 'string' && - typeof p.path === 'string' - } - - const parameters = def.parameters.filter(isValidParameter) as ExtendedParameterDefinition[] - const FieldLabel = ({ text, description, htmlFor }: { text: string; description?: string; htmlFor?: string }) => ( -
- - {description ? ( - - ) : null} -
- ) return ( <> {/* Security warnings for email credentials */} - {selectedNode.data.nodeType === NodeType.ACTION && - (selectedNode.data as { actionType: ActionType }).actionType === ActionType.EMAIL && ( -
-
- -
-

Security Notice

-
    -
  • • Your credentials are encrypted and stored locally on your device only
  • -
  • • Use app-specific passwords instead of your main email password
  • -
  • • Data is automatically cleared when you close the browser
  • -
  • • Only use on trusted devices for maximum security
  • -
-
-
-
- )} - - {parameters.map((param) => { - // Type-safe showIf condition checking with runtime guards - const shouldShow = (() => { - // Check if showIf exists and is an array - if (!Array.isArray(param.showIf) || param.showIf.length === 0) { - return true - } - - // Safely assert selectedNode.data.config exists - const config = selectedNode?.data?.config - if (!config || typeof config !== 'object') { - return true // Show by default if config is invalid - } - - // Check if any condition matches with safe predicate - return param.showIf.some((cond) => { - // Verify cond is an object and has required properties - if (!cond || typeof cond !== 'object') { - return false - } - - // Type guard for condition structure - const isValidCondition = (c: unknown): c is { path?: string; name?: string; equals: string | number | boolean } => { - if (!c || typeof c !== 'object') return false - const condition = c as Record - - // Must have either path or name (but not both) as strings - const hasPath = typeof condition.path === 'string' - const hasName = typeof condition.name === 'string' - const hasEquals = condition.equals !== undefined - - return (hasPath || hasName) && hasEquals && !(hasPath && hasName) - } - - if (!isValidCondition(cond)) { - return false - } - - // Extract the path or name safely - const pathToCheck = cond.path || cond.name || '' - if (!pathToCheck) { - return false - } - - return pathValueEquals(config as Record, pathToCheck, cond.equals) - }) - })() - - if (!shouldShow) return null - switch (param.type) { - case 'select': { - const config = (selectedNode.data.config as Record) || {} - const paramPath = param.path - const currentValue = getValueAtPath(config, paramPath) - const defaultVal = getSafeDefaultValue(param.default, 'string') - const value = typeof currentValue === 'string' ? currentValue : defaultVal - const description = getSafeDescription(param.description) - return ( -
- - -
- ) - } - case 'string': - case 'text': { - // Allow both 'string' and 'text' parameter types for compatibility - const paramPath = param.path - const value = getParamValue(paramPath, 'string', param.default) - const description = getSafeDescription(param.description) - return ( -
- - handleConfigChange(paramPath, e.target.value)} - placeholder={description} - className="bg-white text-gray-900 placeholder:text-gray-400 border-gray-300" - /> -
- ) - } - case 'textarea': { - const value = getParamValue(param.path, 'string', param.default) - const description = getSafeDescription(param.description) - return ( -
- -