widgettdc-api / apps /matrix-frontend /src /widgets /ApprovalQueueWidget.tsx
Kraft102's picture
fix: sql.js Docker/Alpine compatibility layer for PatternMemory and FailureMemory
5a81b95
/**
* 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>
);
}