/ src / components / ops / ApprovalPanel.tsx
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  }