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:
2026-05-23 22:30:30 +02:00
parent 45edc1fa77
commit a7b7684e87
12 changed files with 1354 additions and 50 deletions
+34 -1
View File
@@ -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>&bull;</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>
);
}
+109 -49
View File
@@ -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 §&nbsp;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>
);
}