JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useState, useEffect } from "react";4import { motion, AnimatePresence } from "framer-motion";5import {6 Monitor, Smartphone, Tablet, Globe, MapPin, Clock, ShieldCheck, ShieldX, LogOut, Wifi,7 ChevronDown, Bot, AlertTriangle, Shield,8} from "lucide-react";9import toast from "react-hot-toast";10import Modal from "../ui/Modal";11import Button from "../ui/Button";12import { EmptyState } from "../ui/PageHeader";1314interface DeviceSession {15 id: string;16 sessionToken: string;17 ip: string;18 device: string;19 browser: string;20 os: string;21 country: string;22 city: string;23 status: string;24 aiDecision: string | null;25 aiReason: string | null;26 aiRiskLevel: string | null;27 createdAt: string;28 endedAt: string | null;29 isCurrent: boolean;30}3132function DeviceIcon({ device, className = "w-5 h-5" }: { device: string; className?: string }) {33 if (device === "Mobile") return <Smartphone className={className} />;34 if (device === "Tablet") return <Tablet className={className} />;35 return <Monitor className={className} />;36}3738function RiskBadge({ level }: { level: string | null }) {39 if (!level || level === "low") return null;40 const cls = level === "high"41 ? "bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-rose-500/20"42 : "bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20";43 return (44 <span className={`inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full ring-1 ring-inset uppercase tracking-wide ${cls}`}>45 <AlertTriangle className="w-2.5 h-2.5" />46 {level === "high" ? "Risque élevé" : "Risque moyen"}47 </span>48 );49}5051function formatRelative(dateStr: string): string {52 const date = new Date(dateStr);53 const diff = Date.now() - date.getTime();54 const mins = Math.floor(diff / 60000);55 const hours = Math.floor(diff / 3600000);56 const days = Math.floor(diff / 86400000);57 if (mins < 1) return "à l'instant";58 if (mins < 60) return `il y a ${mins} min`;59 if (hours < 24) return `il y a ${hours} h`;60 if (days === 1) return "hier";61 if (days < 7) return `il y a ${days} jours`;62 return date.toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });63}6465function SessionSkeleton() {66 return (67 <div className="space-y-2">68 {[0, 1, 2].map((i) => (69 <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">70 <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-white/10" />71 <div className="flex-1 space-y-2">72 <div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" />73 <div className="h-2.5 w-1/2 rounded bg-gray-200 dark:bg-white/10" />74 </div>75 </div>76 ))}77 </div>78 );79}8081export default function DevicesPanel() {82 const [sessions, setSessions] = useState<DeviceSession[]>([]);83 const [loading, setLoading] = useState(true);84 const [revokingId, setRevokingId] = useState<string | null>(null);85 const [expandedId, setExpandedId] = useState<string | null>(null);86 const [toRevoke, setToRevoke] = useState<DeviceSession | null>(null);8788 const fetchSessions = async () => {89 try {90 const res = await fetch("/api/auth/devices");91 if (res.ok) setSessions(await res.json());92 } catch {}93 setLoading(false);94 };9596 useEffect(() => { fetchSessions(); }, []);9798 const handleRevoke = async (session: DeviceSession) => {99 if (session.isCurrent) { toast.error("Impossible de révoquer la session active"); return; }100 setRevokingId(session.id);101 setToRevoke(null);102 try {103 const res = await fetch(`/api/auth/devices/${session.id}`, { method: "DELETE" });104 if (res.ok) {105 toast.success("Session révoquée");106 setSessions((prev) => prev.map((s) => (s.id === session.id ? { ...s, endedAt: new Date().toISOString() } : s)));107 } else toast.error("Erreur lors de la révocation");108 } catch { toast.error("Erreur réseau"); }109 setRevokingId(null);110 };111112 const refused = sessions.filter((s) => s.status === "refused_by_ai");113 const current = sessions.find((s) => s.isCurrent && s.status === "active" && !s.endedAt);114 const active = sessions.filter((s) => s.status === "active" && !s.endedAt && !s.isCurrent);115 const past = sessions.filter((s) => (s.status === "active" && s.endedAt) || s.status === "ended");116117 if (loading) return <SessionSkeleton />;118119 if (sessions.length === 0) {120 return <EmptyState icon={Monitor} title="Aucune session enregistrée" description="Les connexions futures apparaîtront ici avec leur appareil, leur emplacement et l'analyse de sécurité." />;121 }122123 const SessionCard = ({ s, i }: { s: DeviceSession; i: number }) => {124 const isRefused = s.status === "refused_by_ai";125 const isActive = s.status === "active" && !s.endedAt;126 const isExpanded = expandedId === s.id;127128 return (129 <motion.div130 key={s.id}131 initial={{ opacity: 0, y: 6 }}132 animate={{ opacity: 1, y: 0 }}133 transition={{ delay: Math.min(i, 8) * 0.04 }}134 className={`card overflow-hidden ${isRefused ? "!border-rose-500/30 bg-rose-500/[0.03]" : !isActive ? "opacity-70" : ""}`}135 >136 <div className="flex flex-col sm:flex-row sm:items-center gap-3 p-4">137 <div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${138 isRefused ? "bg-rose-500/10 text-rose-500" : isActive ? "bg-blue-500/10 text-blue-600 dark:text-blue-300" : "bg-gray-100 dark:bg-white/[0.06] text-gray-400"139 }`}>140 {isRefused ? <ShieldX className="w-5 h-5" /> : <DeviceIcon device={s.device} />}141 </div>142143 <div className="flex-1 min-w-0 space-y-1">144 <div className="flex items-center gap-2 flex-wrap">145 <span className="text-[14px] font-medium text-gray-900 dark:text-white">{s.browser} · {s.os}</span>146 {isRefused && (147 <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-1 ring-inset ring-rose-500/20 rounded-full uppercase tracking-wide">148 <Bot className="w-2.5 h-2.5" /> Refusé par IA149 </span>150 )}151 {!isRefused && <RiskBadge level={s.aiRiskLevel} />}152 </div>153 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-gray-500 dark:text-gray-400">154 <span className="flex items-center gap-1 font-mono"><Globe className="w-3 h-3" />{s.ip}</span>155 {(s.country || s.city) && (156 <span className="flex items-center gap-1"><MapPin className="w-3 h-3" />{[s.city, s.country].filter(Boolean).join(", ")}</span>157 )}158 <span className="flex items-center gap-1"><Clock className="w-3 h-3" />{formatRelative(s.createdAt)}</span>159 {s.endedAt && <span className="flex items-center gap-1 text-gray-400"><LogOut className="w-3 h-3" />déconnecté {formatRelative(s.endedAt)}</span>}160 </div>161 </div>162163 <div className="flex items-center gap-1 shrink-0">164 {s.aiReason && (165 <button166 onClick={() => setExpandedId(isExpanded ? null : s.id)}167 className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-xl hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-colors"168 title="Voir l'analyse IA"169 aria-expanded={isExpanded}170 >171 <ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />172 </button>173 )}174 {isActive && !s.isCurrent && (175 <Button variant="danger" size="sm" onClick={() => setToRevoke(s)} loading={revokingId === s.id}>176 <LogOut className="w-3.5 h-3.5" /> Révoquer177 </Button>178 )}179 </div>180 </div>181182 <AnimatePresence>183 {isExpanded && s.aiReason && (184 <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} className="overflow-hidden">185 <div className={`mx-4 mb-4 p-3 rounded-xl flex items-start gap-2.5 text-[12.5px] ${186 isRefused ? "bg-rose-500/10 text-rose-700 dark:text-rose-300" : "bg-blue-500/10 text-blue-800 dark:text-blue-200"187 }`}>188 <Bot className="w-4 h-4 mt-0.5 shrink-0" />189 <div>190 <span className="font-semibold block mb-0.5">191 Analyse IA — {isRefused ? "connexion refusée" : `risque ${s.aiRiskLevel || "faible"}`}192 </span>193 <span>{s.aiReason}</span>194 </div>195 </div>196 </motion.div>197 )}198 </AnimatePresence>199 </motion.div>200 );201 };202203 return (204 <div className="space-y-6">205 {/* Appareil courant */}206 {current && (207 <div className="relative overflow-hidden rounded-3xl border border-emerald-500/25 bg-gradient-to-br from-emerald-500/10 via-emerald-500/5 to-transparent p-4 sm:p-5">208 <div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-emerald-500/15 blur-3xl pointer-events-none" />209 <div className="relative flex flex-col sm:flex-row sm:items-center gap-4">210 <div className="w-12 h-12 rounded-2xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">211 <DeviceIcon device={current.device} className="w-6 h-6" />212 </div>213 <div className="flex-1 min-w-0">214 <div className="flex items-center gap-2 flex-wrap">215 <span className="text-[15px] font-semibold text-gray-900 dark:text-white">{current.browser} · {current.os}</span>216 <span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 rounded-full uppercase tracking-wide">217 <Wifi className="w-2.5 h-2.5" /> Cet appareil218 </span>219 </div>220 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-gray-600 dark:text-gray-400 mt-1">221 <span className="flex items-center gap-1 font-mono"><Globe className="w-3 h-3" />{current.ip}</span>222 {(current.country || current.city) && <span className="flex items-center gap-1"><MapPin className="w-3 h-3" />{[current.city, current.country].filter(Boolean).join(", ")}</span>}223 <span className="flex items-center gap-1"><Clock className="w-3 h-3" />connecté {formatRelative(current.createdAt)}</span>224 </div>225 </div>226 <ShieldCheck className="w-6 h-6 text-emerald-500 shrink-0 hidden sm:block" />227 </div>228 </div>229 )}230231 <div className="flex items-start gap-2.5 p-3 rounded-2xl bg-blue-500/[0.07] text-[13px] text-blue-800 dark:text-blue-200">232 <Shield className="w-4 h-4 mt-0.5 shrink-0" />233 <span>Chaque connexion est analysée en temps réel par l'IA (appareil, emplacement, historique) pour détecter toute activité suspecte.</span>234 </div>235236 {refused.length > 0 && (237 <section className="space-y-2">238 <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-rose-500 px-1 flex items-center gap-1.5">239 <ShieldX className="w-3.5 h-3.5" /> Connexions refusées ({refused.length})240 </h3>241 {refused.map((s, i) => <SessionCard key={s.id} s={s} i={i} />)}242 </section>243 )}244245 {active.length > 0 && (246 <section className="space-y-2">247 <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 px-1">Autres sessions actives ({active.length})</h3>248 {active.map((s, i) => <SessionCard key={s.id} s={s} i={i} />)}249 </section>250 )}251252 {past.length > 0 && (253 <section className="space-y-2">254 <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 px-1">Historique ({past.length})</h3>255 {past.map((s, i) => <SessionCard key={s.id} s={s} i={i} />)}256 </section>257 )}258259 <Modal260 isOpen={!!toRevoke}261 onClose={() => setToRevoke(null)}262 title="Révoquer cette session ?"263 description={toRevoke ? `${toRevoke.browser} · ${toRevoke.os} — ${toRevoke.ip}` : undefined}264 footer={265 <>266 <Button variant="ghost" onClick={() => setToRevoke(null)}>Annuler</Button>267 <Button variant="danger" onClick={() => toRevoke && handleRevoke(toRevoke)}><LogOut className="w-4 h-4" /> Révoquer</Button>268 </>269 }270 >271 <p className="text-sm text-gray-600 dark:text-gray-300">L'appareil sera déconnecté immédiatement et devra se reconnecter.</p>272 </Modal>273 </div>274 );275}276