Files
la-voix-du-peuple/artifacts/voix-du-peuple/src/pages/admin.tsx
T

1277 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React, { useState, useEffect, useCallback } from "react";
import { Link } from "wouter";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Trash2, RefreshCw, Download, LogOut, Check, X, Flag,
ChevronLeft, ChevronRight, Search, ShieldCheck, Eye, Loader2,
Plus, Lock, Clock, Users, Building2, ExternalLink, Calendar,
ShieldBan, ShieldOff,
} from "lucide-react";
const API_BASE = import.meta.env.VITE_API_URL ?? "";
/**
* fetch() vers l'API admin — envoie toujours le cookie de session httpOnly
* (jamais lu ni stocké côté JS) et pose le Content-Type JSON par défaut.
*/
function adminFetch(path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
});
}
// ─── Types ────────────────────────────────────────────────────────────────────
type Idea = {
id: number;
content: string;
author: string | null;
accepted: boolean;
flagged: boolean;
flagCount: number;
rejectionReason: string | null;
legalBasis: string | null;
adminNote: string | null;
consultationId: number | null;
createdAt: string | null;
};
type Stats = {
total: number;
accepted: number;
rejected: number;
flagged: number;
};
type IdeaList = {
ideas: Idea[];
total: number;
page: number;
pages: number;
perPage: number;
};
type ConsultationStats = {
total: number;
accepted: number;
lastUpdated: string | null;
};
type IpBlacklistEntry = {
ipHash: string;
rejectionCount: number;
firstRejectedAt: string | null;
lastRejectedAt: string | null;
lastIdeaId: number | null;
blacklistedAt: string | null;
expiresAt: string | null;
};
type Consultation = {
id: number;
slug: string;
title: string;
subject: string;
introMessage: string | null;
organizerName: string | null;
organizerLogoUrl: string | null;
startsAt: string | null;
endsAt: string | null;
closedAt: string | null;
createdAt: string | null;
isOpen: boolean;
stats: ConsultationStats;
};
// ─── Hook auth ────────────────────────────────────────────────────────────────
/**
* L'authentification vit dans un cookie httpOnly (invisible à JS, jamais dans
* sessionStorage) — au chargement, on interroge /api/admin/session pour savoir
* s'il est encore valide. `authed === null` = vérification en cours.
*/
function useAdminAuth() {
const [authed, setAuthed] = useState<boolean | null>(null);
const checkSession = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/session");
setAuthed(res.ok);
} catch {
setAuthed(false);
}
}, []);
useEffect(() => { checkSession(); }, [checkSession]);
const login = () => setAuthed(true);
const logout = async () => {
try {
await adminFetch("/api/admin/logout", { method: "POST" });
} catch { /* ignore */ }
setAuthed(false);
};
return { authed, login, logout };
}
// ─── Composants utilitaires ───────────────────────────────────────────────────
function LoginPanel({ onLogin }: { onLogin: () => void }) {
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await adminFetch("/api/admin/login", {
method: "POST",
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) { setError(data.message || "Mot de passe incorrect."); return; }
onLogin();
toast({ title: "Connecté", description: "Bienvenue dans le panel admin." });
} catch {
setError("Erreur réseau. Vérifiez que l'API est accessible.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<div className="w-full max-w-sm space-y-6 p-8 bg-background border border-border rounded-xl shadow-sm">
<div className="flex items-center gap-3">
<ShieldCheck className="h-6 w-6 text-primary" />
<div>
<h1 className="font-serif text-xl font-bold text-primary">Panel Admin</h1>
<p className="text-xs text-muted-foreground">La Voix du Peuple</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Mot de passe</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="ADMIN_SECRET"
autoFocus
required
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Connexion
</Button>
</form>
</div>
</div>
);
}
function StatsBadge({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className={`rounded-lg border px-4 py-3 text-center ${color}`}>
<div className="text-2xl font-bold font-mono">{value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
</div>
);
}
// ─── Panel Consultations ──────────────────────────────────────────────────────
function ConsultationsPanel() {
const { toast } = useToast();
const [consultations, setConsultations] = useState<Consultation[]>([]);
const [loading, setLoading] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [closeTarget, setCloseTarget] = useState<Consultation | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Consultation | null>(null);
// Champs du formulaire de création
const [fSlug, setFSlug] = useState("");
const [fTitle, setFTitle] = useState("");
const [fSubject, setFSubject] = useState("");
const [fIntro, setFIntro] = useState("");
const [fOrgName, setFOrgName] = useState("");
const [fOrgLogo, setFOrgLogo] = useState("");
const [fStartsAt, setFStartsAt] = useState("");
const [fEndsAt, setFEndsAt] = useState("");
const [fWebhook, setFWebhook] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const fetchConsultations = useCallback(async () => {
setLoading(true);
try {
const res = await adminFetch("/api/admin/consultations");
if (res.ok) setConsultations(await res.json());
} catch { /* ignore */ }
finally { setLoading(false); }
}, []);
useEffect(() => { fetchConsultations(); }, [fetchConsultations]);
// Normalise le slug en temps réel (alphanumériques + tirets)
const handleSlugChange = (v: string) => {
setFSlug(v.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-"));
};
const resetForm = () => {
setFSlug(""); setFTitle(""); setFSubject(""); setFIntro("");
setFOrgName(""); setFOrgLogo(""); setFStartsAt(""); setFEndsAt(""); setFWebhook("");
setCreateError("");
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
setCreateError("");
try {
const res = await adminFetch("/api/admin/consultations", {
method: "POST",
body: JSON.stringify({
slug: fSlug, title: fTitle, subject: fSubject,
introMessage: fIntro || undefined,
organizerName: fOrgName || undefined,
organizerLogoUrl: fOrgLogo || undefined,
startsAt: fStartsAt || undefined,
endsAt: fEndsAt || undefined,
webhookUrl: fWebhook || undefined,
}),
});
const data = await res.json();
if (!res.ok) {
setCreateError(data.message || "Création échouée.");
return;
}
toast({ title: "Consultation créée", description: `Slug : ${data.slug}` });
resetForm();
setShowCreateForm(false);
fetchConsultations();
} catch {
setCreateError("Erreur réseau.");
} finally {
setCreating(false);
}
};
const handleClose = async () => {
if (!closeTarget) return;
const res = await adminFetch(`/api/admin/consultations/${closeTarget.slug}/close`, {
method: "POST",
});
if (res.ok) {
toast({ title: "Consultation fermée", description: "Le webhook a été déclenché si configuré." });
fetchConsultations();
} else {
toast({ title: "Erreur", description: "Fermeture échouée.", variant: "destructive" });
}
setCloseTarget(null);
};
const handleDelete = async () => {
if (!deleteTarget) return;
const res = await adminFetch(`/api/admin/consultations/${deleteTarget.slug}`, {
method: "DELETE",
});
if (res.ok) {
toast({ title: "Consultation supprimée" });
fetchConsultations();
} else {
toast({ title: "Erreur", description: "Suppression échouée.", variant: "destructive" });
}
setDeleteTarget(null);
};
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "2-digit" }) : "—";
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex items-center justify-between">
<h2 className="font-serif font-semibold text-lg text-primary">Consultations ciblées</h2>
<Button size="sm" onClick={() => { setShowCreateForm((v) => !v); resetForm(); }}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
{showCreateForm ? "Annuler" : "Nouvelle consultation"}
</Button>
</div>
{/* Formulaire de création */}
{showCreateForm && (
<form onSubmit={handleCreate} className="bg-background border border-border rounded-xl p-5 space-y-4">
<h3 className="font-medium text-sm text-foreground">Créer une consultation</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium mb-1 block">Slug URL *</label>
<Input
value={fSlug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="mairie-paris-budget-2026"
required
className="text-sm font-mono"
/>
<p className="text-xs text-muted-foreground mt-0.5">
Sera accessible sur /consultation/{fSlug || "slug"}
</p>
</div>
<div>
<label className="text-xs font-medium mb-1 block">Titre *</label>
<Input value={fTitle} onChange={(e) => setFTitle(e.target.value)} placeholder="Budget participatif 2026" required className="text-sm" />
</div>
</div>
<div>
<label className="text-xs font-medium mb-1 block">Sujet * (affiché publiquement)</label>
<Textarea
value={fSubject}
onChange={(e) => setFSubject(e.target.value)}
placeholder="Décrivez la thématique et les questions posées aux participants."
rows={2}
required
className="text-sm"
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block">Message d'introduction (facultatif)</label>
<Textarea
value={fIntro}
onChange={(e) => setFIntro(e.target.value)}
placeholder="Message de bienvenue ou instructions pour les participants."
rows={2}
className="text-sm"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium mb-1 block">Nom de l'organisateur</label>
<Input value={fOrgName} onChange={(e) => setFOrgName(e.target.value)} placeholder="Mairie de Paris" className="text-sm" />
</div>
<div>
<label className="text-xs font-medium mb-1 block">URL logo HTTPS (facultatif)</label>
<Input value={fOrgLogo} onChange={(e) => setFOrgLogo(e.target.value)} placeholder="https://…" className="text-sm" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium mb-1 block flex items-center gap-1">
<Calendar className="h-3 w-3" /> Ouverture (UTC)
</label>
<Input type="datetime-local" value={fStartsAt} onChange={(e) => setFStartsAt(e.target.value)} className="text-sm" />
<p className="text-xs text-muted-foreground mt-0.5">Vide = immédiatement</p>
</div>
<div>
<label className="text-xs font-medium mb-1 block flex items-center gap-1">
<Calendar className="h-3 w-3" /> Fermeture automatique (UTC)
</label>
<Input type="datetime-local" value={fEndsAt} onChange={(e) => setFEndsAt(e.target.value)} className="text-sm" />
<p className="text-xs text-muted-foreground mt-0.5">Vide = pas de fermeture auto</p>
</div>
</div>
<div>
<label className="text-xs font-medium mb-1 block">URL Webhook de clôture (facultatif)</label>
<Input value={fWebhook} onChange={(e) => setFWebhook(e.target.value)} placeholder="https://…/webhook" className="text-sm font-mono" />
<p className="text-xs text-muted-foreground mt-0.5">
Reçoit un POST JSON avec la synthèse à la clôture (manuelle ou automatique).
</p>
</div>
{createError && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
{createError}
</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={() => { setShowCreateForm(false); resetForm(); }}>
Annuler
</Button>
<Button type="submit" size="sm" disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
Créer la consultation
</Button>
</div>
</form>
)}
{/* Liste */}
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Chargement…
</div>
) : consultations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border rounded-xl">
Aucune consultation créée. Utilisez le bouton "Nouvelle consultation" pour commencer.
</div>
) : (
<div className="space-y-3">
{consultations.map((c) => (
<div key={c.id} className="bg-background border border-border rounded-xl p-4 space-y-3">
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-foreground">{c.title}</span>
{c.closedAt ? (
<Badge variant="secondary" className="text-xs gap-1">
<Lock className="h-2.5 w-2.5" /> Clôturée
</Badge>
) : c.isOpen ? (
<Badge className="bg-green-600 text-xs gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-white/80 animate-pulse inline-block" />
En cours
</Badge>
) : (
<Badge variant="outline" className="text-xs gap-1">
<Clock className="h-2.5 w-2.5" /> À venir
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono">/consultation/{c.slug}</p>
{c.organizerName && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Building2 className="h-3 w-3" /> {c.organizerName}
</p>
)}
<p className="text-xs text-foreground/70 line-clamp-2">{c.subject}</p>
</div>
{/* Stats */}
<div className="flex gap-3 text-xs text-muted-foreground flex-shrink-0">
<div className="text-center">
<div className="text-base font-bold font-mono text-foreground">{c.stats.accepted}</div>
<div>intégrées</div>
</div>
<div className="text-center">
<div className="text-base font-bold font-mono text-foreground">{c.stats.total}</div>
<div>soumises</div>
</div>
</div>
</div>
{/* Dates */}
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Ouverture : {fmtDate(c.startsAt)}
</span>
{c.endsAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Fermeture auto : {fmtDate(c.endsAt)}
</span>
)}
{c.closedAt && (
<span className="flex items-center gap-1 text-orange-600">
<Lock className="h-3 w-3" />
Fermée le : {fmtDate(c.closedAt)}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap pt-1 border-t border-border/30">
<Link href={`/consultation/${c.slug}`} target="_blank">
<Button size="sm" variant="outline" className="h-7 text-xs gap-1">
<ExternalLink className="h-3 w-3" /> Voir la page
</Button>
</Link>
{!c.closedAt && (
<Button
size="sm" variant="outline"
className="h-7 text-xs gap-1 text-orange-600 border-orange-200 hover:bg-orange-50"
onClick={() => setCloseTarget(c)}
>
<Lock className="h-3 w-3" /> Fermer manuellement
</Button>
)}
<Button
size="sm" variant="ghost"
className="h-7 text-xs gap-1 text-red-500 hover:text-red-600 hover:bg-red-50 ml-auto"
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="h-3 w-3" /> Supprimer
</Button>
</div>
</div>
))}
</div>
)}
{/* Dialog fermeture */}
<AlertDialog open={closeTarget !== null} onOpenChange={() => setCloseTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Fermer la consultation ?</AlertDialogTitle>
<AlertDialogDescription>
La consultation « {closeTarget?.title} » sera définitivement fermée aux nouvelles
contributions. Si un webhook est configuré, il sera déclenché immédiatement.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction className="bg-orange-600 hover:bg-orange-700" onClick={handleClose}>
Fermer la consultation
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog suppression */}
<AlertDialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer la consultation ?</AlertDialogTitle>
<AlertDialogDescription>
La consultation « {deleteTarget?.title} » sera supprimée avec toutes ses contributions
et sa synthèse. Cette action est irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 hover:bg-red-700" onClick={handleDelete}>
Supprimer définitivement
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ─── Panel Blacklist IP ───────────────────────────────────────────────────────
function BlacklistPanel() {
const [entries, setEntries] = React.useState<IpBlacklistEntry[]>([]);
const [loading, setLoading] = React.useState(false);
const { toast } = useToast();
const fetchEntries = React.useCallback(async () => {
setLoading(true);
try {
const res = await adminFetch("/api/admin/ip-blacklist");
if (res.ok) setEntries(await res.json());
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { fetchEntries(); }, [fetchEntries]);
const lift = async (ipHash: string) => {
const res = await adminFetch(`/api/admin/ip-blacklist/${ipHash}`, {
method: "DELETE",
});
if (res.ok) {
toast({ title: "Blacklist levée", description: `Hash ${ipHash.slice(0, 8)}… retiré.` });
fetchEntries();
} else {
toast({ title: "Erreur", description: "Impossible de lever le blacklist.", variant: "destructive" });
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldBan className="h-4 w-4 text-destructive" />
<h2 className="font-serif font-semibold text-lg text-primary">IPs blacklistées</h2>
<span className="text-xs text-muted-foreground font-mono">({entries.length} active{entries.length !== 1 ? "s" : ""})</span>
</div>
<Button size="sm" variant="outline" onClick={fetchEntries} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
</div>
<div className="text-xs text-muted-foreground border border-border/40 rounded-md p-3 bg-muted/30 space-y-1">
<p>Les IPs sont stockées sous forme de hash SHA-256 (non réversible — RGPD art. 6(1)(f)).</p>
<p>Un blacklist est déclenché après <strong>2 contributions illégales</strong>. Durée : <strong>30 jours</strong> par défaut.</p>
</div>
{entries.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
{loading ? <Loader2 className="h-5 w-5 animate-spin mx-auto" /> : "Aucune IP blacklistée actuellement."}
</div>
) : (
<div className="space-y-2">
{entries.map(e => (
<div key={e.ipHash} className="border border-border rounded-lg p-4 bg-card space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="font-mono text-xs text-foreground/80 break-all">
<span className="text-muted-foreground mr-2">hash</span>{e.ipHash}
</p>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span><span className="text-destructive font-semibold">{e.rejectionCount}</span> rejet{e.rejectionCount !== 1 ? "s" : ""}</span>
{e.lastIdeaId && (
<span>dernière contribution : <span className="font-mono">#{e.lastIdeaId}</span></span>
)}
{e.blacklistedAt && (
<span>depuis le {new Date(e.blacklistedAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}</span>
)}
{e.expiresAt && (
<span>expire le {new Date(e.expiresAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}</span>
)}
</div>
</div>
<Button
size="sm"
variant="outline"
className="flex-shrink-0 text-xs gap-1.5"
onClick={() => lift(e.ipHash)}
>
<ShieldOff className="h-3.5 w-3.5" /> Lever
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}
function AiModelsPanel() {
const { toast } = useToast();
const [data, setData] = useState<{ filter: string; synthesis: string; available: string[]; error: string | null } | null>(null);
const [filter, setFilter] = useState("");
const [synthesis, setSynthesis] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const res = await adminFetch("/api/admin/ai/models");
if (!res.ok) return;
const d = await res.json();
setData(d); setFilter(d.filter); setSynthesis(d.synthesis);
}, []);
useEffect(() => { load(); }, [load]);
const test = async (model: string) => {
setBusy(true);
const r = await adminFetch("/api/admin/ai/test", { method: "POST", body: JSON.stringify({ model }) });
const d = await r.json().catch(() => ({}));
setBusy(false);
toast(d.ok
? { title: "Modèle OK", description: model }
: { title: "Échec", description: d.error ?? `Erreur ${r.status}`, variant: "destructive" });
return !!d.ok;
};
const save = async () => {
setBusy(true);
const r = await adminFetch("/api/admin/ai/models", { method: "POST", body: JSON.stringify({ filter, synthesis }) });
setBusy(false);
toast(r.ok ? { title: "Modèles enregistrés" } : { title: "Erreur", variant: "destructive" });
if (r.ok) load();
};
const opts = data?.available ?? [];
const field = (label: string, value: string, set: (v: string) => void) => (
<div className="space-y-1">
<label className="text-xs text-muted-foreground">{label}</label>
<div className="flex gap-2">
<Input list="ai-models" value={value} onChange={e => set(e.target.value)} className="font-mono text-xs" />
<Button size="sm" variant="outline" disabled={busy || !value} onClick={() => test(value)}>Tester</Button>
</div>
</div>
);
return (
<div className="space-y-4">
<h2 className="font-serif font-semibold text-lg text-primary">Modèles IA</h2>
{data?.error && (
<div className="text-xs border border-destructive/40 rounded-md p-3 bg-destructive/5 text-destructive">
Clé API inutilisable ou fournisseur injoignable : <span className="font-mono">{data.error}</span>
<p className="mt-1 text-muted-foreground">La clé se change dans <code>MISTRAL_API_KEY</code> (.env) puis redémarrage de l'API.</p>
</div>
)}
<datalist id="ai-models">{opts.map(m => <option key={m} value={m} />)}</datalist>
{field("Modèle de filtrage (rapide)", filter, setFilter)}
{field("Modèle de synthèse (qualité)", synthesis, setSynthesis)}
<div className="flex items-center gap-3">
<Button size="sm" onClick={save} disabled={busy}>Enregistrer</Button>
<span className="text-xs text-muted-foreground">{opts.length} modèle(s) accessible(s) avec la clé actuelle</span>
</div>
</div>
);
}
// ─── Composant principal Admin ────────────────────────────────────────────────
export default function Admin() {
const { authed, login, logout } = useAdminAuth();
const { toast } = useToast();
const [activePanel, setActivePanel] = useState<"contributions" | "consultations" | "blacklist" | "ia">("contributions");
const [stats, setStats] = useState<Stats | null>(null);
const [list, setList] = useState<IdeaList | null>(null);
const [status, setStatus] = useState("all");
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [searchInput, setSearchInput] = useState("");
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [overrideTarget, setOverrideTarget] = useState<Idea | null>(null);
const [overrideAccepted, setOverrideAccepted] = useState(false);
const [overrideReason, setOverrideReason] = useState("");
const [overrideNote, setOverrideNote] = useState("");
const [regenLoading, setRegenLoading] = useState(false);
const fetchStats = useCallback(async () => {
if (!authed) return;
try {
const res = await adminFetch("/api/admin/stats");
if (res.ok) setStats(await res.json());
} catch { /* ignore */ }
}, [authed]);
const fetchList = useCallback(async () => {
if (!authed) return;
setLoading(true);
try {
const params = new URLSearchParams({
status, page: String(page), per_page: "50",
...(search ? { q: search } : {}),
});
const res = await adminFetch(`/api/admin/ideas?${params}`);
if (res.ok) {
setList(await res.json());
setSelected(new Set());
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [authed, status, page, search]);
useEffect(() => {
if (authed && activePanel === "contributions") { fetchStats(); fetchList(); }
}, [authed, fetchStats, fetchList, activePanel]);
const deleteOne = async (id: number) => {
const res = await adminFetch(`/api/admin/ideas/${id}`, { method: "DELETE" });
if (res.ok) {
toast({ title: "Contribution supprimée", description: "La synthèse est en cours de mise à jour." });
fetchStats(); fetchList();
} else {
toast({ title: "Erreur", description: "Suppression échouée.", variant: "destructive" });
}
setDeleteTarget(null);
};
const bulkDelete = async () => {
const ids = Array.from(selected);
const res = await adminFetch("/api/admin/ideas/bulk-delete", {
method: "POST", body: JSON.stringify({ ids }),
});
if (res.ok) {
const data = await res.json();
toast({ title: `${data.deleted} contribution(s) supprimée(s)` });
fetchStats(); fetchList();
} else {
toast({ title: "Erreur", description: "Suppression en masse échouée.", variant: "destructive" });
}
setBulkDeleteOpen(false);
};
const override = async () => {
if (!overrideTarget) return;
const res = await adminFetch(`/api/admin/ideas/${overrideTarget.id}/override`, {
method: "POST",
body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }),
});
if (res.ok) {
toast({ title: "Statut modifié" });
fetchStats(); fetchList();
} else {
toast({ title: "Erreur", description: "Modification échouée.", variant: "destructive" });
}
setOverrideTarget(null);
};
const unflag = async (id: number) => {
const res = await adminFetch(`/api/admin/ideas/${id}/unflag`, { method: "POST" });
if (res.ok) { toast({ title: "Signalement retiré" }); fetchList(); }
};
const regenerate = async () => {
setRegenLoading(true);
const res = await adminFetch("/api/admin/synthesis/regenerate", { method: "POST" });
if (res.ok) toast({ title: "Régénération lancée", description: "Mise à jour dans quelques secondes." });
setRegenLoading(false);
};
const exportCsv = () => {
const url = `${API_BASE}/api/admin/export/csv`;
const req = new XMLHttpRequest();
req.open("GET", url);
req.withCredentials = true; // envoie le cookie de session httpOnly
req.responseType = "blob";
req.onload = () => {
if (req.status !== 200) {
toast({ title: "Erreur export", description: `Erreur ${req.status} lors de l'export CSV.`, variant: "destructive" });
return;
}
const a = document.createElement("a");
a.href = URL.createObjectURL(req.response);
a.setAttribute("download", "contributions.csv");
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
};
req.send();
};
const toggleSelect = (id: number) => {
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
};
const toggleAll = () => {
if (!list) return;
setSelected(selected.size === list.ideas.length ? new Set() : new Set(list.ideas.map((i) => i.id)));
};
const openOverride = (idea: Idea) => {
setOverrideTarget(idea);
setOverrideAccepted(!idea.accepted);
setOverrideReason(idea.rejectionReason || "");
setOverrideNote(idea.adminNote || "");
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
if (authed === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!authed) return <LoginPanel onLogin={login} />;
const TABS = [
{ key: "all", label: "Toutes" },
{ key: "accepted", label: "Acceptées" },
{ key: "rejected", label: "Rejetées" },
{ key: "flagged", label: "Signalées" },
];
return (
<div className="min-h-screen bg-muted/20">
{/* Header */}
<header className="sticky top-0 z-40 bg-background border-b border-border/60 px-6 py-3 flex items-center gap-4">
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0" />
<span className="font-serif font-bold text-primary">Administration</span>
<span className="text-muted-foreground text-sm hidden sm:inline">La Voix du Peuple</span>
{/* Sélecteur de panneau */}
<div className="flex gap-1 bg-muted/50 rounded-lg p-1 ml-4">
<button
onClick={() => setActivePanel("contributions")}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
activePanel === "contributions"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
Contributions
</button>
<button
onClick={() => setActivePanel("consultations")}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
activePanel === "consultations"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
Consultations
</button>
<button
onClick={() => setActivePanel("blacklist")}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 ${
activePanel === "blacklist"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<ShieldBan className="h-3 w-3" /> Blacklist
</button>
<button
onClick={() => setActivePanel("ia")}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
activePanel === "ia"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
IA
</button>
</div>
<div className="ml-auto flex items-center gap-2">
{activePanel === "contributions" && (
<>
<Button size="sm" variant="outline" onClick={regenerate} disabled={regenLoading}>
{regenLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
<span className="hidden sm:inline ml-1.5">Régénérer synthèse</span>
</Button>
<Button size="sm" variant="outline" onClick={exportCsv}>
<Download className="h-3 w-3" />
<span className="hidden sm:inline ml-1.5">CSV</span>
</Button>
</>
)}
<Button size="sm" variant="ghost" onClick={logout}>
<LogOut className="h-3 w-3" />
<span className="hidden sm:inline ml-1.5">Déconnexion</span>
</Button>
</div>
</header>
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
{/* ─── Panel Consultations ─── */}
{activePanel === "consultations" && (
<ConsultationsPanel />
)}
{activePanel === "ia" && <AiModelsPanel />}
{/* ─── Panel Blacklist IP ─── */}
{activePanel === "blacklist" && (
<BlacklistPanel />
)}
{/* ─── Panel Contributions ─── */}
{activePanel === "contributions" && (
<>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatsBadge label="Total" value={stats.total} color="border-border bg-background" />
<StatsBadge label="Acceptées" value={stats.accepted} color="border-green-200 bg-green-50 text-green-800" />
<StatsBadge label="Rejetées" value={stats.rejected} color="border-red-200 bg-red-50 text-red-800" />
<StatsBadge label="Signalées" value={stats.flagged} color="border-orange-200 bg-orange-50 text-orange-800" />
</div>
)}
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex gap-1 bg-muted/50 rounded-lg p-1">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => { setStatus(t.key); setPage(1); setSelected(new Set()); }}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
status === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}
</button>
))}
</div>
<form onSubmit={handleSearch} className="flex gap-2 sm:ml-auto">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Rechercher..."
className="h-8 text-sm w-48"
/>
<Button type="submit" size="sm" variant="outline" className="h-8 px-2">
<Search className="h-3 w-3" />
</Button>
{search && (
<Button size="sm" variant="ghost" className="h-8 px-2" onClick={() => { setSearch(""); setSearchInput(""); setPage(1); }}>
<X className="h-3 w-3" />
</Button>
)}
</form>
</div>
{/* Sélection multiple */}
{selected.size > 0 && (
<div className="flex items-center gap-3 bg-primary/5 border border-primary/20 rounded-lg px-4 py-2">
<span className="text-sm font-medium text-primary">{selected.size} sélectionnée(s)</span>
<Button size="sm" variant="destructive" onClick={() => setBulkDeleteOpen(true)}>
<Trash2 className="h-3 w-3 mr-1" /> Supprimer la sélection
</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>Annuler</Button>
</div>
)}
{/* Tableau */}
<div className="bg-background border border-border rounded-xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Chargement...
</div>
) : !list || list.ideas.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-sm">
Aucune contribution dans cette catégorie.
</div>
) : (
<>
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/50 bg-muted/30 text-xs text-muted-foreground font-medium">
<input
type="checkbox"
className="rounded"
checked={selected.size === list.ideas.length && list.ideas.length > 0}
onChange={toggleAll}
/>
<span className="flex-1">Contribution</span>
<span className="w-20 text-right">Statut</span>
<span className="w-32 text-right hidden sm:block">Date</span>
<span className="w-28 text-right">Actions</span>
</div>
{list.ideas.map((idea) => (
<div
key={idea.id}
className={`flex items-start gap-3 px-4 py-3 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors ${
idea.flagged ? "bg-orange-50/50" : ""
} ${selected.has(idea.id) ? "bg-primary/5" : ""}`}
>
<input
type="checkbox"
className="rounded mt-1 flex-shrink-0"
checked={selected.has(idea.id)}
onChange={() => toggleSelect(idea.id)}
/>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm leading-snug line-clamp-3">{idea.content}</p>
<div className="flex items-center gap-2 flex-wrap">
{idea.author && (
<span className="text-xs text-muted-foreground font-mono">{idea.author}</span>
)}
{idea.consultationId && (
<span className="text-xs text-blue-500 font-mono">consultation #{idea.consultationId}</span>
)}
{idea.flagged && (
<span className="text-xs text-orange-600 font-medium flex items-center gap-0.5">
<Flag className="h-3 w-3" /> {idea.flagCount}×
</span>
)}
{idea.rejectionReason && (
<span className="text-xs text-red-600 truncate max-w-xs" title={idea.rejectionReason}>
✗ {idea.rejectionReason}
</span>
)}
{idea.adminNote && (
<span className="text-xs text-blue-600 italic truncate max-w-xs" title={idea.adminNote}>
Note: {idea.adminNote}
</span>
)}
</div>
</div>
<div className="w-20 flex-shrink-0 text-right pt-0.5">
<Badge variant={idea.accepted ? "default" : "destructive"} className="text-xs">
{idea.accepted ? "Acceptée" : "Rejetée"}
</Badge>
</div>
<div className="w-32 flex-shrink-0 text-right pt-1 hidden sm:block">
{idea.createdAt && (
<span className="text-xs text-muted-foreground font-mono">
{new Date(idea.createdAt).toLocaleDateString("fr-FR", {
day: "2-digit", month: "2-digit", year: "2-digit",
hour: "2-digit", minute: "2-digit",
})}
</span>
)}
</div>
<div className="w-28 flex-shrink-0 flex items-center gap-1 justify-end pt-0.5">
{idea.flagged && (
<Button
size="sm" variant="ghost"
className="h-7 px-1.5 text-orange-600 hover:text-orange-700"
title="Retirer le signalement"
onClick={() => unflag(idea.id)}
>
<Eye className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm" variant="ghost" className="h-7 px-1.5"
title={idea.accepted ? "Rejeter manuellement" : "Accepter manuellement"}
onClick={() => openOverride(idea)}
>
{idea.accepted
? <X className="h-3.5 w-3.5 text-red-500" />
: <Check className="h-3.5 w-3.5 text-green-600" />
}
</Button>
<Button
size="sm" variant="ghost"
className="h-7 px-1.5 text-red-500 hover:text-red-600 hover:bg-red-50"
title="Supprimer définitivement"
onClick={() => setDeleteTarget(idea.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</>
)}
</div>
{/* Pagination */}
{list && list.pages > 1 && (
<div className="flex items-center justify-center gap-3">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground font-mono">{page} / {list.pages}</span>
<Button size="sm" variant="outline" disabled={page >= list.pages} onClick={() => setPage((p) => p + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
</div>
{/* Dialog suppression unique */}
<AlertDialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cette contribution ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. La synthèse collective sera régénérée automatiquement.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => deleteTarget !== null && deleteOne(deleteTarget)}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog suppression en masse */}
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer {selected.size} contribution(s) ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. La synthèse sera régénérée après suppression.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 hover:bg-red-700" onClick={bulkDelete}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog override */}
<Dialog open={overrideTarget !== null} onOpenChange={() => setOverrideTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le statut manuellement</DialogTitle>
</DialogHeader>
{overrideTarget && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground bg-muted/40 p-3 rounded-lg line-clamp-4">
{overrideTarget.content}
</p>
<div className="flex gap-3">
<Button
variant={overrideAccepted ? "default" : "outline"} size="sm"
onClick={() => setOverrideAccepted(true)} className="flex-1"
>
<Check className="h-4 w-4 mr-1" /> Accepter
</Button>
<Button
variant={!overrideAccepted ? "destructive" : "outline"} size="sm"
onClick={() => setOverrideAccepted(false)} className="flex-1"
>
<X className="h-4 w-4 mr-1" /> Rejeter
</Button>
</div>
{!overrideAccepted && (
<div>
<label className="text-sm font-medium mb-1 block">Motif du refus</label>
<Input
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="Raison de la modération manuelle"
/>
</div>
)}
<div>
<label className="text-sm font-medium mb-1 block">Note admin (interne)</label>
<Textarea
value={overrideNote}
onChange={(e) => setOverrideNote(e.target.value)}
placeholder="Note visible uniquement par l'administrateur"
rows={2}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => setOverrideTarget(null)}>Annuler</Button>
<Button onClick={override}>Confirmer</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}