/** * HandApprovalModal - Modal for approving/rejecting Hand executions * * Provides detailed view of Hand execution request with: * - Hand name and description * - Trigger parameters * - Expected output/impact * - Risk level indicator * - Approval timeout countdown * - Approve/Reject buttons */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { X, AlertTriangle, CheckCircle, XCircle, Clock, Loader2, Shield, Zap, Info, } from 'lucide-react'; import type { HandRun } from '../store/gatewayStore'; import { HAND_DEFINITIONS, type HandId } from '../types/hands'; // === Types === export type RiskLevel = 'low' | 'medium' | 'high'; export interface HandApprovalData { runId: string; handId: HandId; handName: string; description: string; params: Record; riskLevel: RiskLevel; expectedImpact?: string; requestedAt: string; requestedBy?: string; timeoutSeconds?: number; } interface HandApprovalModalProps { handRun: HandRun | null; isOpen: boolean; onApprove: (runId: string) => Promise; onReject: (runId: string, reason: string) => Promise; onClose: () => void; } // === Risk Level Config === const RISK_CONFIG: Record< RiskLevel, { label: string; color: string; bgColor: string; borderColor: string; icon: typeof AlertTriangle } > = { low: { label: 'Low Risk', color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-100 dark:bg-green-900/30', borderColor: 'border-green-300 dark:border-green-700', icon: CheckCircle, }, medium: { label: 'Medium Risk', color: 'text-yellow-600 dark:text-yellow-400', bgColor: 'bg-yellow-100 dark:bg-yellow-900/30', borderColor: 'border-yellow-300 dark:border-yellow-700', icon: AlertTriangle, }, high: { label: 'High Risk', color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/30', borderColor: 'border-red-300 dark:border-red-700', icon: AlertTriangle, }, }; // === Helper Functions === function calculateRiskLevel(handId: HandId, params: Record): RiskLevel { // Risk assessment based on Hand type and parameters switch (handId) { case 'browser': // Browser automation can be high risk if interacting with sensitive sites const url = String(params.url || '').toLowerCase(); if (url.includes('bank') || url.includes('payment') || url.includes('admin')) { return 'high'; } if (params.headless === false) { return 'medium'; // Non-headless mode is more visible } return 'low'; case 'twitter': // Twitter actions can have public impact const action = String(params.action || ''); if (action === 'post' || action === 'engage') { return 'high'; // Public posting is high impact } return 'medium'; case 'collector': // Data collection depends on scope if (params.pagination === true) { return 'medium'; // Large scale collection } return 'low'; case 'lead': // Lead generation accesses external data return 'medium'; case 'clip': // Video processing is generally safe return 'low'; case 'predictor': // Predictions are read-only return 'low'; case 'researcher': // Research is generally safe const depth = String(params.depth || ''); return depth === 'deep' ? 'medium' : 'low'; default: return 'medium'; } } function getExpectedImpact(handId: HandId, params: Record): string { switch (handId) { case 'browser': return `Will perform browser automation on ${params.url || 'specified URL'}`; case 'twitter': if (params.action === 'post') { return 'Will post content to Twitter/X publicly'; } if (params.action === 'engage') { return 'Will like/reply to tweets'; } return 'Will perform Twitter/X operations'; case 'collector': return `Will collect data from ${params.targetUrl || 'specified source'}`; case 'lead': return `Will search for leads from ${params.source || 'specified source'}`; case 'clip': return `Will process video: ${params.inputPath || 'specified input'}`; case 'predictor': return `Will run prediction on ${params.dataSource || 'specified data'}`; case 'researcher': return `Will conduct research on: ${params.topic || 'specified topic'}`; default: return 'Will execute Hand operation'; } } function formatTimeRemaining(seconds: number): string { if (seconds <= 0) return 'Expired'; if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes}m ${secs}s`; } // === Countdown Timer Hook === function useCountdown(startedAt: string, timeoutSeconds: number = 300) { const [timeRemaining, setTimeRemaining] = useState(0); const [isExpired, setIsExpired] = useState(false); useEffect(() => { const started = new Date(startedAt).getTime(); const expiresAt = started + timeoutSeconds * 1000; const updateTimer = () => { const now = Date.now(); const remaining = Math.max(0, Math.floor((expiresAt - now) / 1000)); setTimeRemaining(remaining); setIsExpired(remaining <= 0); }; updateTimer(); const interval = setInterval(updateTimer, 1000); return () => clearInterval(interval); }, [startedAt, timeoutSeconds]); return { timeRemaining, isExpired }; } // === Sub-components === function RiskBadge({ level }: { level: RiskLevel }) { const config = RISK_CONFIG[level]; const Icon = config.icon; return (
{config.label}
); } function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: number; totalSeconds: number }) { const percentage = Math.max(0, Math.min(100, (timeRemaining / totalSeconds) * 100)); const isUrgent = timeRemaining < 60; return (
Time Remaining {formatTimeRemaining(timeRemaining)}
); } function ParamsDisplay({ params }: { params: Record }) { if (!params || Object.keys(params).length === 0) { return (

No parameters provided

); } return (
        {JSON.stringify(params, null, 2)}
      
); } // === Main Component === export function HandApprovalModal({ handRun, isOpen, onApprove, onReject, onClose, }: HandApprovalModalProps) { const [isProcessing, setIsProcessing] = useState(false); const [showRejectInput, setShowRejectInput] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [error, setError] = useState(null); // Parse HandRun to get approval data const approvalData = useMemo((): HandApprovalData | null => { if (!handRun) return null; // Extract hand ID from run data (could be stored in result or need to be passed separately) const result = handRun.result as Record | undefined; const handId = (result?.handId as HandId) || 'researcher'; // Default fallback const params = (result?.params as Record) || {}; const handDef = HAND_DEFINITIONS.find((h) => h.id === handId); return { runId: handRun.runId, handId, handName: handDef?.name || handId, description: handDef?.description || 'Hand execution request', params, riskLevel: calculateRiskLevel(handId, params), expectedImpact: getExpectedImpact(handId, params), requestedAt: handRun.startedAt, timeoutSeconds: 300, // 5 minutes default timeout }; }, [handRun]); // Timer countdown const { timeRemaining, isExpired } = useCountdown( approvalData?.requestedAt || new Date().toISOString(), approvalData?.timeoutSeconds || 300 ); // Handle keyboard escape useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { onClose(); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [isOpen, onClose]); // Reset state when modal opens/closes useEffect(() => { if (isOpen) { setShowRejectInput(false); setRejectReason(''); setError(null); setIsProcessing(false); } }, [isOpen]); const handleApprove = useCallback(async () => { if (!approvalData || isProcessing || isExpired) return; setIsProcessing(true); setError(null); try { await onApprove(approvalData.runId); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to approve'); } finally { setIsProcessing(false); } }, [approvalData, isProcessing, isExpired, onApprove, onClose]); const handleReject = useCallback(async () => { if (!approvalData || isProcessing) return; if (!showRejectInput) { setShowRejectInput(true); return; } if (!rejectReason.trim()) { setError('Please provide a reason for rejection'); return; } setIsProcessing(true); setError(null); try { await onReject(approvalData.runId, rejectReason.trim()); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to reject'); } finally { setIsProcessing(false); } }, [approvalData, isProcessing, showRejectInput, rejectReason, onReject, onClose]); const handleCancelReject = useCallback(() => { setShowRejectInput(false); setRejectReason(''); setError(null); }, []); if (!isOpen || !approvalData) return null; return (
{/* Backdrop */}
{/* Modal */}
{/* Header */}

Hand Approval Request

Review and approve Hand execution

{/* Content */}
{/* Expired Warning */} {isExpired && (
This approval request has expired
)} {/* Hand Info */}

{approvalData.handName}

{approvalData.description}

{/* Timeout Progress */} {!isExpired && ( )} {/* Parameters */}
{/* Expected Impact */} {approvalData.expectedImpact && (

{approvalData.expectedImpact}

)} {/* Request Info */}

Run ID: {approvalData.runId}

Requested: {new Date(approvalData.requestedAt).toLocaleString()}

{/* Reject Input */} {showRejectInput && (