/** * CreateTriggerModal - Modal for creating event triggers * * Supports trigger types: * - webhook: External HTTP request trigger * - event: ZCLAW internal event trigger * - message: Chat message pattern trigger */ import { useState, useEffect, useCallback } from 'react'; import { useHandStore } from '../store/handStore'; import { useWorkflowStore } from '../store/workflowStore'; import { Zap, X, AlertCircle, CheckCircle, Loader2, Globe, MessageSquare, Bell, } from 'lucide-react'; // === ReDoS Protection === const MAX_PATTERN_LENGTH = 200; const REGEX_TIMEOUT_MS = 100; // Dangerous regex patterns that can cause catastrophic backtracking const DANGEROUS_PATTERNS = [ /\([^)]*\+[^)]*\)\+/, // Nested quantifiers like (a+)+ /\([^)]*\*[^)]*\)\*/, // Nested quantifiers like (a*)* /\([^)]*\+[^)]*\)\*/, // Mixed nested quantifiers /\([^)]*\*[^)]*\)\+/, // Mixed nested quantifiers /\.\*\.\*/, // Multiple greedy wildcards /\.+\.\+/, // Multiple greedy wildcards /(.*)\1{3,}/, // Backreference loops ]; function validateRegexPattern(pattern: string): { valid: boolean; error?: string } { // Length check if (pattern.length > MAX_PATTERN_LENGTH) { return { valid: false, error: `Pattern too long (max ${MAX_PATTERN_LENGTH} chars)` }; } // Check for dangerous constructs for (const dangerous of DANGEROUS_PATTERNS) { if (dangerous.test(pattern)) { return { valid: false, error: 'Pattern contains potentially dangerous constructs' }; } } // Validate syntax and check execution time try { const regex = new RegExp(pattern); const testString = 'a'.repeat(20) + 'b'.repeat(20); const start = Date.now(); regex.test(testString); const elapsed = Date.now() - start; if (elapsed > REGEX_TIMEOUT_MS) { return { valid: false, error: 'Pattern is too complex (execution timeout)' }; } return { valid: true }; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Invalid pattern'; return { valid: false, error: `Invalid regular expression: ${message}` }; } } // === Types === type TriggerType = 'webhook' | 'event' | 'message'; type TargetType = 'hand' | 'workflow'; interface TriggerFormData { name: string; type: TriggerType; pattern: string; targetType: TargetType; targetId: string; webhookPath: string; eventType: string; enabled: boolean; } interface CreateTriggerModalProps { isOpen: boolean; onClose: () => void; onSuccess: () => void; } const initialFormData: TriggerFormData = { name: '', type: 'webhook', pattern: '', targetType: 'hand', targetId: '', webhookPath: '', eventType: '', enabled: true, }; // === Trigger Type Options === const triggerTypeOptions: Array<{ value: TriggerType; label: string; description: string; icon: React.ComponentType<{ className?: string }>; }> = [ { value: 'webhook', label: 'Webhook', description: 'External HTTP request trigger', icon: Globe, }, { value: 'event', label: 'Event', description: 'ZCLAW internal event trigger', icon: Bell, }, { value: 'message', label: 'Message', description: 'Chat message pattern trigger', icon: MessageSquare, }, ]; // === Event Type Options === const eventTypeOptions = [ { value: 'file_changed', label: 'File Changed' }, { value: 'agent_started', label: 'Agent Started' }, { value: 'agent_stopped', label: 'Agent Stopped' }, { value: 'hand_completed', label: 'Hand Completed' }, { value: 'workflow_completed', label: 'Workflow Completed' }, { value: 'session_created', label: 'Session Created' }, { value: 'custom', label: 'Custom Event' }, ]; // === Component === export function CreateTriggerModal({ isOpen, onClose, onSuccess }: CreateTriggerModalProps) { // Store state - use domain stores const hands = useHandStore((s) => s.hands); const workflows = useWorkflowStore((s) => s.workflows); const createTrigger = useHandStore((s) => s.createTrigger); const loadHands = useHandStore((s) => s.loadHands); const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows); const [formData, setFormData] = useState(initialFormData); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [errorMessage, setErrorMessage] = useState(''); // Load available targets on mount useEffect(() => { if (isOpen) { loadHands(); loadWorkflows(); } }, [isOpen, loadHands, loadWorkflows]); // Reset form when modal opens useEffect(() => { if (isOpen) { setFormData(initialFormData); setErrors({}); setSubmitStatus('idle'); setErrorMessage(''); } }, [isOpen]); // Validate form const validateForm = useCallback((): boolean => { const newErrors: Record = {}; if (!formData.name.trim()) { newErrors.name = 'Trigger name is required'; } switch (formData.type) { case 'webhook': if (!formData.webhookPath.trim()) { newErrors.webhookPath = 'Webhook path is required'; } else if (!formData.webhookPath.startsWith('/')) { newErrors.webhookPath = 'Webhook path must start with /'; } break; case 'event': if (!formData.eventType) { newErrors.eventType = 'Event type is required'; } break; case 'message': if (!formData.pattern.trim()) { newErrors.pattern = 'Pattern is required'; } else { // Validate regex pattern with ReDoS protection const validation = validateRegexPattern(formData.pattern); if (!validation.valid) { newErrors.pattern = validation.error || 'Invalid pattern'; } } break; } if (!formData.targetId) { newErrors.targetId = 'Please select a target'; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }, [formData]); // Handle form submission const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) { return; } setIsSubmitting(true); setSubmitStatus('idle'); setErrorMessage(''); try { // Build config based on trigger type const config: Record = {}; switch (formData.type) { case 'webhook': config.path = formData.webhookPath; break; case 'event': config.eventType = formData.eventType; break; case 'message': config.pattern = formData.pattern; break; } await createTrigger({ type: formData.type, name: formData.name.trim(), enabled: formData.enabled, config, handName: formData.targetType === 'hand' ? formData.targetId : undefined, workflowId: formData.targetType === 'workflow' ? formData.targetId : undefined, }); setSubmitStatus('success'); setTimeout(() => { onSuccess(); onClose(); }, 1500); } catch (err) { setSubmitStatus('error'); setErrorMessage(err instanceof Error ? err.message : 'Failed to create trigger'); } finally { setIsSubmitting(false); } }; // Update form field const updateField = (field: K, value: TriggerFormData[K]) => { setFormData(prev => ({ ...prev, [field]: value })); // Clear error when field is updated if (errors[field]) { setErrors(prev => { const newErrors = { ...prev }; delete newErrors[field]; return newErrors; }); } }; // Get available targets based on type const getAvailableTargets = (): Array<{ id: string; name?: string }> => { switch (formData.targetType) { case 'hand': return hands.map(h => ({ id: h.id, name: h.name })); case 'workflow': return workflows.map(w => ({ id: w.id, name: w.name })); default: return []; } }; if (!isOpen) return null; return (
{/* Backdrop */}
{/* Modal */}
{/* Header */}

