SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
2 h agolast push
JavaScript 56.7% TypeScript 42.6%
12.7 KB · 276 lines tsx
Raw Blame History
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&apos;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&apos;appareil sera déconnecté immédiatement et devra se reconnecter.</p>272      </Modal>273    </div>274  );275}276