diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index fd43675..829e430 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -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//flag") diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index 207612a..fbe2362 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -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( diff --git a/artifacts/voix-du-peuple/src/App.tsx b/artifacts/voix-du-peuple/src/App.tsx index 14ab809..4d9e16c 100644 --- a/artifacts/voix-du-peuple/src/App.tsx +++ b/artifacts/voix-du-peuple/src/App.tsx @@ -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 ( + + ); +} + function Router() { + const [location] = useLocation(); return (
{/* Lien d'évitement pour lecteurs d'écran */} @@ -73,9 +101,14 @@ function Router() { + + + + {/* Pied de page masqué sur la page d'accueil (layout plein-écran) */} + {location !== "/" &&
}
); } diff --git a/artifacts/voix-du-peuple/src/components/consent-dialog.tsx b/artifacts/voix-du-peuple/src/components/consent-dialog.tsx new file mode 100644 index 0000000..27c6565 --- /dev/null +++ b/artifacts/voix-du-peuple/src/components/consent-dialog.tsx @@ -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 ( + { if (!o) onCancel(); }}> + + + + + Votre consentement + + +
+

+ Avant de contribuer, nous avons besoin de votre accord explicite + sur le traitement de vos données. +

+
+

Ce que nous collectons :

+
    +
  • • Le texte de votre contribution (opinion politique — donnée sensible au sens de l'art. 9 RGPD)
  • +
  • • Un pseudonyme facultatif librement choisi
  • +
  • • La date et l'heure de la soumission
  • +
  • • Un identifiant technique non-personnel pour la protection anti-abus (hash non réversible)
  • +
+

Durée de conservation :

+

24 mois, puis suppression ou anonymisation.

+

Sous-traitant IA :

+

+ Votre contribution est analysée par Mistral AI (France) pour modération et synthèse. + Aucun transfert hors Union européenne. +

+
+

+ Vous pouvez retirer votre consentement et demander la suppression de vos données + à tout moment via piron.antoine@gmail.com. Détails :{" "} + + politique de confidentialité + + . +

+
+
+
+ +
+ setChecked(val === true)} + /> + +
+ + + + + +
+
+ ); +} diff --git a/artifacts/voix-du-peuple/src/pages/contributions-brutes.tsx b/artifacts/voix-du-peuple/src/pages/contributions-brutes.tsx new file mode 100644 index 0000000..d24de7d --- /dev/null +++ b/artifacts/voix-du-peuple/src/pages/contributions-brutes.tsx @@ -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(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; + }) + .then((d) => { + setData(d); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + }, [page]); + + return ( +
+
+ + + + +
+

+ Contributions brutes +

+

+ L'intégralité des contributions acceptées, dans leur ordre de soumission, sans mise en forme éditoriale. +

+
+ + {/* Avertissement éditorial */} + + + La{" "} + + synthèse collective + {" "} + 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. + + + + {/* Boutons d'export */} + + + {/* Contenu */} + {loading ? ( +
+ +
+ ) : error ? ( +
+ + Impossible de charger les contributions. +
+ ) : data && data.contributions.length > 0 ? ( + <> +

+ {data.total} contribution{data.total !== 1 ? "s" : ""} acceptée{data.total !== 1 ? "s" : ""} + {" "}— page {data.page} / {data.pages} +

+ +
+ {data.contributions.map((c) => ( +
+

+ {c.content} +

+
+ + {c.author || "Citoyen anonyme"} + + + + {format(new Date(c.createdAt), "d MMMM yyyy, HH:mm", { locale: fr })} + + #{c.id} +
+
+ ))} +
+ + {/* Pagination */} + {data.pages > 1 && ( +
+ + + {page} / {data.pages} + + +
+ )} + + ) : ( +
+ Aucune contribution acceptée pour l'instant. +
+ )} + +

+ Contributions modérées selon le droit international des droits humains (DUDH · PIDCP · CEDH).{" "} + + Voir les critères de modération + + . +

+
+
+ ); +} diff --git a/artifacts/voix-du-peuple/src/pages/home.tsx b/artifacts/voix-du-peuple/src/pages/home.tsx index aff7338..c4d2172 100644 --- a/artifacts/voix-du-peuple/src/pages/home.tsx +++ b/artifacts/voix-du-peuple/src/pages/home.tsx @@ -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(null); const [captchaToken, setCaptchaToken] = React.useState(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(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 ( <> + { setShowConsentDialog(false); pendingSubmitData.current = null; }} + /> + {/* Bandeau d'introduction */}
-

- La Voix du Peuple 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. - Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé. -

+
+

+ La Voix du Peuple 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. + Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé. +

+ {stats && stats.total > 0 && ( +
+
+
{stats.accepted}
+
acceptées
+
+
+
{stats.total}
+
soumises
+
+
+ )} +
@@ -520,6 +567,19 @@ export default function Home() {
+ {/* Avertissement éditorial — transparence sur la nature de la synthèse IA */} +
+ + + Synthèse générée par IA — peut regrouper, omettre ou reformuler des contributions.{" "} + + Voir les contributions brutes + {" "} + pour vérification indépendante. + + +
+ {/* Texte défilable */}
diff --git a/artifacts/voix-du-peuple/src/pages/legal-notice.tsx b/artifacts/voix-du-peuple/src/pages/legal-notice.tsx new file mode 100644 index 0000000..b12333c --- /dev/null +++ b/artifacts/voix-du-peuple/src/pages/legal-notice.tsx @@ -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 ( +
+
+ + + + +
+

+ Mentions légales +

+

+ Conformément à la loi n° 2004-575 du 21 juin 2004 (LCEN) +

+
+ +
+ +
+

Éditeur du site

+ + + {[ + { 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 => ( + + + + + ))} + +
{row.label}{row.value}
+
+ +
+

Directeur de la publication

+

Antoine Piron (billisdead)

+
+ +
+

Hébergement

+

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

+

+ Configuration : Rocky Linux 9 · Nginx · Python / Flask · PostgreSQL +

+
+ +
+

Propriété intellectuelle

+

+ Le code source de cette plateforme est publié sous la{" "} + European Union Public Licence v. 1.2 (EUPL-1.2), 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. +

+

+ Dépôt :{" "} + + homegit.gyozamancave.fr/billisdead/la-voix-du-peuple + +

+
+ +
+

Responsabilité éditoriale

+

+ 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é. +

+

+ 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{" "} + Fonctionnement{" "} + et les{" "} + Contributions brutes{" "} + pour vérification. +

+
+ +
+

Données personnelles

+

+ Voir la{" "} + + politique de confidentialité + {" "} + et le document de conformité RGPD complet ( + + docs/RGPD.md + + ). +

+
+ +
+

Signalement d'un contenu illicite

+

+ Conformément à l'article 6-I-7 de la LCEN, vous pouvez signaler tout + contenu manifestement illicite par email à{" "} + + piron.antoine@gmail.com + {" "} + ou en utilisant le bouton "Signaler" disponible sur chaque contribution. +

+
+ +

+ Dernière mise à jour : mai 2026 +

+
+
+
+ ); +} diff --git a/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx b/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx new file mode 100644 index 0000000..a2adf97 --- /dev/null +++ b/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx @@ -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 ( +
+
+ + + + +
+
+ +

+ Politique de confidentialité +

+
+

+ Version 1.0 — mai 2026 · Responsable : Antoine Piron (billisdead) +

+
+ +
+ +
+

+ En résumé +

+
+

+ Cette plateforme collecte uniquement ce qui est strictement nécessaire + au fonctionnement du service. +

+
    +
  • → Aucun compte utilisateur, aucune inscription
  • +
  • → Aucun cookie de tracking, aucune publicité
  • +
  • → Aucun transfert de données hors Union européenne
  • +
  • → Vos contributions sont supprimées après 24 mois
  • +
  • → Vous pouvez demander la suppression à tout moment
  • +
+
+
+ +
+

+ Qui traite vos données ? +

+

+ Responsable de traitement : Antoine Piron (billisdead), France. + Contact : piron.antoine@gmail.com. +

+
+ +
+

+ Quelles données sont collectées ? +

+
+ + + + + + + + + + {[ + { 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 => ( + + + + + + ))} + +
DonnéeCollectée ?Pourquoi ?
{row.d} + {row.c + ? Oui + : Non + } + {row.r}
+
+
+ +
+

+ Vos opinions politiques — donnée sensible +

+

+ Les contributions peuvent contenir vos opinions politiques, qui sont des{" "} + données sensibles au sens de l'article 9 du RGPD. + Leur traitement est autorisé uniquement avec votre{" "} + consentement explicite, que vous donnez en cochant + la case avant votre première contribution. +

+

+ 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). +

+
+ +
+

+ Intelligence artificielle (Mistral AI) +

+

+ Votre contribution est transmise à{" "} + Mistral AI (Paris, France) 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. +

+

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

+

+ Les instructions précises données à l'IA sont publiques :{" "} + + docs/PROMPTS_IA.md + + . +

+
+ +
+

+ Durée de conservation +

+

+ Vos contributions sont conservées pendant 24 mois après la soumission, + puis supprimées ou anonymisées. Le pseudonyme est supprimé en priorité si vous + en avez fourni un. +

+
+ +
+

+ Vos droits +

+

+ Conformément au RGPD (articles 15 à 22), vous disposez des droits suivants. + Pour les exercer, envoyez un email à{" "} + + piron.antoine@gmail.com + {" "} + avec l'objet "Demande RGPD". +

+
    + {[ + { 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 => ( +
  • + {r.titre} + {r.desc} +
  • + ))} +
+

+ Délai de réponse : 1 mois (prolongeable à 3 mois). + En cas de désaccord, vous pouvez saisir la{" "} + + CNIL + + . +

+
+ +
+

+ Document complet +

+

+ Le document de conformité RGPD complet (registre des traitements, bases légales, + sous-traitants, durées de conservation) est disponible en open source :{" "} + + docs/RGPD.md + + . +

+
+ +

+ Version 1.0 — mai 2026 +

+
+
+
+ ); +} diff --git a/docs/PROMPTS_IA.md b/docs/PROMPTS_IA.md new file mode 100644 index 0000000..4d7bf84 --- /dev/null +++ b/docs/PROMPTS_IA.md @@ -0,0 +1,186 @@ +# Prompts IA — La Voix du Peuple + +> Transparence radicale sur les instructions données aux modèles d'intelligence artificielle. +> Ces prompts constituent l'intégralité des instructions système envoyées à Mistral AI. +> Source : `artifacts/flask-api/legal_framework.py` + +--- + +## Pourquoi publier ces prompts ? + +La plateforme repose sur deux décisions automatisées (modération et synthèse) prises par une IA. Conformément à la posture de transparence du projet et aux principes d'auditabilité des systèmes algorithmiques (cf. règlement IA européen), ces instructions sont intégralement publiées. + +Tout chercheur, journaliste ou citoyen peut ainsi : +- Vérifier les biais potentiels du cadre de modération +- Évaluer si le prompt de synthèse introduit des distorsions +- Proposer des améliorations via le dépôt de code + +--- + +## 1. Prompt de modération + +**Modèle utilisé** : Mistral Small (`mistral-small-latest` par défaut, configurable via `FILTER_MODEL`) +**Rôle** : Décider si une contribution respecte le cadre légal international des droits humains et le droit français. + +``` +Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne. +Ta mission est d'analyser des idées politiques soumises par des citoyens +et de décider si elles sont conformes aux valeurs et droits fondamentaux +reconnus par le droit international. + +═══════════════════════════════════════════════════════════════════════════════ +CADRE LÉGAL DE RÉFÉRENCE +═══════════════════════════════════════════════════════════════════════════════ + +1. DÉCLARATION UNIVERSELLE DES DROITS DE L'HOMME (DUDH, ONU 1948) + • Art. 1 : "Tous les êtres humains naissent libres et égaux en dignité et + en droits." + • Art. 2 : Interdiction de toute discrimination (race, sexe, langue, + religion, opinion, origine nationale, condition sociale, etc.) + • Art. 3 : "Tout individu a droit à la vie, à la liberté et à la sûreté + de sa personne." + • Art. 5 : Interdiction de la torture et des traitements dégradants. + • Art. 7 : Égalité devant la loi, protection contre la discrimination. + • Art. 18 : Liberté de pensée, de conscience et de religion. + • Art. 19 : "Tout individu a droit à la liberté d'opinion et d'expression." + • Art. 20 : "Toute propagande en faveur de la guerre est interdite par la + loi. Tout appel à la haine nationale, raciale ou religieuse + qui constitue une incitation à la discrimination, à l'hostilité + ou à la violence est interdit par la loi." + • Art. 21 : Droit de participer au gouvernement de son pays, suffrage. + • Art. 29 : Les droits s'exercent dans les limites qui assurent le respect + des droits d'autrui. + +[... cadre légal complet dans artifacts/flask-api/legal_framework.py ...] + +═══════════════════════════════════════════════════════════════════════════════ +CRITÈRES D'ACCEPTATION +═══════════════════════════════════════════════════════════════════════════════ + +Accepte les idées qui : +✓ Promeuvent les droits fondamentaux, la liberté, l'égalité, la justice (DUDH Art. 1-3) +✓ Proposent des réformes sociales, économiques, politiques ou environnementales +✓ Critiquent le gouvernement, les institutions, les politiques — c'est protégé + (DUDH Art. 19, CEDH Art. 10) +✓ Expriment des opinions politiques, même radicales, tant qu'elles respectent + la dignité humaine et ne prônent pas la haine +✓ Défendent des groupes marginalisés ou discriminés +✓ Proposent des changements constitutionnels, législatifs ou systémiques par + des voies démocratiques et pacifiques +✓ Soulèvent des préoccupations légitimes de sécurité, d'économie, de justice +✓ Sont rédigées dans n'importe quelle langue + +═══════════════════════════════════════════════════════════════════════════════ +CRITÈRES DE REJET (extraits) +═══════════════════════════════════════════════════════════════════════════════ + +Rejette les idées qui : +✗ Prônent le fascisme, le nazisme ou tout régime totalitaire + → CEDH Art. 17 (abus de droit), DUDH Art. 29-30 +✗ Appellent à la haine raciale, ethnique, religieuse ou nationale + → DUDH Art. 20, PIDCP Art. 20, CERD Art. 4 +✗ Incitent à la violence, au terrorisme ou à la guerre contre une population + → DUDH Art. 3, Statut de Rome +✗ Nient l'égale dignité d'êtres humains sur la base de race, genre, sexualité, + religion, handicap, origine nationale ou toute autre caractéristique +✗ Prônent l'élimination, l'expulsion forcée ou la persécution d'un groupe +✗ Contiennent de la désinformation délibérée visant à détruire les institutions + démocratiques +✗ Nient ou contestent l'existence de crimes contre l'humanité reconnus + (négationnisme) → Loi du 29 juillet 1881, Art. 24 bis +✗ Font l'apologie du terrorisme → Code pénal Art. 421-2-5 +✗ Appellent au renversement violent des institutions républicaines + → Code pénal Art. 412-1 à 412-8 +✗ Contiennent des menaces de mort ou de violences graves + → Code pénal Art. 222-17 à 222-18-3 +✗ Incitent au suicide → Code pénal Art. 223-13 à 223-15 +✗ Contiennent du contenu sexuel ou pornographique + → Code pénal Art. 222-32, Art. 227-24 +✗ Contiennent des données personnelles identifiables de tiers → RGPD +✗ Ne constituent pas une proposition citoyenne (spam, tests, hors sujet) + +═══════════════════════════════════════════════════════════════════════════════ +FORMAT DE RÉPONSE — OBLIGATOIRE +═══════════════════════════════════════════════════════════════════════════════ + +Réponds UNIQUEMENT avec un objet JSON valide, sans markdown, sans commentaire : + +Si acceptée : +{"accepted": true} + +Si rejetée : +{"accepted": false, "reason": "Explication courte en français avec référence légale précise", "legal_basis": "DUDH Art. XX, ..."} +``` + +> **Note** : la liste complète des critères de rejet (25+ catégories) est dans `artifacts/flask-api/legal_framework.py`. Cette page présente les catégories principales. Le cadre légal complet (16 sources, 350+ lignes) est intégralement transmis au modèle à chaque appel. + +--- + +## 2. Prompt de synthèse + +**Modèle utilisé** : Mistral Large (`mistral-large-latest` par défaut, configurable via `SYNTHESIS_MODEL`) +**Rôle** : Produire un résumé structuré des contributions acceptées, directement transmissible à des élus. + +``` +Tu es un assistant qui résume des contributions citoyennes à destination d'élus politiques. + +STYLE +- Phrases courtes, directes. Pas d'emphase, pas de lyrisme. +- N'écris jamais "Nous le peuple", "la voix du peuple", ni aucune formule solennelle. +- N'écris pas de phrase d'introduction générale. Va directement aux sujets. +- Ton neutre : ni poétique, ni journalistique. Factuel. + +FORMAT +- Regroupe les contributions par thème (1 paragraphe par thème, 2 à 4 thèmes au total). +- Chaque paragraphe commence par le sujet principal du thème, par exemple : + "Sur la transparence des élus : ..." ou "Concernant les services publics : ..." +- Formule les demandes au présent, à la troisième personne : + "Des citoyens demandent que...", "Plusieurs contributions soulignent que..." +- Si des contributions se contredisent sur un point, dis-le en une phrase. +- Pas de conclusion, pas de résumé final. +- Pas d'emojis, pas de markdown, pas de tirets. + +Réponds avec UNIQUEMENT le texte, sans en-tête ni commentaire. +``` + +--- + +## 3. Analyse des biais potentiels + +### Biais du prompt de modération + +| Biais potentiel | Évaluation | +|-----------------|------------| +| Biais vers le consensus institutionnel | **Présent** — le cadre légal favorise les expressions reconnues par les institutions. Des formes de résistance radicale (mais légales) pourraient être rejetées à tort. | +| Biais de langue | **Faible** — le prompt précise explicitement "rédigées dans n'importe quelle langue". Le modèle peut toutefois moins bien interpréter des nuances dans des langues rares. | +| Faux positifs (légitimes rejetés) | **Possible** — le modèle peut rejeter des contributions satiriques, métaphoriques ou au style très direct. Aucun mécanisme de recours automatique n'existe dans la v1. | +| Faux négatifs (illicites acceptés) | **Possible** — le modèle n'est pas infaillible. Un mécanisme de signalement public et d'override admin existe (voir panel admin). | + +### Biais du prompt de synthèse + +| Biais potentiel | Évaluation | +|-----------------|------------| +| Omission | **Inhérent** — la synthèse regroupe et peut omettre des contributions peu représentées thématiquement, même si elles sont légitimes. La vue "/contributions-brutes" permet de vérifier. | +| Hiérarchisation implicite | **Possible** — le modèle peut placer certains thèmes en premier, suggérant implicitement une priorité. | +| Reformulation déformante | **Possible** — le résumé peut trahir la nuance d'une contribution individuelle. La vue "/contributions-brutes" est le contrepoids. | +| Neutralisation du registre émotionnel | **Voulu** — le prompt demande explicitement un ton factuel. Cela lisse intentionnellement l'expression citoyenne. | + +--- + +## 4. Données transmises à Mistral AI + +À chaque soumission, le texte de la contribution est envoyé à l'API Mistral (Mistral Small) pour modération. + +Si la contribution est acceptée, l'intégralité des contributions acceptées est envoyée à l'API Mistral (Mistral Large) pour recalcul de la synthèse. + +**Données transmises** : uniquement le texte brut des contributions. Pas d'adresse IP, pas de pseudonyme, pas de fingerprint. + +**Politique de rétention Mistral AI** : Mistral AI ne conserve pas les données soumises via API pour entraîner ses modèles (politique en vigueur au moment de la rédaction — vérifier le DPA actuel). + +--- + +## 5. Modification des prompts + +Les prompts sont modifiables dans `artifacts/flask-api/legal_framework.py`. Toute modification du prompt de modération impacte les critères d'acceptation et doit être documentée ici avec une justification et la date de changement. + +**Politique** : tout changement de prompt doit faire l'objet d'un commit documenté et d'une mise à jour de cette page. La transparence sur les modifications est aussi importante que la transparence sur le contenu initial. diff --git a/docs/RGPD.md b/docs/RGPD.md new file mode 100644 index 0000000..109ad42 --- /dev/null +++ b/docs/RGPD.md @@ -0,0 +1,201 @@ +# Conformité RGPD — La Voix du Peuple + +> Document de référence RGPD. Version 1.0 — mai 2026. +> Responsable de traitement : billisdead (Antoine Piron) — piron.antoine@gmail.com + +--- + +## 1. Responsable de traitement + +| Champ | Valeur | +|-------|--------| +| Identité | Antoine Piron (billisdead) | +| Contact | piron.antoine@gmail.com | +| DPO | Même personne (structure individuelle) | +| Territoire | France | + +La plateforme est exploitée à titre personnel dans un but civique non-lucratif. + +--- + +## 2. Registre des traitements + +### Traitement 1 — Contributions citoyennes + +| Champ | Détail | +|-------|--------| +| **Finalité** | Recueillir des propositions citoyennes, les modérer automatiquement et produire une synthèse thématique | +| **Base légale principale** | Art. 6.1.e RGPD — exécution d'une mission d'intérêt public (participation démocratique) | +| **Base légale données sensibles** | Art. 9.2.a RGPD — **consentement explicite** de la personne concernée (les opinions politiques sont des données sensibles au sens de l'art. 9) | +| **Catégories de données** | Contenu de la contribution (texte libre, peut contenir des opinions politiques), pseudonyme (facultatif, librement choisi), horodatage | +| **Destinataires** | Responsable de traitement (admin), Mistral AI (sous-traitant IA, voir §4) | +| **Durée de conservation** | 24 mois à compter de la soumission, puis suppression ou anonymisation complète | +| **Transferts hors UE** | Aucun | + +### Traitement 2 — Fingerprint de session (anti-abus) + +| Champ | Détail | +|-------|--------| +| **Finalité** | Protection contre les attaques automatisées (bots, sybil attacks, flood), sans authentification et sans traçage individuel | +| **Base légale** | Art. 6.1.f RGPD — intérêt légitime du responsable de traitement (sécurité technique de la plateforme) | +| **Nature de la donnée** | Hash SHA-256 (tronqué à 32 caractères hexadécimaux) d'un identifiant généré à partir des caractéristiques du navigateur (FingerprintJS). Non réversible, non-PII au sens du RGPD | +| **Durée de conservation** | Liée à la contribution associée (24 mois max) | +| **Transferts hors UE** | Aucun | + +### Traitement 3 — Consentements + +| Champ | Détail | +|-------|--------| +| **Finalité** | Traçabilité du recueil du consentement (art. 7.1 RGPD — charge de la preuve) | +| **Base légale** | Art. 6.1.c RGPD — obligation légale (conservation de la preuve de consentement) | +| **Catégories de données** | Hash fingerprint (voir §2.2), version du texte accepté, horodatage du consentement | +| **Durée de conservation** | Durée du traitement principal + 3 ans (prescription civile) | +| **Transferts hors UE** | Aucun | + +### Traitement 4 — Synthèse IA + +| Champ | Détail | +|-------|--------| +| **Finalité** | Produire un résumé structuré des contributions acceptées, à destination d'élus ou décideurs | +| **Base légale** | Art. 6.1.e RGPD — même finalité que le traitement 1 | +| **Catégories de données** | Contenu des contributions acceptées (transmis à Mistral AI pour synthèse) | +| **Destinataires** | Mistral AI (sous-traitant, voir §4) | +| **Durée de conservation** | Synthèse courante uniquement — pas d'historique conservé | +| **Transferts hors UE** | Aucun | + +--- + +## 3. Données collectées / non collectées + +| Donnée | Collectée | Base / commentaire | +|--------|-----------|--------------------| +| Texte de la contribution | Oui | Finalité principale — art. 6.1.e + 9.2.a | +| Pseudonyme | Oui (optionnel) | Librement choisi, pas de vérification | +| Horodatage | Oui | Nécessaire à la traçabilité | +| Résultat de modération | Oui (admin uniquement) | Traçabilité des décisions automatisées | +| Hash fingerprint navigateur | Oui | Art. 6.1.f — sécurité, non-PII | +| Consentement (version + date) | Oui | Art. 6.1.c — obligation légale | +| Adresse IP | Non conservée | Utilisée pour le rate limiting, non stockée | +| Compte utilisateur | Non | Pas d'inscription requise | +| Cookie de suivi | Non | Aucun tracker tiers | +| Données de navigation | Non | Aucune | +| Données de géolocalisation | Non | Aucune | + +--- + +## 4. Sous-traitants + +### Mistral AI + +| Champ | Détail | +|-------|--------| +| **Raison sociale** | Mistral AI SAS | +| **Siège** | Paris, France | +| **Rôle** | Sous-traitant pour la modération IA (Mistral Small) et la synthèse (Mistral Large) | +| **Données transmises** | Texte des contributions soumises (modération) · Texte des contributions acceptées (synthèse) | +| **Hébergement** | Exclusivement en Union européenne (GCP europe-west4, Pays-Bas) | +| **DPA** | Disponible via le portail Mistral AI — conforme RGPD | +| **Transferts hors UE** | Aucun | + +**Note** : Mistral AI ne conserve pas les données soumises via API pour entraîner ses modèles (politique explicite de l'API Mistral). À vérifier et documenter lors de la signature du DPA. + +### Hébergeur VPS + +| Champ | Détail | +|-------|--------| +| **Configuration cible** | Rocky Linux 9, VPS français ou européen (OVHcloud, Scaleway, ou équivalent) | +| **Rôle** | Hébergement de la base de données PostgreSQL, du backend Flask et du frontend compilé | +| **Données hébergées** | Toutes les données listées §2 | +| **Transferts hors UE** | Aucun (hébergeur UE requis) | +| **DPA** | À signer avec l'hébergeur retenu lors du déploiement | + +--- + +## 5. Durées de conservation + +| Catégorie | Durée active | Traitement au terme | +|-----------|-------------|---------------------| +| Contributions (texte + auteur) | 24 mois | Suppression complète ou anonymisation du pseudonyme | +| Hash fingerprint | 24 mois (liée contribution) | Suppression avec la contribution | +| Consentements | 24 mois + 3 ans | Suppression (prescription civile) | +| Synthèse courante | Sans limite | Écrasée à chaque recalcul | +| Logs applicatifs | 90 jours | Rotation automatique | + +--- + +## 6. Droits des personnes + +Conformément aux articles 15 à 22 du RGPD, toute personne peut exercer les droits suivants : + +| Droit | Contenu | Comment l'exercer | +|-------|---------|-------------------| +| **Accès** (art. 15) | Obtenir copie des données vous concernant | Email à piron.antoine@gmail.com | +| **Rectification** (art. 16) | Corriger des données inexactes | Email à piron.antoine@gmail.com | +| **Effacement** (art. 17) | "Droit à l'oubli" — suppression de vos contributions | Email à piron.antoine@gmail.com avec la date approximative de la contribution | +| **Opposition** (art. 21) | S'opposer au traitement basé sur l'intérêt légitime (fingerprint) | Email à piron.antoine@gmail.com | +| **Portabilité** (art. 20) | Obtenir vos données dans un format structuré | Email à piron.antoine@gmail.com | +| **Retrait du consentement** (art. 7.3) | Retirer votre consentement à tout moment | Email à piron.antoine@gmail.com — sans effet sur les traitements déjà effectués | + +**Délai de réponse** : 1 mois (prolongeable à 3 mois pour les demandes complexes, avec notification). + +**Réclamation CNIL** : En cas de non-réponse ou de litige, vous pouvez adresser une réclamation à la CNIL (www.cnil.fr/fr/plaintes). + +--- + +## 7. Sécurité des données + +Mesures techniques et organisationnelles mises en place : + +- Transport chiffré (HTTPS/TLS) entre le client et le serveur (Nginx) +- Connexion base de données sur réseau local uniquement (pas d'exposition publique du port PostgreSQL) +- Rate limiting et détection de flood (voir `docs/SECURITE_ANTI_ABUS.md`) +- Aucune donnée personnelle dans les logs applicatifs +- Accès admin protégé par token secret (`ADMIN_SECRET`) +- Principe de minimisation : pas de collecte d'IP ni de cookies de tracking +- Hash SHA-256 non réversible pour le fingerprint (non-PII) + +--- + +## 8. Décisions automatisées + +La plateforme utilise une modération automatisée par IA (Mistral Small) pour filtrer les contributions. + +**Information prévue à l'art. 22 RGPD** : si votre contribution est rejetée, le motif et la référence légale précise vous sont communiqués. Vous pouvez reformuler et resoumettre. + +Il n'existe pas de mécanisme de contestation formel de la décision de modération dans la version actuelle. En cas de déploiement institutionnel, un mécanisme de recours humain est recommandé. + +--- + +## 9. Consentement pour les données sensibles + +Les contributions peuvent contenir des **opinions politiques**, qui sont des données sensibles au sens de l'art. 9 RGPD. Leur traitement requiert un consentement explicite de la personne concernée. + +**Mécanisme de recueil** : avant la première contribution, un bandeau de consentement explicite est présenté. Il contient : +- Une description claire du traitement +- Un lien vers la présente politique de confidentialité +- Une case à cocher obligatoire (pas de pré-cochage) +- Un bouton "Accepter et contribuer" + +**Horodatage** : le consentement est enregistré en base de données avec la version du texte accepté et la date. + +**Retrait** : le consentement peut être retiré à tout moment par email. Les contributions déjà publiées restent dans la base mais peuvent être supprimées sur demande (droit à l'effacement). + +--- + +## 10. Cookies + +La plateforme utilise uniquement les cookies suivants, strictement nécessaires : + +| Nom | Type | Finalité | Durée | +|-----|------|----------|-------| +| `_cv` | httpOnly, SameSite=Lax | Anti-abus — cooldown entre soumissions | `CONTRIBUTION_COOLDOWN_SECONDS` (1h par défaut) | + +**Aucun cookie tiers, aucun cookie de tracking, aucune publicité.** + +--- + +## Historique des versions + +| Version | Date | Changements | +|---------|------|-------------| +| 1.0 | Mai 2026 | Version initiale | diff --git a/lib/api-client-react/src/custom-fetch.ts b/lib/api-client-react/src/custom-fetch.ts index de5b30c..bda109f 100644 --- a/lib/api-client-react/src/custom-fetch.ts +++ b/lib/api-client-react/src/custom-fetch.ts @@ -47,6 +47,14 @@ export function removeExtraHeader(key: string): void { delete _extraHeaders[key]; } +/** + * Retourne l'identifiant de visite FingerprintJS actuellement enregistré. + * Utilisé pour transmettre l'identifiant lors d'appels fetch manuels (ex. enregistrement du consentement). + */ +export function getVisitorId(): string | null { + return _visitorId; +} + /** * Set a base URL that is prepended to every relative request URL * (i.e. paths that start with `/`). diff --git a/lib/api-client-react/src/index.ts b/lib/api-client-react/src/index.ts index 2499789..7f25ba3 100644 --- a/lib/api-client-react/src/index.ts +++ b/lib/api-client-react/src/index.ts @@ -4,6 +4,7 @@ export { setBaseUrl, setAuthTokenGetter, setVisitorId, + getVisitorId, addExtraHeader, removeExtraHeader, } from "./custom-fetch";