Create Event Trigger

Create a trigger to respond to events

{/* Form */}
{/* Trigger Name */}
updateField('name', e.target.value)} placeholder="e.g., Daily Report Webhook" className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 ${ errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }`} /> {errors.name && (

{errors.name}

)}
{/* Trigger Type */}
{triggerTypeOptions.map((option) => { const Icon = option.icon; return ( ); })}

{triggerTypeOptions.find(o => o.value === formData.type)?.description}

{/* Webhook Path */} {formData.type === 'webhook' && (
updateField('webhookPath', e.target.value)} placeholder="/api/webhooks/daily-report" className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 font-mono ${ errors.webhookPath ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }`} /> {errors.webhookPath && (

{errors.webhookPath}

)}

The URL path that will trigger this action when called

)} {/* Event Type */} {formData.type === 'event' && (
{errors.eventType && (

{errors.eventType}

)}
)} {/* Message Pattern */} {formData.type === 'message' && (
updateField('pattern', e.target.value)} placeholder="^/report" className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 font-mono ${ errors.pattern ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' }`} /> {errors.pattern && (

{errors.pattern}

)}

Regular expression pattern to match chat messages

)} {/* Target Selection */}
{[ { value: 'hand', label: 'Hand' }, { value: 'workflow', label: 'Workflow' }, ].map((option) => ( ))}
{/* Target Selection Dropdown */}
{errors.targetId && (

{errors.targetId}

)} {getAvailableTargets().length === 0 && (

No {formData.targetType === 'hand' ? 'Hands' : 'Workflows'} available

)}
{/* Enabled Toggle */}
updateField('enabled', e.target.checked)} className="w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500" />
{/* Status Messages */} {submitStatus === 'success' && (
Trigger created successfully!
)} {submitStatus === 'error' && (
{errorMessage}
)}
{/* Footer */}
); } export default CreateTriggerModal;