diff --git a/components/ui/credential-selector.tsx b/components/ui/credential-selector.tsx new file mode 100644 index 0000000..a9caab4 --- /dev/null +++ b/components/ui/credential-selector.tsx @@ -0,0 +1,320 @@ +"use client" + +import { useState, useEffect, useCallback } from 'react' +import { Plus, Key, Database, Mail, Globe, Trash2, Edit3 } from 'lucide-react' +import { Button } from './button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './dialog' +import { MobileSheet } from './mobile-sheet' +import { Input } from './input' +import { Label } from './label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select' +import { credentialStore, type StoredCredential } from '@/lib/credential-store' +import { SecurityBadge, SecurityWarning } from './security-status' +import { SECURITY_WARNINGS } from '@/lib/security' +import { CredentialType } from '@/types/credentials' + +interface CredentialSelectorProps { + value: string + onChange: (credentialId: string) => void + credentialType?: CredentialType + placeholder?: string + className?: string +} + +interface NewCredentialDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + credentialType: CredentialType + onCredentialCreated: (credentialId: string) => void +} + +function NewCredentialDialog({ open, onOpenChange, credentialType, onCredentialCreated }: NewCredentialDialogProps) { + const [name, setName] = useState('') + const [value, setValue] = useState('') + const [description, setDescription] = useState('') + const [loading, setLoading] = useState(false) + const [isMobile, setIsMobile] = useState(false) + + // 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) + }, []) + + const handleCreate = async () => { + if (!name.trim() || !value.trim()) return + + setLoading(true) + try { + const credentialId = credentialStore.storeCredential( + name.trim(), + value.trim(), + credentialType, + description.trim() || undefined + ) + + onCredentialCreated(credentialId) + onOpenChange(false) + + // Reset form + setName('') + setValue('') + setDescription('') + } catch (error) { + console.error('Failed to create credential:', error) + alert('Failed to create credential. Please try again.') + } finally { + setLoading(false) + } + } + + const getPlaceholder = () => { + switch (credentialType) { + case 'database': + return 'postgresql://user:password@localhost:5432/dbname' + case 'api': + return 'sk-1234567890abcdef...' + case 'email': + return 'your-app-password' + default: + return 'Your secret value' + } + } + + const getIcon = () => { + switch (credentialType) { + case 'database': + return + case 'api': + return + case 'email': + return + default: + return + } + } + + const getTypeLabel = () => { + switch (credentialType) { + case 'database': + return 'Database' + case 'api': + return 'API' + case 'email': + return 'Email' + default: + return 'Generic' + } + } + + const renderContent = () => ( +
+ + +
+ + setName(e.target.value)} + placeholder={`My ${getTypeLabel()} Connection`} + className="bg-white text-gray-900 placeholder:text-gray-400 border-gray-300 h-9" + /> +
+ +
+ + setValue(e.target.value)} + placeholder={getPlaceholder()} + className="bg-white text-gray-900 placeholder:text-gray-400 border-gray-300 h-9" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Brief description of this credential" + className="bg-white text-gray-900 placeholder:text-gray-400 border-gray-300 h-9" + /> +
+ +
+ +
+ + +
+
+
+ ) + + return ( + <> + {/* Mobile Sheet */} + {isMobile && ( + + {renderContent()} + + )} + + {/* Desktop Dialog */} + {!isMobile && ( + + + + + {getIcon()} + New {getTypeLabel()} Credential + + + Create a new secure credential that will be encrypted and stored locally. + + + + {renderContent()} + + + )} + + ) +} + +export function CredentialSelector({ + value, + onChange, + credentialType = 'generic', + placeholder = "Select a credential", + className = "" +}: CredentialSelectorProps) { + const [credentials, setCredentials] = useState[]>([]) + const [showNewDialog, setShowNewDialog] = useState(false) + const [mounted, setMounted] = useState(false) + + const loadCredentials = useCallback(() => { + const creds = credentialStore.getCredentialsByType(credentialType) + setCredentials(creds) + }, [credentialType]) + + useEffect(() => { + setMounted(true) + loadCredentials() + }, [credentialType, loadCredentials]) + + const handleCredentialCreated = (credentialId: string) => { + onChange(credentialId) + loadCredentials() // Refresh the list + } + + const getIcon = () => { + switch (credentialType) { + case 'database': + return + case 'api': + return + case 'email': + return + default: + return + } + } + + if (!mounted) { + return null // Avoid hydration issues + } + + const selectedCredential = credentials.find(c => c.id === value) + + return ( +
+
+ + + +
+ + +
+ ) +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 7326ef3..8a9c02e 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -121,7 +121,11 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName export { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogFooter, DialogTitle, } + +// Also export DialogTrigger from Radix for external use +export const DialogTrigger = DialogPrimitive.Trigger diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 76d76b5..afce835 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -6,15 +6,35 @@ 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' + +/** + * Safely gets the workflowId from URL search params + * @returns A validated and URI-encoded workflowId + */ +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) +} export function NodeConfigPanel() { const { nodes, selectedNodeId, isConfigPanelOpen, setConfigPanelOpen, setSelectedNodeId, updateNode, deleteNode, pendingDeleteNodeId, clearPendingDelete } = useWorkflowStore() @@ -113,21 +133,59 @@ export function NodeConfigPanel() { } const handleConfigChange = (path: string, value: unknown) => { - const setDeep = (obj: Record, p: string, v: unknown) => { + const setDeep = (obj: Record, p: string, v: unknown): Record => { const parts = p.split('.') - const clone: Record = { ...obj } + + // 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 (typeof next !== 'object' || next === null) { - 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 { - cur[key] = { ...(next as Record) } + // 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 } - cur[parts[parts.length - 1]] = v + + // 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) @@ -136,20 +194,67 @@ export function NodeConfigPanel() { }) } - const getValueAtPath = (obj: Record | undefined, path: string): unknown => { - if (!obj) return undefined - return path.split('.').reduce((acc: unknown, part: string) => { - if (acc && typeof acc === 'object') { - return (acc as Record)[part] + // 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 '' } - return undefined - }, obj) + } } const renderConfig = () => { const { data } = selectedNode const def = findNodeDefinition(selectedNode) 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 }) => (
@@ -187,78 +292,139 @@ export function NodeConfigPanel() {
)} - {def.parameters.map((param) => { - const shouldShow = !param.showIf || param.showIf.length === 0 - ? true - : param.showIf.some((cond) => getValueAtPath(data.config as Record, cond.path) === cond.equals) + {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 - const value = getValueAtPath(data.config as Record, param.path) switch (param.type) { - case 'select': + 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(param.path, e.target.value)} - placeholder={param.description} + value={String(value)} + onChange={(e) => handleConfigChange(paramPath, e.target.value)} + placeholder={description} className="bg-white text-gray-900 placeholder:text-gray-400 border-gray-300" />
) - case 'textarea': + } + case 'textarea': { + const value = getParamValue(param.path, 'string', param.default) + const description = getSafeDescription(param.description) return (
- +