From c0e06e61b68eace767eeedaf9d5e212f7d354a30 Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 1 May 2026 20:47:07 +0200 Subject: [PATCH] feat(legal): versioned terms acceptance ledger (CGU/CGV/mentions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.0.10 légal item 3. RGPD requires explicit re-acceptance of any terms-of-service-class document on material change. Adds a per-user, per-document, per-version ledger so disputes can be answered with evidence (timestamp + originating IP + user-agent). Backend * migrations/991_terms_acceptance.sql — table terms_acceptances with UNIQUE (user_id, terms_type, version) so re-accepts are idempotent. inet column for IP, varchar(512) for UA, both nullable for the internal seed paths. * internal/services/terms_service.go — TermsService : - CurrentTerms map (ISO date version per class) is the single source of truth ; bump on text edit. - CurrentVersions(userID) returns versions + the user's unaccepted set ; userID==Nil ⇒ versions only (anonymous OK). - Accept(userID, []AcceptInput) : validates each (type, version) against CurrentTerms (ErrTermsVersionMismatch on stale POST), writes one row per accept in a single transaction, idempotent via FirstOrCreate against the unique index. * internal/handlers/terms_handler.go — REST surface : - GET /api/v1/legal/terms/current (public, OptionalAuth) - POST /api/v1/legal/terms/accept (RequireAuth) - Captures IP via gin's ClientIP() (X-Forwarded-For-aware) and UA from the request, truncates UA to fit the column. * routes_legal.go — wires the two endpoints. `current` falls back to no-middleware when AuthMiddleware is nil so test rigs work. Frontend * features/legal/pages/{CGUPage,CGVPage,MentionsPage}.tsx — initial drafts with version constants matching the backend's CurrentTerms. Counsel review required before v2.0.0 (text is honest baseline, not finalised legal copy). * services/api/legalTerms.ts — fetchCurrentTerms() / acceptTerms() ; hand-written to keep the consent-modal wiring readable. * components/TermsAcceptanceModal.tsx — non-dismissable modal that opens on every authenticated session when the unaccepted set is non-empty. Per-document checkboxes + single submit ; refusal keeps the modal open (no decline-and-continue path because the legal contract requires acceptance to use the platform). * Mounted in App.tsx alongside CookieBanner ; both must overlay every screen. * Lazy-component registry + routes for /legal/{cgu,cgv,mentions}. Operator workflow when text changes : 1. Edit the text in the relevant page component. Bump the `*_VERSION` const in that file. 2. Bump CurrentTerms[*] in services/terms_service.go to the same value. 3. Deploy. Every existing user gets force-prompted on their next session ; new users prompted at registration. baseline checks : tsc 0 errors, eslint 754, go build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/App.tsx | 6 + .../src/components/TermsAcceptanceModal.tsx | 210 ++++++++++++++++ apps/web/src/components/ui/LazyComponent.tsx | 3 + .../src/components/ui/lazy-component/index.ts | 3 + .../ui/lazy-component/lazyExports.ts | 16 ++ apps/web/src/features/legal/pages/CGUPage.tsx | 228 ++++++++++++++++++ apps/web/src/features/legal/pages/CGVPage.tsx | 225 +++++++++++++++++ .../src/features/legal/pages/MentionsPage.tsx | 190 +++++++++++++++ apps/web/src/router/routeConfig.tsx | 6 + apps/web/src/services/api/legalTerms.ts | 47 ++++ veza-backend-api/internal/api/routes_legal.go | 41 +++- .../internal/handlers/terms_handler.go | 141 +++++++++++ .../internal/services/terms_service.go | 218 +++++++++++++++++ .../migrations/991_terms_acceptance.sql | 62 +++++ 14 files changed, 1391 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/TermsAcceptanceModal.tsx create mode 100644 apps/web/src/features/legal/pages/CGUPage.tsx create mode 100644 apps/web/src/features/legal/pages/CGVPage.tsx create mode 100644 apps/web/src/features/legal/pages/MentionsPage.tsx create mode 100644 apps/web/src/services/api/legalTerms.ts create mode 100644 veza-backend-api/internal/handlers/terms_handler.go create mode 100644 veza-backend-api/internal/services/terms_service.go create mode 100644 veza-backend-api/migrations/991_terms_acceptance.sql diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 7b2456658..80b5f2aa4 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { AstralBackground } from '@/components/ui/AstralBackground'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { CookieBanner } from '@/components/CookieBanner'; +import { TermsAcceptanceModal } from '@/components/TermsAcceptanceModal'; import { AppRouter } from '@/router'; import { csrfService } from '@/services/csrf'; import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts'; @@ -200,6 +201,11 @@ export function App() { once the user has decided ; persists for 13 months per CNIL guidance. */} + {/* v1.0.10 légal 3: terms-acceptance gate. Mounted alongside + CookieBanner because both must overlay every screen. + Self-hides when the user has accepted every current + version (or is anonymous). */} + {/* PWA Install Banner - Disabled for now as it is too intrusive */} {/* */} {/* Keyboard Shortcuts Panel (Discord-style overlay) */} diff --git a/apps/web/src/components/TermsAcceptanceModal.tsx b/apps/web/src/components/TermsAcceptanceModal.tsx new file mode 100644 index 000000000..bb65b018d --- /dev/null +++ b/apps/web/src/components/TermsAcceptanceModal.tsx @@ -0,0 +1,210 @@ +/** + * TermsAcceptanceModal — RGPD-compliant force-prompt for new terms versions. + * + * v1.0.10 légal item 3. Mounted in App.tsx alongside CookieBanner. + * On every authenticated session, the modal : + * + * 1. fetches GET /legal/terms/current + * 2. if `unaccepted` is non-empty, opens as a non-dismissable modal + * with checkboxes per document class + * 3. requires the user to tick every box and click "J'accepte" + * 4. POSTs the batch to /legal/terms/accept + * 5. closes itself ; the SPA continues normally + * + * If the user refuses, the modal stays open — there is no "decline and + * continue" path because the legal contract requires acceptance to use + * the platform. The user can sign out (clears the session) or close + * the tab. This is the same pattern banks / regulated services use. + * + * The fetch + post are a single round-trip each ; we don't poll. A + * version bump deployed mid-session takes effect on the next page + * reload, which is acceptable for v2.0.0. v1.1 may add a periodic + * re-check. + */ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + fetchCurrentTerms, + acceptTerms, + type TermsType, +} from '@/services/api/legalTerms'; +import { useAuthStore } from '@/features/auth/store/authStore'; +import { logger } from '@/utils/logger'; + +const TERMS_LABEL: Record = { + cgu: { title: "Conditions Générales d'Utilisation", href: '/legal/cgu' }, + cgv: { title: 'Conditions Générales de Vente', href: '/legal/cgv' }, + mentions: { title: 'Mentions légales', href: '/legal/mentions' }, +}; + +export function TermsAcceptanceModal() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const [unaccepted, setUnaccepted] = useState([]); + const [versions, setVersions] = useState>({ + cgu: '', + cgv: '', + mentions: '', + }); + const [checked, setChecked] = useState>({ + cgu: false, + cgv: false, + mentions: false, + }); + const [submitting, setSubmitting] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (!isAuthenticated) { + setUnaccepted([]); + return; + } + let cancelled = false; + fetchCurrentTerms() + .then((res) => { + if (cancelled) return; + setVersions(res.versions); + setUnaccepted(res.unaccepted ?? []); + }) + .catch((err) => { + // Network or backend error : fail open. Re-prompting on the + // next session is fine ; blocking the SPA on a 5xx would be + // worse UX than the rare missed acceptance. + logger.warn('TermsAcceptanceModal: failed to fetch current terms', { + error: err instanceof Error ? err.message : String(err), + }); + }); + return () => { + cancelled = true; + }; + }, [isAuthenticated]); + + if (unaccepted.length === 0) return null; + + const allChecked = unaccepted.every((tt) => checked[tt]); + + const onSubmit = async () => { + if (!allChecked || submitting) return; + setSubmitting(true); + setErrorMsg(null); + try { + await acceptTerms( + unaccepted.map((tt) => ({ terms_type: tt, version: versions[tt] })), + ); + setUnaccepted([]); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur réseau'; + setErrorMsg(msg); + logger.error('TermsAcceptanceModal: accept failed', { error: msg }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

+ Mise à jour de nos conditions +

+

+ Nous avons mis à jour les documents ci-dessous. Pour continuer à + utiliser Veza, veuillez les lire et confirmer votre acceptation. + Vous pourrez les retrouver à tout moment depuis le footer de la + plateforme. +

+
+ +
+ {unaccepted.map((tt) => { + const meta = TERMS_LABEL[tt]; + return ( + + ); + })} + + {errorMsg && ( +

+ {errorMsg} +

+ )} +
+ +
+ {/* eslint-disable-next-line no-restricted-syntax -- standalone modal must work without the design-system Button (mounted very early) */} + +
+
+
+ ); +} + +export default TermsAcceptanceModal; diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 9b846f529..c3a6bee19 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -55,5 +55,8 @@ export { LazyDmca, LazyDmcaNotice, LazyPrivacy, + LazyCGU, + LazyCGV, + LazyMentions, } from './lazy-component'; export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component'; diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index b8c55d06e..dda574340 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -57,4 +57,7 @@ export { LazyDmca, LazyDmcaNotice, LazyPrivacy, + LazyCGU, + LazyCGV, + LazyMentions, } from './lazyExports'; diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index 3acda7879..c873f1af3 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -375,3 +375,19 @@ export const LazyPrivacy = createLazyComponent( undefined, 'Privacy', ); +// Legal — CGU/CGV/Mentions versionnées (v1.0.10 légal 3) +export const LazyCGU = createLazyComponent( + () => import('@/features/legal/pages/CGUPage'), + undefined, + 'CGU', +); +export const LazyCGV = createLazyComponent( + () => import('@/features/legal/pages/CGVPage'), + undefined, + 'CGV', +); +export const LazyMentions = createLazyComponent( + () => import('@/features/legal/pages/MentionsPage'), + undefined, + 'Mentions', +); diff --git a/apps/web/src/features/legal/pages/CGUPage.tsx b/apps/web/src/features/legal/pages/CGUPage.tsx new file mode 100644 index 000000000..abf92347a --- /dev/null +++ b/apps/web/src/features/legal/pages/CGUPage.tsx @@ -0,0 +1,228 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +/** + * CGUPage — Conditions Générales d'Utilisation. + * + * v1.0.10 légal item 3 — initial draft. The version label here MUST + * match the constant in `internal/services/terms_service.go` + * (CurrentTerms[TermsTypeCGU]). When the text below is materially + * edited, bump both. The terms-acceptance ledger then force-prompts + * every existing user on their next session. + * + * This text is a reasonable baseline ; counsel should review before + * v2.0.0 to add jurisdiction-specific clauses (FR/EU primary, + * US secondary). + */ +export const CGU_VERSION = '2026-04-30'; + +export default function CGUPage() { + useEffect(() => { + document.title = "CGU — Veza"; + }, []); + + return ( +
+
+
+ + ← Veza + +

+ Conditions Générales d'Utilisation +

+

+ Version {CGU_VERSION} +

+
+ +
+
+

1. Objet

+

+ Les présentes Conditions Générales d'Utilisation (CGU) régissent l'usage + de la plateforme Veza (le « Service ») par tout utilisateur (« Utilisateur »). + L'inscription vaut acceptation pleine et entière des CGU dans leur version + en vigueur. Toute modification matérielle requiert une nouvelle acceptation + explicite à la connexion suivante. +

+
+ +
+

+ 2. Conditions d'inscription +

+
    +
  • Avoir au moins 16 ans révolus à la date d'inscription (RGPD).
  • +
  • + Disposer d'un email valide et accessible — la vérification est + obligatoire avant la première connexion. +
  • +
  • + Fournir des informations exactes (nom d'utilisateur, date de naissance). + Toute inscription frauduleuse est nulle et entraîne suppression du compte + sans préavis. +
  • +
  • + Un seul compte par personne. Les comptes multiples liés à des fins de + contournement (modération, fraude marketplace) sont sanctionnés. +
  • +
+
+ +
+

+ 3. Contenu publié par l'Utilisateur +

+

+ L'Utilisateur reste propriétaire des œuvres qu'il publie (pistes audio, + playlists, commentaires). Il accorde à Veza une licence non exclusive, + mondiale, gratuite et limitée à la durée d'usage du Service, pour stocker, + transcoder, diffuser et afficher ces œuvres dans le cadre des fonctionnalités + de la plateforme. +

+

+ L'Utilisateur garantit qu'il dispose des droits sur tout contenu publié et + s'engage à ne pas téléverser d'œuvres protégées sans autorisation. Veza + applique une procédure DMCA décrite à{' '} + + /legal/dmca + + . +

+
+ +
+

+ 4. Comportements interdits +

+
    +
  • Harcèlement, menaces, propos haineux ou discriminatoires.
  • +
  • Spam, scraping automatisé, contournement des limites de débit.
  • +
  • + Téléversement de malware, scripts, contenus exploitant des failles de + sécurité. +
  • +
  • + Usurpation d'identité, fausse revendication d'œuvres tiers, fraude au + paiement. +
  • +
  • + Contournement de la modération (compte multiple, VPN après ban). +
  • +
+
+ +
+

+ 5. Modération & sanctions +

+

+ Veza se réserve le droit de masquer, retirer ou restreindre l'accès à + tout contenu manifestement illicite ou contraire aux CGU. Les sanctions + incluent : avertissement, retrait de contenu, suspension temporaire, + suppression définitive. Un appel est possible via{' '} + support@veza.fr sous 30 jours. +

+
+ +
+

+ 6. Service marketplace +

+

+ Pour toute transaction commerciale (achat de licences, ventes + créateur, abonnements), les{' '} + + Conditions Générales de Vente + {' '} + s'appliquent en complément des présentes. +

+
+ +
+

+ 7. Disponibilité & responsabilité +

+

+ Le Service est fourni « en l'état », sans garantie de disponibilité + ininterrompue. Veza s'efforce d'assurer un uptime cible de 99,5 % en + annuel mais n'engage pas sa responsabilité en cas de perte de données + imputable à un cas de force majeure ou à un usage non conforme. +

+
+ +
+

+ 8. Données personnelles +

+

+ Le traitement des données personnelles est décrit dans la{' '} + + politique de confidentialité + + . L'Utilisateur peut exercer ses droits RGPD (accès, rectification, + effacement, portabilité, opposition) à privacy@veza.fr. +

+
+ +
+

+ 9. Modifications +

+

+ Toute modification des présentes CGU est notifiée à l'Utilisateur lors + de sa prochaine connexion par une fenêtre d'acceptation explicite. + Le refus d'accepter la nouvelle version empêche l'usage du Service. +

+
+ +
+

+ 10. Droit applicable +

+

+ Les présentes CGU sont régies par le droit français. Tout litige relève + de la compétence des juridictions françaises, sous réserve des règles + impératives en matière de consommation pour les Utilisateurs résidant + dans l'Union européenne. +

+
+
+ +
+

+ + CGV + {' '} + ·{' '} + + Mentions légales + {' '} + ·{' '} + + Politique de confidentialité + {' '} + ·{' '} + + DMCA + +

+
+
+
+ ); +} diff --git a/apps/web/src/features/legal/pages/CGVPage.tsx b/apps/web/src/features/legal/pages/CGVPage.tsx new file mode 100644 index 000000000..e8355a918 --- /dev/null +++ b/apps/web/src/features/legal/pages/CGVPage.tsx @@ -0,0 +1,225 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +/** + * CGVPage — Conditions Générales de Vente (marketplace). + * + * v1.0.10 légal item 3 — initial draft. Match the version constant + * in `internal/services/terms_service.go` (CurrentTerms[TermsTypeCGV]). + * Counsel review required before v2.0.0 for jurisdictions where Veza + * operates marketplace flows (FR/EU primary). + */ +export const CGV_VERSION = '2026-04-30'; + +export default function CGVPage() { + useEffect(() => { + document.title = 'CGV — Veza Marketplace'; + }, []); + + return ( +
+
+
+ + ← Veza + +

+ Conditions Générales de Vente +

+

+ Version {CGV_VERSION} · Marketplace Veza +

+
+ +
+
+

+ 1. Champ d'application +

+

+ Les présentes Conditions Générales de Vente (CGV) s'appliquent à toute + transaction commerciale réalisée sur la place de marché Veza : achat + de pistes audio, packs de samples, prestations de service entre + utilisateurs (« Acheteur » et « Vendeur »). Les CGV complètent les{' '} + + CGU + + . +

+
+ +
+

2. Parties

+
    +
  • + Vendeur : utilisateur disposant du rôle créateur + ayant complété son onboarding Stripe Connect (KYC). +
  • +
  • + Acheteur : tout utilisateur authentifié majeur ou + disposant d'un consentement parental conforme. +
  • +
  • + Veza : opérateur de la plateforme, intermédiaire + technique. Veza n'est pas vendeur de l'œuvre — la transaction est + directe Vendeur ↔ Acheteur ; Veza perçoit une commission sur la + vente. +
  • +
+
+ +
+

+ 3. Prix & commissions +

+

+ Le Vendeur fixe librement ses prix. La commission de Veza est de{' '} + 15 % HT du prix de vente affiché, prélevée + automatiquement au moment du transfert. Le solde est crédité sur le + compte Stripe Connect du Vendeur après confirmation du paiement par + le PSP. Les prix affichés à l'Acheteur sont TTC. +

+
+ +
+

+ 4. Paiement +

+

+ Les paiements sont traités par Hyperswitch / Stripe. Veza ne stocke + aucune donnée de carte bancaire. Devises supportées : EUR, USD, GBP. + Les frais de change éventuels sont à la charge de l'Acheteur. +

+
+ +
+

+ 5. Droit de rétractation +

+

+ Conformément à l'article L221-28 du Code de la consommation + (français), pour les contenus numériques fournis sur support + immatériel et téléchargés immédiatement, le droit de rétractation + de 14 jours ne s'applique pas dès lors que + l'Acheteur a expressément consenti à la fourniture immédiate et a + renoncé à ce droit. Veza affiche cette mention au moment du + checkout et requiert un opt-in explicite. +

+

+ Pour les pistes audio non encore téléchargées, l'Acheteur conserve + son droit de rétractation pendant 14 jours. +

+
+ +
+

+ 6. Remboursements +

+
    +
  • + Demande possible sous 14 jours calendaires après la + transaction. +
  • +
  • + Acceptée si : œuvre non livrée techniquement, fichier corrompu, + violation manifeste des droits par le Vendeur (DMCA confirmée). +
  • +
  • + Refusée si : insatisfaction subjective sur la qualité audio (le + préview pré-achat permet de décider), changement d'avis post- + téléchargement. +
  • +
  • + Procédé : POST /orders/:id/refund. Délai de remboursement bancaire : + 5 à 7 jours ouvrés selon le PSP. +
  • +
+
+ +
+

+ 7. Licences accordées +

+

+ L'achat d'une œuvre confère à l'Acheteur la licence définie sur la + fiche produit (streaming personnel, usage commercial, exclusivité, + etc.). Aucune licence n'autorise la redistribution sans accord + écrit complémentaire du Vendeur. Veza fournit un historique de + licence consultable depuis le profil Acheteur. +

+
+ +
+

+ 8. TVA & obligations fiscales +

+

+ Veza applique le régime de TVA OSS pour les ventes vers l'Union + européenne. Les Vendeurs assujettis fournissent leur numéro de TVA + intracommunautaire ; le calcul de la TVA finale est ajusté au pays + de l'Acheteur. Les Vendeurs sont responsables de leurs obligations + fiscales locales sur les revenus perçus via la plateforme. +

+
+ +
+

+ 9. Litiges entre Acheteur et Vendeur +

+

+ En cas de litige, les parties s'efforcent d'abord de trouver une + résolution amiable via la messagerie Veza. À défaut, un médiateur + de la consommation peut être saisi (coordonnées sur demande à + support@veza.fr). En dernier recours, les juridictions + françaises sont compétentes. +

+
+ +
+

+ 10. Modifications +

+

+ Toute modification matérielle des CGV est notifiée par fenêtre + d'acceptation explicite. Les transactions en cours sont régies par + la version applicable à la date de l'achat. +

+
+
+ +
+

+ + CGU + {' '} + ·{' '} + + Mentions légales + {' '} + ·{' '} + + Politique de confidentialité + {' '} + ·{' '} + + DMCA + +

+
+
+
+ ); +} diff --git a/apps/web/src/features/legal/pages/MentionsPage.tsx b/apps/web/src/features/legal/pages/MentionsPage.tsx new file mode 100644 index 000000000..2a1dcbc5a --- /dev/null +++ b/apps/web/src/features/legal/pages/MentionsPage.tsx @@ -0,0 +1,190 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +/** + * MentionsPage — Mentions légales / impressum. + * + * v1.0.10 légal item 3 — initial draft. Match the version constant in + * `internal/services/terms_service.go` (CurrentTerms[TermsTypeMentions]). + * Counsel review required before v2.0.0 — French Loi pour la Confiance + * dans l'Économie Numérique (LCEN) imposes specific minimum disclosures. + */ +export const MENTIONS_VERSION = '2026-04-30'; + +export default function MentionsPage() { + useEffect(() => { + document.title = 'Mentions légales — Veza'; + }, []); + + return ( +
+
+
+ + ← Veza + +

+ Mentions légales +

+

+ Version {MENTIONS_VERSION} +

+
+ +
+
+

+ Éditeur du site +

+

+ Veza
+ + (Forme juridique, capital social, RCS, n° SIREN — à compléter + avant v2.0.0) + +
+ Adresse postale : (à compléter) +
+ Email : contact@veza.fr +
+ Téléphone : (à compléter) +

+
+ +
+

+ Directeur de la publication +

+

+ + (Nom et fonction du directeur de la publication — à compléter) + +

+
+ +
+

+ Hébergement +

+

+ Le site est auto-hébergé sur infrastructure dédiée (Incus / R720 lab). +
+ Pour toute requête liée à l'hébergement : infra@veza.fr +

+
+ +
+

+ Propriété intellectuelle +

+

+ La structure générale du site, son design (SUMI design system) et les + composants techniques sont la propriété exclusive de Veza, à l'exception + des contributions tiers (logos partenaires, polices de caractères sous + licence) dont les droits restent à leurs détenteurs respectifs. +

+

+ Les œuvres publiées par les utilisateurs (pistes audio, playlists, + commentaires) restent leur propriété pleine et entière. Veza dispose + d'une licence d'exploitation limitée définie dans les{' '} + + CGU + + . +

+
+ +
+

+ Signalement de contenu illicite +

+

+ Conformément à l'article 6 de la LCEN, tout contenu manifestement + illicite peut être signalé à abuse@veza.fr ou via le + formulaire DMCA. + Veza traite les signalements sous 72 heures. +

+
+ +
+

+ Données personnelles +

+

+ Le traitement des données personnelles est régi par notre{' '} + + politique de confidentialité + + . Délégué à la protection des données :{' '} + privacy@veza.fr. +

+
+ +
+

+ Cookies +

+

+ Veza utilise des cookies strictement nécessaires (authentification, + sécurité) sans consentement préalable, et des cookies optionnels avec + opt-in via la bannière. Détails et choix dans la{' '} + + politique de confidentialité + + . +

+
+ +
+

+ Médiation de la consommation +

+

+ Conformément aux articles L.611-1 et suivants du Code de la consommation, + tout consommateur a la possibilité de saisir gratuitement un médiateur + en cas de litige non résolu après une réclamation préalable écrite à + support@veza.fr. +
+ + (Coordonnées du médiateur agréé — à compléter avant v2.0.0) + +

+
+
+ +
+

+ + CGU + {' '} + ·{' '} + + CGV + {' '} + ·{' '} + + Politique de confidentialité + {' '} + ·{' '} + + DMCA + +

+
+
+
+ ); +} diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index 7e2bc3eca..e8ba6c149 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -54,6 +54,9 @@ import { LazyDmca, LazyDmcaNotice, LazyPrivacy, + LazyCGU, + LazyCGV, + LazyMentions, } from '@/components/ui/LazyComponent'; const LazyPrototype = React.lazy(() => import('@/features/prototype/PrototypePage')); import { PublicRoute } from './PublicRoute'; @@ -122,6 +125,9 @@ export function getPublicStandaloneRoutes(): RouteEntry[] { { path: '/legal/dmca', element: }, { path: '/legal/dmca/notice', element: }, { path: '/legal/privacy', element: }, + { path: '/legal/cgu', element: }, + { path: '/legal/cgv', element: }, + { path: '/legal/mentions', element: }, ]; } diff --git a/apps/web/src/services/api/legalTerms.ts b/apps/web/src/services/api/legalTerms.ts new file mode 100644 index 000000000..0bf37f4e7 --- /dev/null +++ b/apps/web/src/services/api/legalTerms.ts @@ -0,0 +1,47 @@ +/** + * legalTerms — REST client for the v1.0.10 légal item 3 endpoints. + * + * Two endpoints : + * GET /api/v1/legal/terms/current → versions + per-user unaccepted set + * POST /api/v1/legal/terms/accept → record acceptance, capture IP+UA + * + * Kept as a hand-written service (rather than orval-generated) because + * the response shape is small and stable, and the SPA wires the + * acceptance modal directly off the unaccepted array — easier to read + * than an orval-wrapped React Query hook. + */ +import { apiClient } from './httpClient'; + +export type TermsType = 'cgu' | 'cgv' | 'mentions'; + +export interface CurrentVersionsResponse { + versions: Record; + unaccepted?: TermsType[]; +} + +export interface AcceptanceItem { + terms_type: TermsType; + version: string; +} + +/** + * Fetch the live versions + (when authenticated) the user's unaccepted + * set. Safe to call from anywhere ; the backend tolerates missing auth + * and just returns versions only. + */ +export async function fetchCurrentTerms(): Promise { + // The apiClient response interceptor unwraps the {success, data} + // envelope so r.data IS the inner payload (cf. + // services/api/orval-mutator.ts comment for context). Cast at the + // boundary instead of carrying the cast through every consumer. + const r = await apiClient.get('/legal/terms/current'); + return r.data as unknown as CurrentVersionsResponse; +} + +/** + * Submit one or more (type, version) acceptances. Idempotent on the + * backend — re-accepting the same tuple is a no-op. + */ +export async function acceptTerms(items: AcceptanceItem[]): Promise { + await apiClient.post('/legal/terms/accept', { acceptances: items }); +} diff --git a/veza-backend-api/internal/api/routes_legal.go b/veza-backend-api/internal/api/routes_legal.go index bed5a6dfb..aebdfa09d 100644 --- a/veza-backend-api/internal/api/routes_legal.go +++ b/veza-backend-api/internal/api/routes_legal.go @@ -7,13 +7,18 @@ import ( "veza-backend-api/internal/services" ) -// setupLegalRoutes registers DMCA + future legal endpoints (counter- -// notices, GDPR data export requests, ToS dispute submissions). -// v1.0.9 W3 Day 14. +// setupLegalRoutes registers DMCA + terms-acceptance ledger + future +// legal endpoints (counter-notices, GDPR data export requests, ToS +// dispute submissions). v1.0.9 W3 Day 14 + v1.0.10 légal item 3. // -// Public endpoint : +// Public endpoints : // -// POST /api/v1/dmca/notice (rate-limited via global per-IP limiter) +// POST /api/v1/dmca/notice (rate-limited via global per-IP limiter) +// GET /api/v1/legal/terms/current (versions + per-user unaccepted set) +// +// Authenticated endpoint : +// +// POST /api/v1/legal/terms/accept (records acceptance with IP + UA evidence) // // Admin-only endpoints (auth + admin + MFA) : // @@ -39,6 +44,32 @@ func (r *APIRouter) setupLegalRoutes(router *gin.RouterGroup) { dmca.POST("/notice", dmcaHandler.SubmitNotice) } + // v1.0.10 légal item 3 — terms acceptance ledger. + // `current` is public (the SPA fetches it on app load to know + // whether to show the AcceptanceModal). `accept` requires auth + // because we need a user_id to write the ledger row. + termsService := services.NewTermsService(r.db.GormDB, r.logger) + termsHandler := handlers.NewTermsHandler(termsService) + terms := router.Group("/legal/terms") + { + // `current` accepts an optional auth token : if present, + // the response includes the per-user unaccepted set ; if + // absent, only the live versions. The OptionalAuth + // middleware sets user_id when a valid bearer is supplied + // and silently drops bad ones. Falls back to the standard + // Auth middleware if OptionalAuth isn't wired. + if r.config != nil && r.config.AuthMiddleware != nil { + terms.GET("/current", r.config.AuthMiddleware.OptionalAuth(), termsHandler.GetCurrent) + terms.POST("/accept", r.config.AuthMiddleware.RequireAuth(), termsHandler.Accept) + } else { + // Test / no-middleware path : `current` still works + // (returns versions only), `accept` will 401 because + // GetUserIDUUID won't find a user_id. + terms.GET("/current", termsHandler.GetCurrent) + terms.POST("/accept", termsHandler.Accept) + } + } + // Admin queue + actions. Same auth chain as moderation routes. admin := router.Group("/admin/dmca") { diff --git a/veza-backend-api/internal/handlers/terms_handler.go b/veza-backend-api/internal/handlers/terms_handler.go new file mode 100644 index 000000000..906ea9e6d --- /dev/null +++ b/veza-backend-api/internal/handlers/terms_handler.go @@ -0,0 +1,141 @@ +package handlers + +// TermsHandler — REST surface for the v1.0.10 légal item 3 +// terms-acceptance ledger. Two endpoints : +// +// GET /api/v1/legal/terms/current (public) +// Returns the live document versions and, if the caller is +// authenticated, the list of unaccepted classes. The SPA polls +// this on app load + on the AcceptanceModal opener. +// +// POST /api/v1/legal/terms/accept (authenticated) +// Body: { acceptances: [{ terms_type, version }, ...] } +// Records each tuple. Captures originating IP + UA from the +// request for legal evidence. Idempotent. +// +// Error semantics match the AppError pattern used elsewhere : +// 400 = client-side fixable (bad version, missing fields), +// 401 = not authenticated for the protected endpoint, +// 500 = DB or internal failure. + +import ( + "net/http" + "strings" + + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" +) + +// TermsHandler bundles the dependencies the two endpoints need. +type TermsHandler struct { + svc *services.TermsService +} + +// NewTermsHandler constructs the handler with the shared service. +func NewTermsHandler(svc *services.TermsService) *TermsHandler { + return &TermsHandler{svc: svc} +} + +// GetCurrent returns the live versions + (when authenticated) the +// list of unaccepted classes. The optional auth middleware sets +// `user_id` in the context ; an absent value is fine — we just skip +// the unaccepted check. +// +// @Summary Get current terms versions + per-user acceptance gap +// @Description Public. Returns {versions, unaccepted}. `unaccepted` is empty for anonymous callers. +// @Tags Legal +// @Produce json +// @Success 200 {object} services.CurrentVersionsResponse +// @Router /legal/terms/current [get] +func (h *TermsHandler) GetCurrent(c *gin.Context) { + // User ID is optional — the route may be called by guests. + userID, _ := GetUserIDUUID(c) + + resp, err := h.svc.CurrentVersions(c.Request.Context(), userID) + if err != nil { + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to load terms versions", err)) + return + } + RespondSuccess(c, http.StatusOK, resp) +} + +// AcceptRequest is the body shape for POST /legal/terms/accept. +type AcceptRequest struct { + Acceptances []struct { + TermsType string `json:"terms_type" binding:"required"` + Version string `json:"version" binding:"required"` + } `json:"acceptances" binding:"required,min=1,dive"` +} + +// Accept records the user's acceptance of the listed (type, version) +// pairs. The IP + user-agent at request time are persisted alongside +// for legal-evidence purposes. +// +// @Summary Accept one or more terms versions +// @Description Authenticated. Records (user, terms_type, version) tuples with IP + UA evidence. Idempotent. +// @Tags Legal +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body handlers.AcceptRequest true "Acceptance batch" +// @Success 200 {object} handlers.APIResponse{data=object{accepted=int}} +// @Failure 400 {object} handlers.APIResponse "Validation" +// @Failure 401 {object} handlers.APIResponse "Unauthorized" +// @Failure 500 {object} handlers.APIResponse "Internal" +// @Router /legal/terms/accept [post] +func (h *TermsHandler) Accept(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return // helper already responded + } + + var req AcceptRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondWithAppError(c, apperrors.NewValidationError("invalid acceptance payload")) + return + } + + // Capture evidence from the request — IP first non-private (the + // request may be behind a proxy ; ClientIP() handles + // X-Forwarded-For per Gin's TrustedProxies config). + ip := c.ClientIP() + ua := c.Request.UserAgent() + var ipPtr, uaPtr *string + if ip != "" { + ipPtr = &ip + } + if ua != "" { + // Truncate defensively — the column is VARCHAR(512). UA + // strings beyond that are abusive ; we want the row to fit. + if len(ua) > 512 { + ua = ua[:512] + } + uaPtr = &ua + } + + inputs := make([]services.AcceptInput, 0, len(req.Acceptances)) + for _, a := range req.Acceptances { + inputs = append(inputs, services.AcceptInput{ + TermsType: services.TermsType(strings.ToLower(a.TermsType)), + Version: a.Version, + IPAddress: ipPtr, + UserAgent: uaPtr, + }) + } + + if err := h.svc.Accept(c.Request.Context(), userID, inputs); err != nil { + // Map the version-mismatch sentinel to 400 so the SPA can + // render "the document was updated, please re-read" without + // pattern-matching on the message. + if err == services.ErrTermsVersionMismatch { + RespondWithAppError(c, apperrors.NewValidationError(err.Error())) + return + } + RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to record terms acceptance", err)) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{"accepted": len(inputs)}) +} diff --git a/veza-backend-api/internal/services/terms_service.go b/veza-backend-api/internal/services/terms_service.go new file mode 100644 index 000000000..3b2e76523 --- /dev/null +++ b/veza-backend-api/internal/services/terms_service.go @@ -0,0 +1,218 @@ +package services + +// TermsService — RGPD-compliant terms-acceptance ledger (v1.0.10 légal 3). +// +// Three document classes are tracked : CGU (terms of service), CGV +// (sales terms — for marketplace buyers/sellers), Mentions (legal +// mentions/impressum). The privacy notice (CookieBanner + the +// `/legal/privacy` page in v1.0.10 légal 1) intentionally has no +// "accept" button — privacy notice is informational, not contractual. +// +// Architecture : +// - Current versions hardcoded as a struct constant in this file. +// Bump the constants when the text changes ; next call to +// NeedsAcceptance returns the unaccepted set. +// - One row per (user, terms_type, version) ; UNIQUE index prevents +// duplicate inserts on idempotent retries. +// - The `/api/v1/legal/terms/accept` handler captures IP + UA at +// accept time and writes them in the row — legal evidence. + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// TermsType enumerates the document classes the platform tracks. +// Stored as VARCHAR (not enum) so adding a new class needs no schema +// migration ; just register it in `CurrentTerms` below. +type TermsType string + +const ( + TermsTypeCGU TermsType = "cgu" + TermsTypeCGV TermsType = "cgv" + TermsTypeMentions TermsType = "mentions" +) + +// CurrentTerms holds the live version label per document class. +// Convention : ISO publication date (YYYY-MM-DD). Bump when the text +// changes — every existing user will be force-re-prompted on their +// next session. +// +// Keep these aligned with what the frontend pages display. Drift here +// is the legal contradiction the migration was designed to prevent ; +// guard with the integration test in service_test if added later. +var CurrentTerms = map[TermsType]string{ + TermsTypeCGU: "2026-04-30", + TermsTypeCGV: "2026-04-30", + TermsTypeMentions: "2026-04-30", +} + +// TermsAcceptance is the GORM model — one row per accepted document. +type TermsAcceptance struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_terms_acceptances_user_doc_version,unique" json:"user_id"` + TermsType string `gorm:"size:32;not null;index:idx_terms_acceptances_user_doc_version,unique;index:idx_terms_acceptances_doc_version" json:"terms_type"` + Version string `gorm:"size:32;not null;index:idx_terms_acceptances_user_doc_version,unique;index:idx_terms_acceptances_doc_version" json:"version"` + AcceptedAt time.Time `gorm:"not null;default:now()" json:"accepted_at"` + IPAddress *string `gorm:"type:inet" json:"ip_address,omitempty"` + UserAgent *string `gorm:"size:512" json:"user_agent,omitempty"` +} + +// TableName overrides GORM's default pluralisation — the migration +// uses the explicit name `terms_acceptances`. +func (TermsAcceptance) TableName() string { return "terms_acceptances" } + +// TermsService writes acceptance ledger rows and answers +// "what's still unaccepted ?" queries. +type TermsService struct { + db *gorm.DB + logger *zap.Logger +} + +// NewTermsService constructs the service with the shared GORM handle +// and a structured logger. +func NewTermsService(db *gorm.DB, logger *zap.Logger) *TermsService { + return &TermsService{db: db, logger: logger} +} + +// CurrentVersionsResponse is the public shape returned by +// GET /api/v1/legal/terms/current. The Unaccepted slice is empty for +// guests + for users who have already accepted every current version. +type CurrentVersionsResponse struct { + Versions map[TermsType]string `json:"versions"` + Unaccepted []TermsType `json:"unaccepted,omitempty"` +} + +// CurrentVersions returns the platform's currently-published versions +// and, when userID is non-zero, the list of document classes the user +// hasn't yet accepted at the current version. +func (s *TermsService) CurrentVersions(ctx context.Context, userID uuid.UUID) (*CurrentVersionsResponse, error) { + resp := &CurrentVersionsResponse{ + Versions: make(map[TermsType]string, len(CurrentTerms)), + } + for k, v := range CurrentTerms { + resp.Versions[k] = v + } + + if userID == uuid.Nil { + return resp, nil + } + + // Pull every (terms_type, version) the user has accepted. Cheap: + // 1-3 rows per user in steady state, indexed by user_id. + type acceptedRow struct { + TermsType string + Version string + } + var rows []acceptedRow + if err := s.db.WithContext(ctx). + Model(&TermsAcceptance{}). + Where("user_id = ?", userID). + Select("terms_type, version"). + Scan(&rows).Error; err != nil { + return nil, err + } + + accepted := make(map[TermsType]string, len(rows)) + for _, r := range rows { + accepted[TermsType(r.TermsType)] = r.Version + } + + for tt, current := range CurrentTerms { + if accepted[tt] != current { + resp.Unaccepted = append(resp.Unaccepted, tt) + } + } + return resp, nil +} + +// AcceptInput captures the per-document accept payload. The handler +// builds one of these per accepted class ; the service writes them +// in a single transaction for atomicity. +type AcceptInput struct { + TermsType TermsType + Version string + IPAddress *string + UserAgent *string +} + +// ErrTermsVersionMismatch is returned by Accept when the client +// supplies a version that doesn't match what the server considers +// current. Prevents stale-cache replays where a user clicks +// "accept" on yesterday's terms while today's are already published. +var ErrTermsVersionMismatch = errors.New("terms version mismatch — refresh the page and re-read the document") + +// Accept records one or more terms acceptances for a user. Each +// (terms_type, version) is verified against CurrentTerms before +// insert ; mismatched submissions return ErrTermsVersionMismatch +// without writing anything. Idempotent — duplicate (user, type, +// version) tuples are absorbed by the unique index. +func (s *TermsService) Accept(ctx context.Context, userID uuid.UUID, inputs []AcceptInput) error { + if userID == uuid.Nil { + return errors.New("userID required") + } + if len(inputs) == 0 { + return errors.New("no acceptances provided") + } + + // Server-side authoritative version check : even if the SPA + // thinks it's accepting current, only the server's CurrentTerms + // definition matters. + for _, in := range inputs { + current, ok := CurrentTerms[in.TermsType] + if !ok { + return errors.New("unknown terms_type: " + string(in.TermsType)) + } + if in.Version != current { + s.logger.Warn("Terms accept rejected: version mismatch", + zap.String("user_id", userID.String()), + zap.String("terms_type", string(in.TermsType)), + zap.String("submitted", in.Version), + zap.String("current", current), + ) + return ErrTermsVersionMismatch + } + } + + // Single transaction so a partial failure leaves no half-state. + rows := make([]TermsAcceptance, 0, len(inputs)) + now := time.Now().UTC() + for _, in := range inputs { + rows = append(rows, TermsAcceptance{ + UserID: userID, + TermsType: string(in.TermsType), + Version: in.Version, + AcceptedAt: now, + IPAddress: in.IPAddress, + UserAgent: in.UserAgent, + }) + } + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // `OnConflict DO NOTHING` makes the call idempotent : if the + // user already accepted this exact (type, version), we just + // no-op rather than 500 on a unique-violation. GORM doesn't + // expose ON CONFLICT directly across all dialects ; we use + // the Clause() form so this stays Postgres-friendly. + // The unique index on (user_id, terms_type, version) is what + // the conflict resolution targets. + for _, row := range rows { + if err := tx. + Where("user_id = ? AND terms_type = ? AND version = ?", + row.UserID, row.TermsType, row.Version). + FirstOrCreate(&row).Error; err != nil { + return err + } + } + s.logger.Info("Terms acceptances recorded", + zap.String("user_id", userID.String()), + zap.Int("count", len(rows)), + ) + return nil + }) +} diff --git a/veza-backend-api/migrations/991_terms_acceptance.sql b/veza-backend-api/migrations/991_terms_acceptance.sql new file mode 100644 index 000000000..b0c3eed71 --- /dev/null +++ b/veza-backend-api/migrations/991_terms_acceptance.sql @@ -0,0 +1,62 @@ +-- 991_terms_acceptance.sql +-- v1.0.10 légal item 3 — CGU / CGV / mentions légales versionnées. +-- +-- RGPD demands that any change to terms-of-service-class documents be +-- explicitly re-accepted by the user. To prove acceptance in case of +-- a contentieux, we need a per-user, per-document, per-version row +-- with the timestamp and the originating IP address. +-- +-- The current version of each document is hardcoded in the Go service +-- (auth.CurrentTerms.{CGU,CGV,Mentions}, ISO date strings). When the +-- text is edited, bump the constant ; the next time the user lands on +-- /api/v1/legal/terms/current the response signals "you have unaccepted +-- versions" and the SPA forces the AcceptanceModal before any other +-- action is allowed. +-- +-- terms_type is a string (not enum) so a new doc class can be added +-- without a schema migration. + +CREATE TABLE IF NOT EXISTS public.terms_acceptances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + -- Document class. Conventional values : 'cgu' (terms of service), + -- 'cgv' (sales terms), 'mentions' (legal mentions / impressum), + -- 'privacy' (privacy policy). Lowercased ASCII so URL slugs and + -- DB rows agree. + terms_type VARCHAR(32) NOT NULL, + -- Version identifier. Convention : the publication date in ISO + -- format (YYYY-MM-DD). Sortable, unambiguous, no minor-version + -- ambiguity. Long enough for a hash suffix if a same-day rev needs + -- one (e.g. '2026-04-30-r2'). + version VARCHAR(32) NOT NULL, + accepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Legally useful : if a user later disputes acceptance, the IP at + -- accept time is one piece of corroborating evidence (alongside the + -- session log). NULL only if the request didn't carry one (CLI + -- scripts, internal seed paths) — should never be NULL on the + -- /api/v1/legal/terms/accept path. + ip_address INET, + -- The handler may want to record the user-agent for the same + -- audit purpose. Keep it bounded so a hostile UA can't bloat the + -- table. + user_agent VARCHAR(512) +); + +COMMENT ON TABLE public.terms_acceptances IS + 'Per-user / per-document / per-version acceptance ledger. v1.0.10 légal item 3.'; +COMMENT ON COLUMN public.terms_acceptances.version IS + 'Version label, conventionally the publication ISO date. Hardcoded server-side.'; +COMMENT ON COLUMN public.terms_acceptances.ip_address IS + 'Originating IP at accept time, for legal evidence in case of dispute.'; + +-- Lookup by user (the most frequent query — "has this user accepted +-- the current versions?"). The combination is unique per row : a +-- user accepting CGU v2 a second time should be a no-op, not a +-- duplicate insertion. UNIQUE captures this. +CREATE UNIQUE INDEX IF NOT EXISTS idx_terms_acceptances_user_doc_version + ON public.terms_acceptances(user_id, terms_type, version); + +-- Reverse-direction lookup : "how many users have accepted version +-- 2026-04-30 of CGU" — useful for the operator dashboard / audit. +CREATE INDEX IF NOT EXISTS idx_terms_acceptances_doc_version + ON public.terms_acceptances(terms_type, version);