Spaces:
Running
Running
| "use client"; | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| Shield, AlertTriangle, CheckCircle, RefreshCw, | |
| TrendingUp, Clock, Zap, Eye, XCircle, Activity, | |
| Sparkles, ChevronDown, ChevronUp, Loader2, | |
| } from "lucide-react"; | |
| import { aiApi } from "@/lib/api"; | |
| import { useAuthStore } from "@/lib/stores/authStore"; | |
| interface FraudAlert { | |
| id: string; | |
| fraud_log_id?: string; | |
| risk_score: number; | |
| details: string; | |
| status?: string; | |
| transaction_id?: string; | |
| merchant?: string; | |
| amount?: number; | |
| timestamp?: string; | |
| } | |
| interface FraudData { | |
| total_alerts: number; | |
| pending_reviews: number; | |
| alerts: FraudAlert[]; | |
| } | |
| interface FraudExplanation { | |
| ai_explanation: string; | |
| raw_reasons: string[]; | |
| amount_vs_avg: number; | |
| user_avg_amount: number; | |
| } | |
| function Skeleton({ className }: { className?: string }) { | |
| return <div className={`shimmer rounded-lg ${className}`} />; | |
| } | |
| function RiskBadge({ score }: { score: number }) { | |
| const level = | |
| score >= 70 ? { label: "High Risk", color: "text-red-400", bg: "bg-red-500/10 border-red-500/20" } | |
| : score >= 40 ? { label: "Suspicious", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" } | |
| : { label: "Low Risk", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" }; | |
| return ( | |
| <span className={`rounded-full border px-2.5 py-1 text-xs font-semibold ${level.color} ${level.bg}`}> | |
| {level.label} | |
| </span> | |
| ); | |
| } | |
| function RiskBar({ score }: { score: number }) { | |
| const color = score >= 70 ? "#ef4444" : score >= 40 ? "#f59e0b" : "#10b981"; | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <div className="h-1.5 w-24 rounded-full bg-white/10 overflow-hidden"> | |
| <motion.div initial={{ width: 0 }} animate={{ width: `${score}%` }} transition={{ duration: 0.8, ease: "easeOut" }} className="h-full rounded-full" style={{ background: color }} /> | |
| </div> | |
| <span className="text-xs font-mono" style={{ color }}>{score}</span> | |
| </div> | |
| ); | |
| } | |
| function AlertRow({ alert, index }: { alert: FraudAlert; index: number }) { | |
| const [expanded, setExpanded] = useState(false); | |
| const [explanation, setExplanation] = useState<FraudExplanation | null>(null); | |
| const [loadingExpl, setLoadingExpl] = useState(false); | |
| const loadExplanation = async () => { | |
| const logId = alert.fraud_log_id || alert.id; | |
| if (!logId || explanation) { setExpanded(!expanded); return; } | |
| setExpanded(true); | |
| setLoadingExpl(true); | |
| try { | |
| const res = await aiApi.fraudExplain(logId); | |
| setExplanation(res); | |
| } catch { | |
| setExplanation({ | |
| ai_explanation: alert.details || "Suspicious activity detected based on transaction patterns.", | |
| raw_reasons: typeof alert.details === "string" ? alert.details.split("; ") : [], | |
| amount_vs_avg: 0, | |
| user_avg_amount: 0, | |
| }); | |
| } finally { | |
| setLoadingExpl(false); | |
| } | |
| }; | |
| return ( | |
| <motion.div initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: index * 0.04 }} | |
| className={`border-b border-white/5 last:border-0 ${alert.risk_score >= 70 ? "bg-red-500/2" : ""}`}> | |
| <div className="flex items-center gap-4 px-6 py-4 hover:bg-white/[0.02] transition-colors cursor-pointer" onClick={loadExplanation}> | |
| <div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border ${alert.risk_score >= 70 ? "bg-red-500/10 border-red-500/20" : alert.risk_score >= 40 ? "bg-amber-500/10 border-amber-500/20" : "bg-emerald-500/10 border-emerald-500/20"}`}> | |
| {alert.risk_score >= 70 ? <XCircle className="h-5 w-5 text-red-400" /> : alert.risk_score >= 40 ? <AlertTriangle className="h-5 w-5 text-amber-400" /> : <CheckCircle className="h-5 w-5 text-emerald-400" />} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <p className="text-sm font-medium text-white truncate">{alert.merchant || `Transaction ${(alert.transaction_id || alert.id).slice(0, 8)}...`}</p> | |
| <RiskBadge score={alert.risk_score} /> | |
| {alert.status && <span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-zinc-400 capitalize">{alert.status}</span>} | |
| </div> | |
| <p className="text-xs text-zinc-500 mt-0.5 truncate"> | |
| {typeof alert.details === "string" ? alert.details : Array.isArray(alert.details) ? (alert.details as string[]).join(" · ") : "Suspicious activity detected"} | |
| </p> | |
| </div> | |
| <div className="flex-shrink-0 hidden sm:block"><RiskBar score={alert.risk_score} /></div> | |
| {alert.amount != null && <div className="flex-shrink-0 text-right"><p className="text-sm font-semibold text-white">${Math.abs(alert.amount).toFixed(2)}</p></div>} | |
| <div className="flex-shrink-0 flex items-center gap-1 text-zinc-500"> | |
| <Sparkles className="h-3.5 w-3.5 text-cyan-400" /> | |
| {expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />} | |
| </div> | |
| </div> | |
| <AnimatePresence> | |
| {expanded && ( | |
| <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25 }} className="overflow-hidden"> | |
| <div className="mx-6 mb-4 rounded-xl border border-cyan-500/20 bg-cyan-500/5 p-4"> | |
| <div className="flex items-start gap-2"> | |
| <Sparkles className="h-4 w-4 text-cyan-400 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <p className="text-xs font-semibold text-cyan-400 mb-2">AI Fraud Explanation</p> | |
| {loadingExpl ? ( | |
| <div className="flex items-center gap-2"> | |
| <Loader2 className="h-3.5 w-3.5 animate-spin text-cyan-400" /> | |
| <span className="text-xs text-zinc-400">Analyzing transaction patterns...</span> | |
| </div> | |
| ) : explanation ? ( | |
| <div className="space-y-3"> | |
| <p className="text-sm text-zinc-200 leading-relaxed">{explanation.ai_explanation}</p> | |
| {explanation.raw_reasons.length > 0 && ( | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-zinc-400">Detection signals:</p> | |
| {explanation.raw_reasons.map((r, i) => ( | |
| <div key={i} className="flex items-start gap-1.5"> | |
| <div className="h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0 mt-1.5" /> | |
| <p className="text-xs text-zinc-400">{r}</p> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {explanation.amount_vs_avg > 0 && ( | |
| <div className="flex items-center gap-4 pt-1 border-t border-white/8"> | |
| <div><p className="text-[10px] text-zinc-500">This transaction</p><p className="text-sm font-bold text-white">${alert.amount?.toFixed(2)}</p></div> | |
| <div className="text-zinc-600">vs</div> | |
| <div><p className="text-[10px] text-zinc-500">Your average</p><p className="text-sm font-bold text-zinc-300">${explanation.user_avg_amount.toFixed(2)}</p></div> | |
| <div className="ml-auto"><span className="rounded-full bg-red-500/15 border border-red-500/30 px-2 py-0.5 text-xs font-bold text-red-400">{explanation.amount_vs_avg.toFixed(1)}× average</span></div> | |
| </div> | |
| )} | |
| </div> | |
| ) : null} | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </motion.div> | |
| ); | |
| } | |
| const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.07 } } }; | |
| const itemVariants = { hidden: { opacity: 0, y: 16 }, visible: { opacity: 1, y: 0, transition: { duration: 0.4 } } }; | |
| export default function SecurityPage() { | |
| const { user } = useAuthStore(); | |
| const [data, setData] = useState<FraudData | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [filter, setFilter] = useState<"all" | "high" | "pending">("all"); | |
| const load = useCallback(async () => { | |
| setLoading(true); setError(null); | |
| try { | |
| const res = await aiApi.fraudAnalysis(user?.user_id); | |
| const raw = res as unknown as Record<string, unknown>; | |
| const alerts: FraudAlert[] = (raw.alerts as FraudAlert[]) || []; | |
| setData({ total_alerts: (raw.total_alerts as number) ?? alerts.length, pending_reviews: (raw.pending_reviews as number) ?? alerts.filter((a) => a.status === "pending").length, alerts }); | |
| } catch (err) { setError((err as Error).message); } | |
| finally { setLoading(false); } | |
| }, [user?.user_id]); | |
| useEffect(() => { load(); }, [load]); | |
| const alerts = data?.alerts || []; | |
| const filtered = alerts.filter((a) => filter === "high" ? a.risk_score >= 70 : filter === "pending" ? a.status === "pending" || !a.status : true); | |
| const highRiskCount = alerts.filter((a) => a.risk_score >= 70).length; | |
| const suspiciousCount = alerts.filter((a) => a.risk_score >= 40 && a.risk_score < 70).length; | |
| const safeCount = alerts.filter((a) => a.risk_score < 40).length; | |
| const avgScore = alerts.length ? Math.round(alerts.reduce((s, a) => s + a.risk_score, 0) / alerts.length) : 0; | |
| return ( | |
| <motion.div variants={containerVariants} initial="hidden" animate="visible" className="flex flex-col gap-6"> | |
| <motion.div variants={itemVariants} className="flex items-center justify-between"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight text-white">Security Center</h1> | |
| <p className="text-zinc-400 mt-1 text-sm">AI-powered fraud detection · Click any alert for a full AI explanation</p> | |
| </div> | |
| <button onClick={load} className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-400 hover:text-white transition-colors"> | |
| <RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />Refresh | |
| </button> | |
| </motion.div> | |
| {error && ( | |
| <motion.div variants={itemVariants} className="flex items-center gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/5 px-5 py-3"> | |
| <AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" /> | |
| <p className="text-xs text-zinc-400"><span className="text-amber-400 font-medium">Could not load fraud data</span> — {error}</p> | |
| </motion.div> | |
| )} | |
| <motion.div variants={itemVariants} className="flex items-center gap-4 rounded-2xl border border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 via-cyan-500/5 to-blue-500/10 px-5 py-4"> | |
| <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex-shrink-0"><Shield className="h-5 w-5 text-emerald-400" /></div> | |
| <div className="flex-1"> | |
| <p className="text-sm font-semibold text-white">AI Fraud Shield Active</p> | |
| <p className="text-xs text-zinc-400 mt-0.5">Click any flagged transaction to get a full AI explanation of exactly why it was flagged.</p> | |
| </div> | |
| <div className="flex items-center gap-1.5"><div className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" /><span className="text-xs text-emerald-400 font-medium">Live</span></div> | |
| </motion.div> | |
| <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| {[ | |
| { label: "Total Alerts", value: loading ? "—" : String(data?.total_alerts ?? 0), icon: Activity, color: "text-blue-400", border: "border-blue-500/20", bg: "from-blue-500/10 to-blue-500/5" }, | |
| { label: "High Risk", value: loading ? "—" : String(highRiskCount), icon: XCircle, color: "text-red-400", border: "border-red-500/20", bg: "from-red-500/10 to-red-500/5" }, | |
| { label: "Suspicious", value: loading ? "—" : String(suspiciousCount), icon: AlertTriangle, color: "text-amber-400", border: "border-amber-500/20", bg: "from-amber-500/10 to-amber-500/5" }, | |
| { label: "Avg Risk Score", value: loading ? "—" : `${avgScore}/100`, icon: TrendingUp, color: "text-purple-400", border: "border-purple-500/20", bg: "from-purple-500/10 to-purple-500/5" }, | |
| ].map((stat) => ( | |
| <motion.div key={stat.label} variants={itemVariants} className={`rounded-2xl border ${stat.border} bg-gradient-to-br ${stat.bg} p-5`}> | |
| <div className="flex items-start justify-between"> | |
| <div><p className="text-xs text-zinc-400 uppercase tracking-wider">{stat.label}</p><p className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</p></div> | |
| <stat.icon className={`h-5 w-5 ${stat.color} opacity-60`} /> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </div> | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <h2 className="text-base font-semibold text-white mb-4">Active Detection Rules</h2> | |
| <div className="grid gap-3 sm:grid-cols-2"> | |
| {[ | |
| { icon: TrendingUp, label: "Amount Spike Detection", desc: "Flags transactions 3.5× above your average" }, | |
| { icon: Clock, label: "Late-Night Monitoring", desc: "Alerts on activity between 11PM – 4AM" }, | |
| { icon: Zap, label: "Rapid Transaction Guard", desc: "Detects multiple transactions within 3 minutes" }, | |
| { icon: Eye, label: "Duplicate Payment Check", desc: "Catches same merchant + amount within 10 minutes" }, | |
| ].map((rule) => ( | |
| <div key={rule.label} className="flex items-start gap-3 rounded-xl border border-white/8 bg-white/[0.02] p-4"> | |
| <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex-shrink-0"><rule.icon className="h-4 w-4 text-emerald-400" /></div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <p className="text-sm font-medium text-white">{rule.label}</p> | |
| <div className="flex items-center gap-1 flex-shrink-0"><div className="h-1.5 w-1.5 rounded-full bg-emerald-400" /><span className="text-[10px] text-emerald-400">ON</span></div> | |
| </div> | |
| <p className="text-xs text-zinc-500 mt-0.5">{rule.desc}</p> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| <motion.div variants={itemVariants} className="glass-card overflow-hidden"> | |
| <div className="flex items-center justify-between px-6 py-4 border-b border-white/8"> | |
| <div> | |
| <h2 className="text-base font-semibold text-white">Fraud Alerts</h2> | |
| <p className="text-xs text-zinc-400 mt-0.5">{loading ? "Loading..." : `${filtered.length} alert${filtered.length !== 1 ? "s" : ""} · Click any row for AI explanation`}</p> | |
| </div> | |
| <div className="flex items-center gap-1 rounded-xl border border-white/10 bg-white/5 p-1"> | |
| {(["all", "high", "pending"] as const).map((f) => ( | |
| <button key={f} onClick={() => setFilter(f)} className={`rounded-lg px-3 py-1.5 text-xs font-medium capitalize transition-all ${filter === f ? "bg-white/10 text-white" : "text-zinc-500 hover:text-zinc-300"}`}> | |
| {f === "all" ? "All" : f === "high" ? "High Risk" : "Pending"} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {loading ? ( | |
| <div className="space-y-px p-4">{[1,2,3].map((i) => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}</div> | |
| ) : filtered.length === 0 ? ( | |
| <div className="flex flex-col items-center gap-3 py-16 text-center"> | |
| <CheckCircle className="h-12 w-12 text-emerald-500/40" /> | |
| <p className="text-sm font-medium text-zinc-400">{alerts.length === 0 ? "No fraud alerts detected" : "No alerts match this filter"}</p> | |
| <p className="text-xs text-zinc-600">{alerts.length === 0 ? "All transactions look clean. AI Shield is monitoring in real-time." : "Try a different filter."}</p> | |
| </div> | |
| ) : ( | |
| <div>{filtered.map((alert, i) => <AlertRow key={alert.id} alert={alert} index={i} />)}</div> | |
| )} | |
| </motion.div> | |
| {!loading && safeCount > 0 && ( | |
| <motion.div variants={itemVariants} className="flex items-center gap-3 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 px-5 py-3"> | |
| <CheckCircle className="h-4 w-4 text-emerald-400 flex-shrink-0" /> | |
| <p className="text-xs text-zinc-400"> | |
| <span className="text-emerald-400 font-medium">{safeCount} transaction{safeCount !== 1 ? "s" : ""} verified safe</span>{" "}— risk score below threshold. | |
| </p> | |
| </motion.div> | |
| )} | |
| </motion.div> | |
| ); | |
| } | |