ApprovalPanel.tsx
1 // Copyright (c) 2026 VPL Solutions. All rights reserved. 2 // Licensed under the MIT License. See LICENSE for details. 3 4 import { useState } from 'react'; 5 import { ShieldCheck, ShieldX, Lock, RotateCcw } from 'lucide-react'; 6 import type { ExecutionPlanSummary } from '../../api/investigation'; 7 import { Card } from '../ui/Card'; 8 9 interface ApprovalPanelProps { 10 executionPlan: ExecutionPlanSummary; 11 policyRationale: string | null; 12 traceId: string; 13 onApprove: (approvalRef: string, planId: string) => void; 14 onReject: (reason: string) => void; 15 isSubmitting?: boolean; 16 } 17 18 export function ApprovalPanel({ executionPlan, policyRationale, traceId, onApprove, onReject, isSubmitting }: ApprovalPanelProps) { 19 const [mode, setMode] = useState<'idle' | 'approve' | 'reject'>('idle'); 20 const [rejectReason, setRejectReason] = useState(''); 21 22 const plan = executionPlan; 23 24 const blastColors: Record<string, string> = { 25 low: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20', 26 medium: 'text-amber-600 bg-amber-50 dark:bg-amber-900/20', 27 high: 'text-orange-600 bg-orange-50 dark:bg-orange-900/20', 28 critical: 'text-red-600 bg-red-50 dark:bg-red-900/20', 29 }; 30 31 return ( 32 <Card className="border-l-4 border-l-amber-400"> 33 {/* Warning banner */} 34 <div className="flex items-center gap-2 mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800"> 35 <Lock className="w-4 h-4 text-amber-600 shrink-0" /> 36 <div> 37 <p className="text-sm font-medium text-amber-800 dark:text-amber-300">Execution requires explicit approval</p> 38 <p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">This action cannot proceed without approval. All execution is bounded and audited.</p> 39 </div> 40 </div> 41 42 {/* Plan summary */} 43 <div className="space-y-4"> 44 <div> 45 <h3 className="text-sm font-semibold text-gray-900 dark:text-white">Execution Plan</h3> 46 <p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{plan.action_summary}</p> 47 </div> 48 49 {policyRationale && ( 50 <p className="text-sm text-gray-600 dark:text-gray-300 italic">{policyRationale}</p> 51 )} 52 53 <div className="grid grid-cols-2 gap-4 text-sm"> 54 <div> 55 <dt className="text-gray-500 dark:text-gray-400">Plan ID</dt> 56 <dd className="font-mono text-xs mt-0.5 text-gray-700 dark:text-gray-300">{plan.plan_id}</dd> 57 </div> 58 <div> 59 <dt className="text-gray-500 dark:text-gray-400">Blast Radius</dt> 60 <dd className="mt-0.5"> 61 <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${blastColors[plan.blast_radius] ?? ''}`}> 62 {plan.blast_radius.toUpperCase()} 63 </span> 64 </dd> 65 </div> 66 <div> 67 <dt className="text-gray-500 dark:text-gray-400">Steps</dt> 68 <dd className="font-medium mt-0.5 text-gray-700 dark:text-gray-300">{plan.total_steps}</dd> 69 </div> 70 <div> 71 <dt className="text-gray-500 dark:text-gray-400">Reversible</dt> 72 <dd className="mt-0.5"> 73 <span className={`inline-flex items-center gap-1 text-xs font-medium ${plan.reversible ? 'text-emerald-600' : 'text-red-600'}`}> 74 <RotateCcw className="w-3 h-3" /> 75 {plan.reversible ? 'Yes' : 'No'} 76 </span> 77 </dd> 78 </div> 79 </div> 80 81 {/* Action buttons */} 82 {mode === 'idle' && ( 83 <div className="flex items-center gap-3 pt-2 border-t border-gray-200 dark:border-white/10"> 84 <button 85 onClick={() => setMode('approve')} 86 disabled={isSubmitting} 87 className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50" 88 > 89 <ShieldCheck className="w-4 h-4" /> 90 Approve 91 </button> 92 <button 93 onClick={() => setMode('reject')} 94 disabled={isSubmitting} 95 className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 transition-colors disabled:opacity-50" 96 > 97 <ShieldX className="w-4 h-4" /> 98 Reject 99 </button> 100 </div> 101 )} 102 103 {/* Approve confirmation */} 104 {mode === 'approve' && ( 105 <div className="p-4 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 space-y-3"> 106 <p className="text-sm font-medium text-emerald-800 dark:text-emerald-300"> 107 Confirm approval for plan {plan.plan_id}? 108 </p> 109 <p className="text-xs text-emerald-600 dark:text-emerald-400"> 110 This will allow the Execution Agent to proceed with {plan.total_steps} bounded step{plan.total_steps > 1 ? 's' : ''}. 111 All execution goes through Tool Gateway with full audit logging. 112 </p> 113 <div className="flex items-center gap-3"> 114 <button 115 onClick={() => onApprove(`studio-${traceId}-${Date.now()}`, plan.plan_id)} 116 disabled={isSubmitting} 117 className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50" 118 > 119 {isSubmitting ? 'Approving...' : 'Confirm Approval'} 120 </button> 121 <button 122 onClick={() => setMode('idle')} 123 disabled={isSubmitting} 124 className="px-4 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" 125 > 126 Cancel 127 </button> 128 </div> 129 </div> 130 )} 131 132 {/* Reject with reason */} 133 {mode === 'reject' && ( 134 <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 space-y-3"> 135 <p className="text-sm font-medium text-red-800 dark:text-red-300"> 136 Reject execution plan? 137 </p> 138 <textarea 139 value={rejectReason} 140 onChange={(e) => setRejectReason(e.target.value)} 141 placeholder="Reason for rejection (required)" 142 rows={3} 143 className="w-full px-3 py-2 rounded-lg border border-red-300 dark:border-red-700 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-red-400" 144 /> 145 <div className="flex items-center gap-3"> 146 <button 147 onClick={() => onReject(rejectReason)} 148 disabled={isSubmitting || !rejectReason.trim()} 149 className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 transition-colors disabled:opacity-50" 150 > 151 {isSubmitting ? 'Rejecting...' : 'Confirm Rejection'} 152 </button> 153 <button 154 onClick={() => { setMode('idle'); setRejectReason(''); }} 155 disabled={isSubmitting} 156 className="px-4 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" 157 > 158 Cancel 159 </button> 160 </div> 161 </div> 162 )} 163 </div> 164 </Card> 165 ); 166 }