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
+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">