Spaces:
Paused
Paused
| /** | |
| * ApprovalQueueWidget - Human-in-the-Loop Approval Interface | |
| * | |
| * Displays pending requests requiring human approval. | |
| * Allows users to approve/reject operations in real-time. | |
| */ | |
| import React, { useState, useEffect } from 'react'; | |
| import { | |
| Shield, Check, X, Clock, AlertTriangle, RefreshCw, | |
| ChevronDown, ChevronUp, Info | |
| } from 'lucide-react'; | |
| import { cn } from '@/lib/utils'; | |
| interface ApprovalRequest { | |
| id: string; | |
| operation: string; | |
| description: string; | |
| riskLevel: 'low' | 'medium' | 'high' | 'critical'; | |
| requestedBy: string; | |
| requestedAt: number; | |
| expiresAt: number; | |
| status: 'pending' | 'approved' | 'rejected' | 'expired'; | |
| metadata: Record<string, any>; | |
| } | |
| const API_BASE = 'http://localhost:3001/api'; | |
| export default function ApprovalQueueWidget() { | |
| const [requests, setRequests] = useState<ApprovalRequest[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [expandedId, setExpandedId] = useState<string | null>(null); | |
| const [processing, setProcessing] = useState<string | null>(null); | |
| const fetchPending = async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/approvals/pending`); | |
| const data = await res.json(); | |
| if (data.success) { | |
| setRequests(data.requests); | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch approvals:', error); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchPending(); | |
| const interval = setInterval(fetchPending, 5000); // Poll every 5s | |
| return () => clearInterval(interval); | |
| }, []); | |
| const handleApprove = async (requestId: string) => { | |
| setProcessing(requestId); | |
| try { | |
| const res = await fetch(`${API_BASE}/approvals/${requestId}/approve`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ approvedBy: 'Dashboard User' }), | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| await fetchPending(); // Refresh list | |
| } else { | |
| alert(`Approval failed: ${data.error}`); | |
| } | |
| } catch (error: any) { | |
| alert(`Error: ${error.message}`); | |
| } finally { | |
| setProcessing(null); | |
| } | |
| }; | |
| const handleReject = async (requestId: string) => { | |
| const reason = prompt('Enter rejection reason:'); | |
| if (!reason) return; | |
| setProcessing(requestId); | |
| try { | |
| const res = await fetch(`${API_BASE}/approvals/${requestId}/reject`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| rejectedBy: 'Dashboard User', | |
| reason, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| await fetchPending(); | |
| } else { | |
| alert(`Rejection failed: ${data.error}`); | |
| } | |
| } catch (error: any) { | |
| alert(`Error: ${error.message}`); | |
| } finally { | |
| setProcessing(null); | |
| } | |
| }; | |
| const getRiskColor = (level: string) => { | |
| switch (level) { | |
| case 'critical': return 'text-red-400 bg-red-400/10 border-red-400/30'; | |
| case 'high': return 'text-orange-400 bg-orange-400/10 border-orange-400/30'; | |
| case 'medium': return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; | |
| case 'low': return 'text-green-400 bg-green-400/10 border-green-400/30'; | |
| default: return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; | |
| } | |
| }; | |
| const getRiskIcon = (level: string) => { | |
| switch (level) { | |
| case 'critical': return <AlertTriangle className="w-4 h-4 text-red-400" />; | |
| case 'high': return <AlertTriangle className="w-4 h-4 text-orange-400" />; | |
| default: return <Shield className="w-4 h-4" />; | |
| } | |
| }; | |
| const formatTimeRemaining = (expiresAt: number) => { | |
| const remaining = expiresAt - Date.now(); | |
| if (remaining < 0) return 'Expired'; | |
| const minutes = Math.floor(remaining / 60000); | |
| const seconds = Math.floor((remaining % 60000) / 1000); | |
| return `${minutes}m ${seconds}s`; | |
| }; | |
| return ( | |
| <div className="h-full flex flex-col bg-card/50 backdrop-blur-sm border border-border/50 rounded-lg overflow-hidden"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b border-border/30 bg-gradient-to-r from-orange-500/10 to-transparent"> | |
| <div className="flex items-center gap-2"> | |
| <Shield className="w-5 h-5 text-orange-400" /> | |
| <span className="font-display text-sm uppercase tracking-wider text-orange-400"> | |
| Approval Queue | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {requests.length > 0 && ( | |
| <span className="px-2 py-1 bg-orange-400/20 text-orange-400 rounded text-xs font-medium"> | |
| {requests.length} pending | |
| </span> | |
| )} | |
| <button | |
| onClick={fetchPending} | |
| className="p-1.5 hover:bg-secondary/50 rounded transition-colors" | |
| disabled={loading} | |
| > | |
| <RefreshCw className={cn("w-4 h-4 text-orange-400", loading && "animate-spin")} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 overflow-y-auto p-4 space-y-3"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center h-32"> | |
| <RefreshCw className="w-6 h-6 text-orange-400 animate-spin" /> | |
| </div> | |
| ) : requests.length === 0 ? ( | |
| <div className="text-center py-8"> | |
| <Check className="w-8 h-8 text-green-400 mx-auto mb-2" /> | |
| <p className="text-sm text-muted-foreground">No pending approvals</p> | |
| </div> | |
| ) : ( | |
| requests.map(request => { | |
| const isExpanded = expandedId === request.id; | |
| const timeRemaining = formatTimeRemaining(request.expiresAt); | |
| const isExpiringSoon = request.expiresAt - Date.now() < 60000; // < 1 min | |
| return ( | |
| <div | |
| key={request.id} | |
| className={cn( | |
| "border rounded-lg overflow-hidden transition-all", | |
| getRiskColor(request.riskLevel) | |
| )} | |
| > | |
| {/* Header */} | |
| <div className="p-3"> | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="flex-1"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| {getRiskIcon(request.riskLevel)} | |
| <span className="text-xs font-medium uppercase tracking-wide opacity-70"> | |
| {request.riskLevel} risk | |
| </span> | |
| <span className="text-xs opacity-50">•</span> | |
| <span className="text-xs font-mono opacity-70">{request.operation}</span> | |
| </div> | |
| <p className="text-sm mb-2">{request.description}</p> | |
| <div className="flex items-center gap-3 text-xs opacity-60"> | |
| <div className="flex items-center gap-1"> | |
| <Clock className="w-3 h-3" /> | |
| <span className={cn(isExpiringSoon && "text-red-400 font-medium")}> | |
| {timeRemaining} | |
| </span> | |
| </div> | |
| <span>•</span> | |
| <span>{request.requestedBy}</span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setExpandedId(isExpanded ? null : request.id)} | |
| className="p-1 hover:bg-black/20 rounded transition-colors" | |
| > | |
| {isExpanded ? ( | |
| <ChevronUp className="w-4 h-4" /> | |
| ) : ( | |
| <ChevronDown className="w-4 h-4" /> | |
| )} | |
| </button> | |
| </div> | |
| {/* Expanded details */} | |
| {isExpanded && ( | |
| <div className="mt-3 pt-3 border-t border-current/20"> | |
| <div className="space-y-2 text-xs"> | |
| {Object.entries(request.metadata).map(([key, value]) => ( | |
| <div key={key} className="flex items-start gap-2"> | |
| <Info className="w-3 h-3 mt-0.5 opacity-50" /> | |
| <span className="font-medium opacity-70">{key}:</span> | |
| <span className="opacity-90">{String(value)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Action buttons */} | |
| <div className="flex gap-2 mt-3"> | |
| <button | |
| onClick={() => handleApprove(request.id)} | |
| disabled={processing === request.id} | |
| className={cn( | |
| "flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded text-xs font-medium transition-all", | |
| "bg-green-500/20 text-green-400 hover:bg-green-500/30", | |
| processing === request.id && "opacity-50 cursor-not-allowed" | |
| )} | |
| > | |
| <Check className="w-3 h-3" /> | |
| Approve | |
| </button> | |
| <button | |
| onClick={() => handleReject(request.id)} | |
| disabled={processing === request.id} | |
| className={cn( | |
| "flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded text-xs font-medium transition-all", | |
| "bg-red-500/20 text-red-400 hover:bg-red-500/30", | |
| processing === request.id && "opacity-50 cursor-not-allowed" | |
| )} | |
| > | |
| <X className="w-3 h-3" /> | |
| Reject | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="p-3 border-t border-border/30 bg-secondary/20"> | |
| <p className="text-xs text-muted-foreground text-center"> | |
| 🔐 Human-in-the-Loop: Ingen handlinger udført uden din godkendelse | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |