Add secure admin panel for content moderation and contribution flagging

Adds an admin interface with authentication for manual content deletion and flagging. Implements a flagging system for user contributions and secures the admin panel with a secret token.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7e5834b1-796d-4a9e-bbde-cd91012292de
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/nghZcOj
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
pironantoine
2026-04-05 03:42:58 +00:00
parent e58c1cef85
commit 2a792cbbb5
5 changed files with 984 additions and 10 deletions
+191 -4
View File
@@ -2,7 +2,8 @@
La Voix du Peuple — Backend Flask La Voix du Peuple — Backend Flask
================================== ==================================
Plateforme démocratique citoyenne. Plateforme démocratique citoyenne.
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000) Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000),
Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024.
Sécurité : Sécurité :
- Rate limiting (flask-limiter) - Rate limiting (flask-limiter)
@@ -10,21 +11,28 @@ Sécurité :
- CORS restreint - CORS restreint
- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.) - En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
- Protection contre l'injection via requêtes paramétrées (psycopg2) - Protection contre l'injection via requêtes paramétrées (psycopg2)
- Panel admin protégé par ADMIN_SECRET (Bearer token)
- Aucun secret exposé dans les réponses d'erreur - Aucun secret exposé dans les réponses d'erreur
""" """
import csv
import io
import os import os
import logging import logging
import threading import threading
from datetime import datetime, timezone from functools import wraps
import bleach import bleach
from flask import Flask, jsonify, request, g from flask import Flask, jsonify, request, Response
from flask_cors import CORS from flask_cors import CORS
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from database import init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, get_synthesis, get_all_ideas from database import (
init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas,
override_idea, flag_idea, unflag_idea,
)
from ai_agent import filter_idea, synthesize_ideas from ai_agent import filter_idea, synthesize_ideas
# ─── Logging ──────────────────────────────────────────────────────────────── # ─── Logging ────────────────────────────────────────────────────────────────
@@ -106,6 +114,23 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
return {"content": content, "author": author}, None return {"content": content, "author": author}, None
# ─── Authentification Admin ──────────────────────────────────────────────────
def _get_admin_secret() -> str | None:
return os.environ.get("ADMIN_SECRET")
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
secret = _get_admin_secret()
if not secret:
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != secret:
return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
return f(*args, **kwargs)
return decorated
# ─── Gestion des erreurs ───────────────────────────────────────────────────── # ─── Gestion des erreurs ─────────────────────────────────────────────────────
@app.errorhandler(400) @app.errorhandler(400)
@@ -220,6 +245,160 @@ def get_synthesis_route():
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None, "updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
}) })
# ─── Route publique : signalement ────────────────────────────────────────────
@app.post("/api/ideas/<int:idea_id>/flag")
@limiter.limit("3 per minute; 10 per hour")
def flag_idea_route(idea_id: int):
"""Signale une contribution pour examen par l'administrateur."""
result = flag_idea(idea_id)
if not result:
return jsonify({"error": "not_found", "message": "Contribution introuvable ou non publiée."}), 404
return jsonify({"ok": True, "flagCount": result.get("flag_count", 1)})
# ─── Routes Admin ─────────────────────────────────────────────────────────────
@app.post("/api/admin/login")
@limiter.limit("10 per minute")
def admin_login():
"""Vérifie le mot de passe admin. Retourne ok si correct."""
secret = _get_admin_secret()
if not secret:
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
data = request.get_json(silent=True) or {}
password = data.get("password", "")
if password != secret:
logger.warning("Tentative de connexion admin échouée")
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
logger.info("Connexion admin réussie")
return jsonify({"ok": True, "token": secret})
@app.get("/api/admin/stats")
@require_admin
def admin_stats():
"""Statistiques détaillées pour l'administrateur."""
stats = get_stats()
return jsonify(stats)
@app.get("/api/admin/ideas")
@require_admin
@limiter.limit("120 per minute")
def admin_list_ideas():
"""Liste toutes les contributions avec filtres et pagination."""
status = request.args.get("status", "all")
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(10, int(request.args.get("per_page", 50))))
search = request.args.get("q", "").strip()
if status not in ("all", "accepted", "rejected", "flagged"):
status = "all"
ideas, total = get_ideas_admin(status=status, page=page, per_page=per_page, search=search)
return jsonify({
"ideas": [serialize_idea_admin(i) for i in ideas],
"total": total,
"page": page,
"perPage": per_page,
"pages": max(1, -(-total // per_page)),
})
@app.delete("/api/admin/ideas/<int:idea_id>")
@require_admin
def admin_delete_idea(idea_id: int):
"""Supprime une contribution et régénère la synthèse."""
deleted = delete_idea(idea_id)
if not deleted:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — contribution #%d supprimée", idea_id)
return jsonify({"ok": True, "synthesisUpdating": True})
@app.post("/api/admin/ideas/bulk-delete")
@require_admin
def admin_bulk_delete():
"""Suppression en masse de contributions."""
data = request.get_json(silent=True) or {}
ids = data.get("ids", [])
if not isinstance(ids, list) or not ids:
return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400
ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()]
count = bulk_delete_ideas(ids)
if count > 0:
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — %d contribution(s) supprimée(s) en masse", count)
return jsonify({"ok": True, "deleted": count, "synthesisUpdating": count > 0})
@app.post("/api/admin/ideas/<int:idea_id>/override")
@require_admin
def admin_override_idea(idea_id: int):
"""Modifie manuellement le statut d'une contribution (accepter/rejeter)."""
data = request.get_json(silent=True) or {}
accepted = bool(data.get("accepted", False))
reason = sanitize_text(data.get("reason", "") or "")
note = sanitize_text(data.get("note", "") or "")
result = override_idea(idea_id, accepted, reason or None, note or None)
if not result:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — contribution #%d override → accepted=%s", idea_id, accepted)
return jsonify({"ok": True, "idea": serialize_idea_admin(result), "synthesisUpdating": True})
@app.post("/api/admin/ideas/<int:idea_id>/unflag")
@require_admin
def admin_unflag_idea(idea_id: int):
"""Retire le signalement d'une contribution."""
result = unflag_idea(idea_id)
if not result:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
return jsonify({"ok": True, "idea": serialize_idea_admin(result)})
@app.post("/api/admin/synthesis/regenerate")
@require_admin
@limiter.limit("5 per minute")
def admin_regenerate_synthesis():
"""Force la régénération complète de la synthèse."""
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — régénération manuelle de la synthèse déclenchée")
return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."})
@app.get("/api/admin/export/csv")
@require_admin
def admin_export_csv():
"""Exporte toutes les contributions en CSV."""
ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000)
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
writer.writerow(["id", "content", "author", "accepted", "flagged",
"flag_count", "rejection_reason", "legal_basis",
"admin_note", "created_at"])
for idea in ideas:
writer.writerow([
idea.get("id"),
idea.get("content", ""),
idea.get("author", ""),
"oui" if idea.get("accepted") else "non",
"oui" if idea.get("flagged") else "non",
idea.get("flag_count", 0),
idea.get("rejection_reason", ""),
idea.get("legal_basis", ""),
idea.get("admin_note", ""),
idea.get("created_at").isoformat() if idea.get("created_at") else "",
])
csv_bytes = output.getvalue().encode("utf-8-sig")
return Response(
csv_bytes,
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
)
# ─── Helpers ───────────────────────────────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────────────────────────────
def serialize_idea(idea: dict) -> dict: def serialize_idea(idea: dict) -> dict:
@@ -228,12 +407,20 @@ def serialize_idea(idea: dict) -> dict:
"content": idea["content"], "content": idea["content"],
"author": idea.get("author"), "author": idea.get("author"),
"accepted": idea["accepted"], "accepted": idea["accepted"],
"flagged": idea.get("flagged", False),
"flagCount": idea.get("flag_count", 0),
"rejectionReason": idea.get("rejection_reason"), "rejectionReason": idea.get("rejection_reason"),
"legalBasis": idea.get("legal_basis"), "legalBasis": idea.get("legal_basis"),
"createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None, "createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None,
} }
def serialize_idea_admin(idea: dict) -> dict:
base = serialize_idea(idea)
base["adminNote"] = idea.get("admin_note")
return base
def _update_synthesis_background(): def _update_synthesis_background():
try: try:
ideas = get_accepted_ideas() ideas = get_accepted_ideas()
+98 -5
View File
@@ -46,10 +46,10 @@ def init_db():
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) )
""") """)
cur.execute(""" cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT")
ALTER TABLE ideas cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE")
ADD COLUMN IF NOT EXISTS legal_basis TEXT cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0")
""") cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT")
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS synthesis ( CREATE TABLE IF NOT EXISTS synthesis (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -91,6 +91,97 @@ def get_all_ideas(limit: int = 50) -> list[dict]:
return [dict(row) for row in cur.fetchall()] return [dict(row) for row in cur.fetchall()]
def get_ideas_admin(status: str = "all", page: int = 1,
per_page: int = 50, search: str = "") -> tuple[list[dict], int]:
offset = (page - 1) * per_page
conditions = []
params: list = []
if status == "accepted":
conditions.append("accepted = TRUE")
elif status == "rejected":
conditions.append("accepted = FALSE AND flagged = FALSE")
elif status == "flagged":
conditions.append("flagged = TRUE")
if search:
conditions.append("content ILIKE %s")
params.append(f"%{search}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with db_cursor() as cur:
cur.execute(f"SELECT COUNT(*) as total FROM ideas {where}", params)
total = cur.fetchone()["total"]
cur.execute(
f"SELECT * FROM ideas {where} ORDER BY flagged DESC, created_at DESC LIMIT %s OFFSET %s",
params + [per_page, offset],
)
rows = [dict(row) for row in cur.fetchall()]
return rows, total
def delete_idea(idea_id: int) -> bool:
with db_cursor() as cur:
cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,))
return cur.fetchone() is not None
def bulk_delete_ideas(idea_ids: list[int]) -> int:
if not idea_ids:
return 0
with db_cursor() as cur:
cur.execute(
"DELETE FROM ideas WHERE id = ANY(%s) RETURNING id",
(idea_ids,),
)
return len(cur.fetchall())
def override_idea(idea_id: int, accepted: bool,
reason: str | None, note: str | None) -> dict | None:
with db_cursor() as cur:
cur.execute(
"""
UPDATE ideas
SET accepted = %s,
rejection_reason = %s,
flagged = FALSE,
admin_note = %s
WHERE id = %s
RETURNING *
""",
(accepted, reason, note, idea_id),
)
row = cur.fetchone()
return dict(row) if row else None
def flag_idea(idea_id: int) -> dict | None:
with db_cursor() as cur:
cur.execute(
"""
UPDATE ideas
SET flagged = TRUE, flag_count = flag_count + 1
WHERE id = %s AND accepted = TRUE
RETURNING *
""",
(idea_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def unflag_idea(idea_id: int) -> dict | None:
with db_cursor() as cur:
cur.execute(
"UPDATE ideas SET flagged = FALSE WHERE id = %s RETURNING *",
(idea_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_stats() -> dict: def get_stats() -> dict:
with db_cursor() as cur: with db_cursor() as cur:
cur.execute("SELECT COUNT(*) as total FROM ideas") cur.execute("SELECT COUNT(*) as total FROM ideas")
@@ -99,7 +190,9 @@ def get_stats() -> dict:
accepted = cur.fetchone()["accepted"] accepted = cur.fetchone()["accepted"]
cur.execute("SELECT COUNT(*) as rejected FROM ideas WHERE accepted = FALSE") cur.execute("SELECT COUNT(*) as rejected FROM ideas WHERE accepted = FALSE")
rejected = cur.fetchone()["rejected"] rejected = cur.fetchone()["rejected"]
return {"total": total, "accepted": accepted, "rejected": rejected} cur.execute("SELECT COUNT(*) as flagged FROM ideas WHERE flagged = TRUE")
flagged = cur.fetchone()["flagged"]
return {"total": total, "accepted": accepted, "rejected": rejected, "flagged": flagged}
def upsert_synthesis(text: str, idea_count: int) -> dict: def upsert_synthesis(text: str, idea_count: int) -> dict:
+2
View File
@@ -7,6 +7,7 @@ import Home from "@/pages/home";
import About from "@/pages/about"; import About from "@/pages/about";
import Transparence from "@/pages/transparence"; import Transparence from "@/pages/transparence";
import Flyer from "@/pages/flyer"; import Flyer from "@/pages/flyer";
import Admin from "@/pages/admin";
import { AccessibilityProvider } from "@/hooks/use-accessibility"; import { AccessibilityProvider } from "@/hooks/use-accessibility";
import { AccessibilityPanel } from "@/components/accessibility-panel"; import { AccessibilityPanel } from "@/components/accessibility-panel";
@@ -67,6 +68,7 @@ function Router() {
<Route path="/about" component={About} /> <Route path="/about" component={About} />
<Route path="/transparence" component={Transparence} /> <Route path="/transparence" component={Transparence} />
<Route path="/flyer" component={Flyer} /> <Route path="/flyer" component={Flyer} />
<Route path="/admin" component={Admin} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
</main> </main>
@@ -0,0 +1,652 @@
import { useState, useEffect, useCallback } from "react";
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,
} from "lucide-react";
const API_BASE = import.meta.env.VITE_API_URL ?? "";
type Idea = {
id: number;
content: string;
author: string | null;
accepted: boolean;
flagged: boolean;
flagCount: number;
rejectionReason: string | null;
legalBasis: string | null;
adminNote: string | 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;
};
function useAdminAuth() {
const [token, setToken] = useState<string | null>(() =>
sessionStorage.getItem("admin_token")
);
const login = (t: string) => {
sessionStorage.setItem("admin_token", t);
setToken(t);
};
const logout = () => {
sessionStorage.removeItem("admin_token");
setToken(null);
};
const headers = token
? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
: { "Content-Type": "application/json" };
return { token, login, logout, headers };
}
function LoginPanel({ onLogin }: { onLogin: (t: string) => 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 fetch(`${API_BASE}/api/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || "Mot de passe incorrect.");
return;
}
onLogin(data.token);
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>
);
}
export default function Admin() {
const { token, login, logout, headers } = useAdminAuth();
const { toast } = useToast();
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 [flaggedIds, setFlaggedIds] = useState<Set<number>>(new Set());
const fetchStats = useCallback(async () => {
if (!token) return;
try {
const res = await fetch(`${API_BASE}/api/admin/stats`, { headers });
if (res.ok) setStats(await res.json());
} catch { /* ignore */ }
}, [token, headers]);
const fetchList = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const params = new URLSearchParams({
status,
page: String(page),
per_page: "50",
...(search ? { q: search } : {}),
});
const res = await fetch(`${API_BASE}/api/admin/ideas?${params}`, { headers });
if (res.ok) {
const data: IdeaList = await res.json();
setList(data);
setSelected(new Set());
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [token, headers, status, page, search]);
useEffect(() => { if (token) { fetchStats(); fetchList(); } }, [token, fetchStats, fetchList]);
const deleteOne = async (id: number) => {
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}`, { method: "DELETE", headers });
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 fetch(`${API_BASE}/api/admin/ideas/bulk-delete`, {
method: "POST", headers, body: JSON.stringify({ ids }),
});
if (res.ok) {
const data = await res.json();
toast({ title: `${data.deleted} contribution(s) supprimée(s)`, description: "Synthèse en cours de mise à jour." });
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 fetch(`${API_BASE}/api/admin/ideas/${overrideTarget.id}/override`, {
method: "POST", headers,
body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }),
});
if (res.ok) {
toast({ title: "Statut modifié", description: `Contribution ${overrideAccepted ? "acceptée" : "rejetée"} manuellement.` });
fetchStats(); fetchList();
} else {
toast({ title: "Erreur", description: "Modification échouée.", variant: "destructive" });
}
setOverrideTarget(null);
};
const unflag = async (id: number) => {
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}/unflag`, { method: "POST", headers });
if (res.ok) {
setFlaggedIds((prev) => { const s = new Set(prev); s.delete(id); return s; });
toast({ title: "Signalement retiré" });
fetchList();
}
};
const regenerate = async () => {
setRegenLoading(true);
const res = await fetch(`${API_BASE}/api/admin/synthesis/regenerate`, { method: "POST", headers });
if (res.ok) {
toast({ title: "Régénération lancée", description: "La synthèse sera mise à jour dans quelques secondes." });
}
setRegenLoading(false);
};
const exportCsv = () => {
const url = `${API_BASE}/api/admin/export/csv`;
const a = document.createElement("a");
a.href = url;
a.setAttribute("download", "contributions.csv");
const req = new XMLHttpRequest();
req.open("GET", url);
req.setRequestHeader("Authorization", `Bearer ${token}`);
req.responseType = "blob";
req.onload = () => {
const blob = req.response;
const objUrl = URL.createObjectURL(blob);
a.href = objUrl;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(objUrl);
};
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;
if (selected.size === list.ideas.length) {
setSelected(new Set());
} else {
setSelected(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 (!token) 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>
<div className="ml-auto flex items-center gap-2">
<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">
{/* 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">
{/* Tabs */}
<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>
{/* Search */}
<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>
{/* Bulk actions */}
{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>
)}
{/* Table */}
<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>
) : (
<>
{/* Header row */}
<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>
{/* Rows */}
{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.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>
) : null}
</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 contribution sera définitivement supprimée
et 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>
);
}
+41 -1
View File
@@ -27,7 +27,7 @@ import {
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { import {
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale, Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
Share2, Printer, Copy, Share2, Printer, Copy, Flag,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -71,6 +71,8 @@ const VALEURS = [
}, },
]; ];
const API_BASE = import.meta.env.VITE_API_URL ?? "";
export default function Home() { export default function Home() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
@@ -79,6 +81,26 @@ export default function Home() {
message: string; message: string;
reason?: string; reason?: string;
} | null>(null); } | null>(null);
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
const [flaggingId, setFlaggingId] = React.useState<number | null>(null);
const handleFlag = async (ideaId: number) => {
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
setFlaggingId(ideaId);
try {
const res = await fetch(`${API_BASE}/api/ideas/${ideaId}/flag`, { method: "POST" });
if (res.ok) {
setFlaggedIds((prev) => new Set(prev).add(ideaId));
toast({ title: "Signalement envoyé", description: "Cette contribution a été signalée à l'administrateur." });
} else {
toast({ title: "Erreur", description: "Impossible d'envoyer le signalement.", variant: "destructive" });
}
} catch {
toast({ title: "Erreur réseau", description: "Vérifiez votre connexion.", variant: "destructive" });
} finally {
setFlaggingId(null);
}
};
const submitIdea = useSubmitIdea(); const submitIdea = useSubmitIdea();
const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas(); const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas();
@@ -345,6 +367,24 @@ export default function Home() {
<span> <span>
{format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })} {format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })}
</span> </span>
<button
onClick={() => handleFlag(idea.id)}
disabled={flaggedIds.has(idea.id) || flaggingId === idea.id}
className={`ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity text-xs ${
flaggedIds.has(idea.id)
? "text-orange-500 opacity-100"
: "text-muted-foreground/50 hover:text-orange-500"
}`}
title={flaggedIds.has(idea.id) ? "Déjà signalé" : "Signaler cette contribution"}
aria-label="Signaler cette contribution"
>
{flaggingId === idea.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Flag className="h-3 w-3" />
)}
{flaggedIds.has(idea.id) ? "Signalé" : "Signaler"}
</button>
</div> </div>
</div> </div>
)) ))