Conformité RGPD (P3) + transparence éditoriale (P4)
P3 — RGPD :
- Table `consents` + `POST /api/consent` (art. 7.1 — preuve du consentement)
- Dialogue de consentement explicite avant la première contribution (art. 9.2.a)
- Pages `/mentions-legales` et `/politique-confidentialite`
- `docs/RGPD.md` — registre des traitements, bases légales, sous-traitants
- `getVisitorId()` exporté depuis l'API client React
P4 — Transparence éditoriale :
- Page `/contributions-brutes` avec pagination et export JSON/CSV
- `GET /api/contributions`, `GET /api/contributions/export/{json,csv}`
- `GET /api/stats/public` — stats publiques sans données de rejet
- Label de transparence IA sur la colonne de synthèse
- Compteurs (acceptées / soumises) dans le bandeau d'intro
- `docs/PROMPTS_IA.md` — prompts intégraux publiés + analyse des biais
- Pied de page avec liens légaux et transparence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ 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,
|
||||
create_consent, get_public_contributions, get_public_stats,
|
||||
)
|
||||
from ai_agent import filter_idea, synthesize_ideas
|
||||
|
||||
@@ -422,6 +423,103 @@ def get_synthesis_route():
|
||||
})
|
||||
|
||||
|
||||
# ─── Routes publiques : consentement, stats, contributions ───────────────────
|
||||
|
||||
@app.post("/api/consent")
|
||||
@limiter.limit("5 per minute")
|
||||
def record_consent():
|
||||
"""Enregistre le consentement explicite d'un citoyen (art. 9.2.a RGPD)."""
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
consent_version = sanitize_text(str(data.get("consent_version", "1.0") or "1.0"))[:20]
|
||||
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
|
||||
fingerprint_hash = (
|
||||
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else "anonymous"
|
||||
)
|
||||
create_consent(fingerprint_hash, consent_version)
|
||||
logger.info("Consentement enregistré — fingerprint: %s... | version: %s", fingerprint_hash[:8], consent_version)
|
||||
return jsonify({"ok": True}), 201
|
||||
|
||||
|
||||
@app.get("/api/stats/public")
|
||||
@limiter.limit("120 per minute")
|
||||
def public_stats():
|
||||
"""Statistiques publiques : total soumis et acceptées (sans données de rejet)."""
|
||||
return jsonify(get_public_stats())
|
||||
|
||||
|
||||
@app.get("/api/contributions")
|
||||
@limiter.limit("60 per minute")
|
||||
def public_contributions():
|
||||
"""Liste paginée des contributions acceptées — vue publique anti-chronologique."""
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(50, max(5, int(request.args.get("per_page", 20))))
|
||||
except (ValueError, TypeError):
|
||||
page, per_page = 1, 20
|
||||
contributions, total = get_public_contributions(page=page, per_page=per_page)
|
||||
pages = max(1, -(-total // per_page))
|
||||
return jsonify({
|
||||
"contributions": [
|
||||
{
|
||||
"id": c["id"],
|
||||
"content": c["content"],
|
||||
"author": c.get("author"),
|
||||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||||
}
|
||||
for c in contributions
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"perPage": per_page,
|
||||
"pages": pages,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/contributions/export/json")
|
||||
@limiter.limit("10 per minute")
|
||||
def export_contributions_json():
|
||||
"""Exporte l'intégralité des contributions acceptées en JSON (champs publics)."""
|
||||
contributions, _ = get_public_contributions(page=1, per_page=10000)
|
||||
payload = [
|
||||
{
|
||||
"id": c["id"],
|
||||
"content": c["content"],
|
||||
"author": c.get("author"),
|
||||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||||
}
|
||||
for c in contributions
|
||||
]
|
||||
return Response(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
mimetype="application/json",
|
||||
headers={"Content-Disposition": "attachment; filename=contributions.json"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/contributions/export/csv")
|
||||
@limiter.limit("10 per minute")
|
||||
def export_contributions_csv():
|
||||
"""Exporte les contributions acceptées en CSV (champs publics uniquement — pas de fingerprint)."""
|
||||
contributions, _ = get_public_contributions(page=1, per_page=10000)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||
writer.writerow(["id", "content", "author", "created_at"])
|
||||
for c in contributions:
|
||||
writer.writerow([
|
||||
c.get("id"),
|
||||
c.get("content", ""),
|
||||
c.get("author", ""),
|
||||
c["created_at"].isoformat() if c.get("created_at") else "",
|
||||
])
|
||||
return Response(
|
||||
output.getvalue().encode("utf-8-sig"),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ─── Route publique : signalement ────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/ideas/<int:idea_id>/flag")
|
||||
|
||||
@@ -62,6 +62,18 @@ def init_db() -> None:
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
# Table de traçabilité des consentements RGPD (art. 7.1 — charge de la preuve)
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS consents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fingerprint_hash VARCHAR(64) NOT NULL,
|
||||
consent_version VARCHAR(20) NOT NULL,
|
||||
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_consents_fingerprint ON consents(fingerprint_hash)"
|
||||
)
|
||||
logger.info("Base de données initialisée.")
|
||||
|
||||
|
||||
@@ -85,6 +97,55 @@ def insert_idea(
|
||||
return dict(cur.fetchone())
|
||||
|
||||
|
||||
def create_consent(fingerprint_hash: str, consent_version: str) -> dict:
|
||||
"""Enregistre un consentement explicite (art. 7.1 RGPD — preuve)."""
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO consents (fingerprint_hash, consent_version)
|
||||
VALUES (%s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(fingerprint_hash, consent_version),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
|
||||
def get_public_contributions(page: int = 1, per_page: int = 20) -> tuple[list[dict], int]:
|
||||
"""Retourne les contributions acceptées paginées pour la vue publique."""
|
||||
offset = (page - 1) * per_page
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as total FROM ideas WHERE accepted = TRUE AND flagged = FALSE"
|
||||
)
|
||||
total = cur.fetchone()["total"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, content, author, created_at
|
||||
FROM ideas
|
||||
WHERE accepted = TRUE AND flagged = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(per_page, offset),
|
||||
)
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
return rows, total
|
||||
|
||||
|
||||
def get_public_stats() -> dict:
|
||||
"""Statistiques publiques — ne révèle pas les chiffres de rejet."""
|
||||
with db_cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) as total FROM ideas")
|
||||
total = cur.fetchone()["total"]
|
||||
cur.execute("SELECT COUNT(*) as accepted FROM ideas WHERE accepted = TRUE")
|
||||
accepted = cur.fetchone()["accepted"]
|
||||
cur.execute("SELECT updated_at FROM synthesis LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None
|
||||
return {"total": total, "accepted": accepted, "lastUpdated": last_updated}
|
||||
|
||||
|
||||
def get_accepted_ideas() -> list[dict]:
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Switch, Route, Router as WouterRouter, Link } from "wouter";
|
||||
import { Switch, Route, Router as WouterRouter, Link, useLocation } from "wouter";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -10,6 +10,9 @@ import About from "@/pages/about";
|
||||
import Transparence from "@/pages/transparence";
|
||||
import Flyer from "@/pages/flyer";
|
||||
import Admin from "@/pages/admin";
|
||||
import LegalNotice from "@/pages/legal-notice";
|
||||
import PrivacyPolicy from "@/pages/privacy-policy";
|
||||
import ContributionsBrutes from "@/pages/contributions-brutes";
|
||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||
import { setVisitorId } from "@workspace/api-client-react";
|
||||
@@ -55,7 +58,32 @@ function Navbar() {
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border/40 bg-background py-5">
|
||||
<div className="container mx-auto max-w-7xl px-4 md:px-8">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-6 items-start sm:items-center justify-between text-xs text-muted-foreground font-mono">
|
||||
<span className="font-serif font-semibold text-foreground/70 text-sm">La Voix du Peuple</span>
|
||||
<nav className="flex flex-wrap gap-4" aria-label="Liens légaux et transparence">
|
||||
<Link href="/mentions-legales" className="hover:text-foreground transition-colors">
|
||||
Mentions légales
|
||||
</Link>
|
||||
<Link href="/politique-confidentialite" className="hover:text-foreground transition-colors">
|
||||
Confidentialité
|
||||
</Link>
|
||||
<Link href="/contributions-brutes" className="hover:text-foreground transition-colors">
|
||||
Contributions brutes
|
||||
</Link>
|
||||
</nav>
|
||||
<span className="opacity-50">EUPL-1.2 · billisdead</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
const [location] = useLocation();
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans">
|
||||
{/* Lien d'évitement pour lecteurs d'écran */}
|
||||
@@ -73,9 +101,14 @@ function Router() {
|
||||
<Route path="/transparence" component={Transparence} />
|
||||
<Route path="/flyer" component={Flyer} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/mentions-legales" component={LegalNotice} />
|
||||
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
|
||||
<Route path="/contributions-brutes" component={ContributionsBrutes} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
{/* Pied de page masqué sur la page d'accueil (layout plein-écran) */}
|
||||
{location !== "/" && <Footer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
interface ConsentDialogProps {
|
||||
open: boolean;
|
||||
onConsent: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConsentDialog({ open, onConsent, onCancel }: ConsentDialogProps) {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
// Réinitialise la case à cocher à chaque ouverture
|
||||
React.useEffect(() => {
|
||||
if (open) setChecked(false);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 font-serif">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
Votre consentement
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm text-foreground/80 leading-relaxed pt-1">
|
||||
<p>
|
||||
Avant de contribuer, nous avons besoin de votre accord explicite
|
||||
sur le traitement de vos données.
|
||||
</p>
|
||||
<div className="bg-muted/50 border border-border/60 rounded-sm p-4 space-y-2 text-xs">
|
||||
<p className="font-semibold text-foreground/90">Ce que nous collectons :</p>
|
||||
<ul className="space-y-1 text-foreground/75">
|
||||
<li>• Le texte de votre contribution (opinion politique — donnée sensible au sens de l'art. 9 RGPD)</li>
|
||||
<li>• Un pseudonyme facultatif librement choisi</li>
|
||||
<li>• La date et l'heure de la soumission</li>
|
||||
<li>• Un identifiant technique non-personnel pour la protection anti-abus (hash non réversible)</li>
|
||||
</ul>
|
||||
<p className="font-semibold text-foreground/90 pt-1">Durée de conservation :</p>
|
||||
<p className="text-foreground/75">24 mois, puis suppression ou anonymisation.</p>
|
||||
<p className="font-semibold text-foreground/90 pt-1">Sous-traitant IA :</p>
|
||||
<p className="text-foreground/75">
|
||||
Votre contribution est analysée par Mistral AI (France) pour modération et synthèse.
|
||||
Aucun transfert hors Union européenne.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/60">
|
||||
Vous pouvez retirer votre consentement et demander la suppression de vos données
|
||||
à tout moment via piron.antoine@gmail.com. Détails :{" "}
|
||||
<Link href="/politique-confidentialite" className="underline text-primary" onClick={onCancel}>
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<Checkbox
|
||||
id="consent-check"
|
||||
checked={checked}
|
||||
onCheckedChange={(val) => setChecked(val === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="consent-check"
|
||||
className="text-sm leading-relaxed cursor-pointer"
|
||||
>
|
||||
J'ai lu et j'accepte la politique de confidentialité. Je consens
|
||||
au traitement de mes données, y compris mes opinions politiques,
|
||||
pour la finalité décrite ci-dessus.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={onConsent} disabled={!checked}>
|
||||
Accepter et contribuer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import {
|
||||
ArrowLeft, Download, ChevronLeft, ChevronRight, Loader2, AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
const PER_PAGE = 20;
|
||||
|
||||
interface Contribution {
|
||||
id: number;
|
||||
content: string;
|
||||
author: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ContributionsResponse {
|
||||
contributions: Contribution[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export default function ContributionsBrutes() {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<ContributionsResponse | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
fetch(`${API_BASE}/api/contributions?page=${page}&per_page=${PER_PAGE}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json() as Promise<ContributionsResponse>;
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-8 space-y-3">
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
|
||||
Contributions brutes
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
L'intégralité des contributions acceptées, dans leur ordre de soumission, sans mise en forme éditoriale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Avertissement éditorial */}
|
||||
<Alert className="mb-8 border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40">
|
||||
<AlertDescription className="text-sm text-foreground/80 leading-relaxed">
|
||||
La{" "}
|
||||
<Link href="/" className="underline text-primary">
|
||||
synthèse collective
|
||||
</Link>{" "}
|
||||
visible sur la page d'accueil est une interprétation thématique automatisée de ces contributions.
|
||||
Elle peut regrouper, omettre ou reformuler des expressions individuelles.
|
||||
Cette page est la source primaire pour vérification.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Boutons d'export */}
|
||||
<div className="flex gap-3 mb-8">
|
||||
<a
|
||||
href={`${API_BASE}/api/contributions/export/json`}
|
||||
download="contributions.json"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
|
||||
<Download className="h-3.5 w-3.5" /> Export JSON
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href={`${API_BASE}/api/contributions/export/csv`}
|
||||
download="contributions.csv"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
|
||||
<Download className="h-3.5 w-3.5" /> Export CSV
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Contenu */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<span className="text-sm">Impossible de charger les contributions.</span>
|
||||
</div>
|
||||
) : data && data.contributions.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs font-mono text-muted-foreground mb-6">
|
||||
{data.total} contribution{data.total !== 1 ? "s" : ""} acceptée{data.total !== 1 ? "s" : ""}
|
||||
{" "}— page {data.page} / {data.pages}
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{data.contributions.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="border-l-2 border-border/50 pl-4 space-y-1"
|
||||
id={`contrib-${c.id}`}
|
||||
>
|
||||
<p className="text-sm leading-relaxed font-serif text-foreground/90">
|
||||
{c.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
|
||||
<span className="font-semibold text-primary/70">
|
||||
{c.author || "Citoyen anonyme"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(c.createdAt), "d MMMM yyyy, HH:mm", { locale: fr })}
|
||||
</span>
|
||||
<span className="ml-auto opacity-40">#{c.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-10 pt-6 border-t border-border/40">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Précédent
|
||||
</Button>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{page} / {data.pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Suivant <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm border border-dashed border-border/60">
|
||||
Aucune contribution acceptée pour l'instant.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 font-mono pt-8 mt-8 border-t border-border/30">
|
||||
Contributions modérées selon le droit international des droits humains (DUDH · PIDCP · CEDH).{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Voir les critères de modération
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
getGetIdeaStatsQueryKey,
|
||||
addExtraHeader,
|
||||
removeExtraHeader,
|
||||
getVisitorId,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ConsentDialog } from "@/components/consent-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -98,6 +101,13 @@ export default function Home() {
|
||||
const captchaRef = React.useRef<HCaptcha>(null);
|
||||
const [captchaToken, setCaptchaToken] = React.useState<string | null>(null);
|
||||
|
||||
// Consentement RGPD — persisté dans localStorage (art. 9.2.a)
|
||||
const [consentGiven, setConsentGiven] = React.useState(
|
||||
() => !!localStorage.getItem("consent_v1")
|
||||
);
|
||||
const [showConsentDialog, setShowConsentDialog] = React.useState(false);
|
||||
const pendingSubmitData = React.useRef<SubmitIdeaValues | null>(null);
|
||||
|
||||
const handleFlag = async (ideaId: number) => {
|
||||
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
||||
setFlaggingId(ideaId);
|
||||
@@ -123,6 +133,60 @@ export default function Home() {
|
||||
query: { refetchInterval: 15000 },
|
||||
});
|
||||
|
||||
// Soumission effective après confirmation du consentement (ou si déjà consenti)
|
||||
const doActualSubmit = (data: SubmitIdeaValues) => {
|
||||
if (captchaToken) {
|
||||
addExtraHeader("x-hcaptcha-token", captchaToken);
|
||||
}
|
||||
setSubmitResult(null);
|
||||
submitIdea.mutate({ data }, {
|
||||
onSuccess: (result) => {
|
||||
if (result.accepted) {
|
||||
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
|
||||
form.reset();
|
||||
} else {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
|
||||
reason: result.reason ?? undefined,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setSubmitResult({ success: false, message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer." });
|
||||
},
|
||||
onSettled: () => {
|
||||
removeExtraHeader("x-hcaptcha-token");
|
||||
captchaRef.current?.resetCaptcha();
|
||||
setCaptchaToken(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Confirme le consentement, l'enregistre en DB, puis exécute la soumission en attente
|
||||
const handleConsentConfirm = () => {
|
||||
localStorage.setItem("consent_v1", new Date().toISOString());
|
||||
setConsentGiven(true);
|
||||
setShowConsentDialog(false);
|
||||
const visitorId = getVisitorId();
|
||||
fetch(`${API_BASE}/api/consent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(visitorId ? { "X-Visitor-Id": visitorId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ consent_version: "1.0" }),
|
||||
}).catch(() => {
|
||||
// Dégradation silencieuse — localStorage suffit comme preuve côté client
|
||||
});
|
||||
if (pendingSubmitData.current) {
|
||||
doActualSubmit(pendingSubmitData.current);
|
||||
pendingSubmitData.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (!synthesis?.text) return;
|
||||
const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr });
|
||||
@@ -189,70 +253,53 @@ export default function Home() {
|
||||
const onSubmit = (data: SubmitIdeaValues) => {
|
||||
// Honeypot — si le champ leurre est rempli, c'est un bot
|
||||
if (honeypotRef.current?.value) {
|
||||
// Simulation silencieuse d'un succès sans appel API
|
||||
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// hCaptcha — obligatoire si la clé de site est configurée
|
||||
if (HCAPTCHA_SITE_KEY && !captchaToken) {
|
||||
toast({
|
||||
title: "Vérification requise",
|
||||
description: "Veuillez valider le CAPTCHA avant de soumettre.",
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({ title: "Vérification requise", description: "Veuillez valider le CAPTCHA avant de soumettre.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Transmission du token hCaptcha si disponible
|
||||
if (captchaToken) {
|
||||
addExtraHeader("x-hcaptcha-token", captchaToken);
|
||||
// Consentement RGPD — obligatoire avant la première contribution
|
||||
if (!consentGiven) {
|
||||
pendingSubmitData.current = data;
|
||||
setShowConsentDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitResult(null);
|
||||
submitIdea.mutate({ data }, {
|
||||
onSuccess: (result) => {
|
||||
if (result.accepted) {
|
||||
setSubmitResult({
|
||||
success: true,
|
||||
message: "Votre contribution a été ajoutée à la synthèse.",
|
||||
});
|
||||
form.reset();
|
||||
} else {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
|
||||
reason: result.reason ?? undefined,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
// Nettoyage du token hCaptcha après chaque tentative
|
||||
removeExtraHeader("x-hcaptcha-token");
|
||||
captchaRef.current?.resetCaptcha();
|
||||
setCaptchaToken(null);
|
||||
},
|
||||
});
|
||||
doActualSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConsentDialog
|
||||
open={showConsentDialog}
|
||||
onConsent={handleConsentConfirm}
|
||||
onCancel={() => { setShowConsentDialog(false); pendingSubmitData.current = null; }}
|
||||
/>
|
||||
|
||||
{/* Bandeau d'introduction */}
|
||||
<div className="border-b border-border/40 bg-muted/30 px-6 md:px-10 py-5">
|
||||
<p className="text-sm text-foreground/75 max-w-3xl leading-relaxed">
|
||||
<span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie.
|
||||
Exprimez-vous librement — chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite.
|
||||
<span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span>
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 max-w-5xl">
|
||||
<p className="text-sm text-foreground/75 leading-relaxed">
|
||||
<span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie.
|
||||
Exprimez-vous librement — chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite.
|
||||
<span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span>
|
||||
</p>
|
||||
{stats && stats.total > 0 && (
|
||||
<div className="flex items-center gap-5 text-xs font-mono text-muted-foreground flex-shrink-0 sm:text-right">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-primary leading-none">{stats.accepted}</div>
|
||||
<div className="mt-0.5">acceptées</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-foreground/40 leading-none">{stats.total}</div>
|
||||
<div className="mt-0.5">soumises</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] h-[calc(100vh-5rem)]">
|
||||
@@ -520,6 +567,19 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avertissement éditorial — transparence sur la nature de la synthèse IA */}
|
||||
<div className="px-6 md:px-10 pt-4 pb-1 relative z-10 flex-shrink-0">
|
||||
<Alert className="border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40 py-3">
|
||||
<AlertDescription className="text-xs text-foreground/70 leading-relaxed">
|
||||
Synthèse générée par IA — peut regrouper, omettre ou reformuler des contributions.{" "}
|
||||
<Link href="/contributions-brutes" className="underline text-primary">
|
||||
Voir les contributions brutes
|
||||
</Link>{" "}
|
||||
pour vérification indépendante.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Texte défilable */}
|
||||
<ScrollArea className="flex-1 relative z-10">
|
||||
<div className="px-6 md:px-10 py-8">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function LegalNotice() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-10 space-y-3">
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
|
||||
Mentions légales
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm font-mono">
|
||||
Conformément à la loi n° 2004-575 du 21 juin 2004 (LCEN)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-10 text-sm leading-relaxed">
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Éditeur du site</h2>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{[
|
||||
{ label: "Nom", value: "Antoine Piron" },
|
||||
{ label: "Pseudonyme", value: "billisdead" },
|
||||
{ label: "Qualité", value: "Personne physique — exploitant à titre personnel" },
|
||||
{ label: "Contact", value: "piron.antoine@gmail.com" },
|
||||
{ label: "Adresse", value: "France" },
|
||||
].map(row => (
|
||||
<tr key={row.label}>
|
||||
<td className="py-2 pr-6 font-mono text-xs font-semibold text-muted-foreground uppercase tracking-wider w-40">{row.label}</td>
|
||||
<td className="py-2 text-foreground/90">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Directeur de la publication</h2>
|
||||
<p className="text-foreground/80">Antoine Piron (billisdead)</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Hébergement</h2>
|
||||
<p className="text-foreground/80">
|
||||
La plateforme est auto-hébergée sur un serveur privé virtuel (VPS) situé
|
||||
en Union européenne. Le choix d'hébergement répond à une exigence de
|
||||
souveraineté numérique européenne.
|
||||
</p>
|
||||
<p className="text-foreground/70 text-xs font-mono">
|
||||
Configuration : Rocky Linux 9 · Nginx · Python / Flask · PostgreSQL
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Propriété intellectuelle</h2>
|
||||
<p className="text-foreground/80">
|
||||
Le code source de cette plateforme est publié sous la{" "}
|
||||
<strong>European Union Public Licence v. 1.2 (EUPL-1.2)</strong>, licence
|
||||
open source officielle de l'Union européenne. Le code est librement
|
||||
réutilisable par toute association, collectivité ou citoyen dans les
|
||||
conditions de cette licence.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Dépôt :{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
homegit.gyozamancave.fr/billisdead/la-voix-du-peuple
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Responsabilité éditoriale</h2>
|
||||
<p className="text-foreground/80">
|
||||
Les contributions publiées sur cette plateforme sont soumises par des
|
||||
tiers. Elles sont automatiquement modérées selon le cadre légal international
|
||||
des droits humains et le droit français. L'éditeur n'est pas responsable des
|
||||
contenus soumis, sous réserve de prendre les mesures prévues par la LCEN
|
||||
dès qu'un contenu illicite lui est signalé.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
La synthèse produite par l'IA est une interprétation éditoriale automatisée
|
||||
des contributions acceptées, pas un document factuel ni un consensus validé.
|
||||
Voir la page{" "}
|
||||
<Link href="/transparence" className="underline text-primary">Fonctionnement</Link>{" "}
|
||||
et les{" "}
|
||||
<Link href="/contributions-brutes" className="underline text-primary">Contributions brutes</Link>{" "}
|
||||
pour vérification.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Données personnelles</h2>
|
||||
<p className="text-foreground/80">
|
||||
Voir la{" "}
|
||||
<Link href="/politique-confidentialite" className="underline text-primary">
|
||||
politique de confidentialité
|
||||
</Link>{" "}
|
||||
et le document de conformité RGPD complet (
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/RGPD.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs/RGPD.md
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Signalement d'un contenu illicite</h2>
|
||||
<p className="text-foreground/80">
|
||||
Conformément à l'article 6-I-7 de la LCEN, vous pouvez signaler tout
|
||||
contenu manifestement illicite par email à{" "}
|
||||
<a href="mailto:piron.antoine@gmail.com" className="underline text-primary">
|
||||
piron.antoine@gmail.com
|
||||
</a>{" "}
|
||||
ou en utilisant le bouton "Signaler" disponible sur chaque contribution.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 font-mono pt-4 border-t border-border/30">
|
||||
Dernière mise à jour : mai 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-10 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
|
||||
Politique de confidentialité
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Version 1.0 — mai 2026 · Responsable : Antoine Piron (billisdead)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-10 text-sm leading-relaxed">
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
En résumé
|
||||
</h2>
|
||||
<div className="bg-muted/30 border border-border/50 p-5 space-y-2 rounded-sm">
|
||||
<p className="text-foreground/90">
|
||||
Cette plateforme collecte uniquement ce qui est strictement nécessaire
|
||||
au fonctionnement du service.
|
||||
</p>
|
||||
<ul className="space-y-1 text-foreground/80">
|
||||
<li>→ Aucun compte utilisateur, aucune inscription</li>
|
||||
<li>→ Aucun cookie de tracking, aucune publicité</li>
|
||||
<li>→ Aucun transfert de données hors Union européenne</li>
|
||||
<li>→ Vos contributions sont supprimées après 24 mois</li>
|
||||
<li>→ Vous pouvez demander la suppression à tout moment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Qui traite vos données ?
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Responsable de traitement : Antoine Piron (billisdead), France.
|
||||
Contact : <a href="mailto:piron.antoine@gmail.com" className="underline text-primary">piron.antoine@gmail.com</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Quelles données sont collectées ?
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 pr-4 font-mono text-xs uppercase tracking-wider text-muted-foreground">Donnée</th>
|
||||
<th className="text-left py-2 pr-4 font-mono text-xs uppercase tracking-wider text-muted-foreground">Collectée ?</th>
|
||||
<th className="text-left py-2 font-mono text-xs uppercase tracking-wider text-muted-foreground">Pourquoi ?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{[
|
||||
{ d: "Votre contribution (texte)", c: true, r: "Afficher et synthétiser les propositions" },
|
||||
{ d: "Pseudonyme", c: true, r: "Facultatif — affiché avec votre contribution" },
|
||||
{ d: "Date et heure", c: true, r: "Traçabilité, affichage dans l'interface" },
|
||||
{ d: "Hash technique anti-abus", c: true, r: "Protection contre les bots — non réversible, non-PII" },
|
||||
{ d: "Horodatage du consentement", c: true, r: "Preuve légale de votre accord (art. 7.1 RGPD)" },
|
||||
{ d: "Adresse IP", c: false, r: "Non stockée — utilisée temporairement pour le rate limiting" },
|
||||
{ d: "Cookies de suivi", c: false, r: "Aucun tracker tiers" },
|
||||
{ d: "Compte utilisateur", c: false, r: "Aucune inscription requise" },
|
||||
{ d: "Géolocalisation", c: false, r: "—" },
|
||||
].map(row => (
|
||||
<tr key={row.d}>
|
||||
<td className="py-2.5 pr-4 font-medium">{row.d}</td>
|
||||
<td className="py-2.5 pr-4">
|
||||
{row.c
|
||||
? <span className="text-amber-700 font-semibold">Oui</span>
|
||||
: <span className="text-green-700 font-semibold">Non</span>
|
||||
}
|
||||
</td>
|
||||
<td className="py-2.5 text-foreground/70">{row.r}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Vos opinions politiques — donnée sensible
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Les contributions peuvent contenir vos opinions politiques, qui sont des{" "}
|
||||
<strong>données sensibles</strong> au sens de l'article 9 du RGPD.
|
||||
Leur traitement est autorisé uniquement avec votre{" "}
|
||||
<strong>consentement explicite</strong>, que vous donnez en cochant
|
||||
la case avant votre première contribution.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Vous pouvez retirer ce consentement à tout moment. Le retrait n'affecte
|
||||
pas les traitements déjà effectués, mais vous pouvez demander la
|
||||
suppression de vos contributions (voir § Vos droits).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Intelligence artificielle (Mistral AI)
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Votre contribution est transmise à{" "}
|
||||
<strong>Mistral AI (Paris, France)</strong> pour modération automatique.
|
||||
Si elle est acceptée, elle est également transmise avec l'ensemble des
|
||||
contributions acceptées pour générer la synthèse collective.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Mistral AI est un sous-traitant RGPD au sens de l'article 28.
|
||||
Son infrastructure est hébergée exclusivement en Union européenne
|
||||
(Pays-Bas). Aucun transfert hors UE n'est effectué.
|
||||
Mistral AI ne conserve pas les données pour entraîner ses modèles.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Les instructions précises données à l'IA sont publiques :{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs/PROMPTS_IA.md
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Durée de conservation
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Vos contributions sont conservées pendant <strong>24 mois</strong> après la soumission,
|
||||
puis supprimées ou anonymisées. Le pseudonyme est supprimé en priorité si vous
|
||||
en avez fourni un.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Vos droits
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Conformément au RGPD (articles 15 à 22), vous disposez des droits suivants.
|
||||
Pour les exercer, envoyez un email à{" "}
|
||||
<a href="mailto:piron.antoine@gmail.com" className="underline text-primary">
|
||||
piron.antoine@gmail.com
|
||||
</a>{" "}
|
||||
avec l'objet "Demande RGPD".
|
||||
</p>
|
||||
<ul className="space-y-2 text-foreground/80">
|
||||
{[
|
||||
{ titre: "Accès", desc: "Obtenir la liste de vos contributions et des données associées." },
|
||||
{ titre: "Rectification", desc: "Corriger un pseudonyme ou une erreur dans vos données." },
|
||||
{ titre: "Effacement", desc: "Faire supprimer vos contributions (avec la date approximative)." },
|
||||
{ titre: "Opposition", desc: "Vous opposer au traitement basé sur l'intérêt légitime (hash technique)." },
|
||||
{ titre: "Portabilité", desc: "Obtenir vos contributions en format JSON ou CSV." },
|
||||
{ titre: "Retrait du consentement", desc: "Annuler votre accord pour les données sensibles (opinions politiques). Les contributions publiées restent visibles sauf demande d'effacement complémentaire." },
|
||||
].map(r => (
|
||||
<li key={r.titre} className="flex gap-3">
|
||||
<span className="font-semibold text-foreground/90 flex-shrink-0 w-36">{r.titre}</span>
|
||||
<span>{r.desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-foreground/70 text-xs">
|
||||
Délai de réponse : 1 mois (prolongeable à 3 mois).
|
||||
En cas de désaccord, vous pouvez saisir la{" "}
|
||||
<a href="https://www.cnil.fr/fr/plaintes" className="underline text-primary" rel="noopener noreferrer">
|
||||
CNIL
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Document complet
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Le document de conformité RGPD complet (registre des traitements, bases légales,
|
||||
sous-traitants, durées de conservation) est disponible en open source :{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/RGPD.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs/RGPD.md
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 font-mono pt-4 border-t border-border/30">
|
||||
Version 1.0 — mai 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user