// 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 { 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(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 (

Panel Admin

La Voix du Peuple

setPassword(e.target.value)} placeholder="ADMIN_SECRET" autoFocus required />
{error && (

{error}

)}
); } function StatsBadge({ label, value, color }: { label: string; value: number; color: string }) { return (
{value}
{label}
); } // ─── Panel Consultations ────────────────────────────────────────────────────── function ConsultationsPanel() { const { toast } = useToast(); const [consultations, setConsultations] = useState([]); const [loading, setLoading] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); const [closeTarget, setCloseTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(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 (
{/* Toolbar */}

Consultations ciblées

{/* Formulaire de création */} {showCreateForm && (

Créer une consultation

handleSlugChange(e.target.value)} placeholder="mairie-paris-budget-2026" required className="text-sm font-mono" />

Sera accessible sur /consultation/{fSlug || "slug"}

setFTitle(e.target.value)} placeholder="Budget participatif 2026" required className="text-sm" />