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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user