feat(legal): versioned terms acceptance ledger (CGU/CGV/mentions)
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) <noreply@anthropic.com>
This commit is contained in:
parent
7f61fb225f
commit
c0e06e61b6
14 changed files with 1391 additions and 5 deletions
|
|
@ -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. */}
|
||||
<CookieBanner />
|
||||
{/* 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). */}
|
||||
<TermsAcceptanceModal />
|
||||
{/* PWA Install Banner - Disabled for now as it is too intrusive */}
|
||||
{/* <PWAInstallBanner /> */}
|
||||
{/* Keyboard Shortcuts Panel (Discord-style overlay) */}
|
||||
|
|
|
|||
210
apps/web/src/components/TermsAcceptanceModal.tsx
Normal file
210
apps/web/src/components/TermsAcceptanceModal.tsx
Normal file
|
|
@ -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<TermsType, { title: string; href: string }> = {
|
||||
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<TermsType[]>([]);
|
||||
const [versions, setVersions] = useState<Record<TermsType, string>>({
|
||||
cgu: '',
|
||||
cgv: '',
|
||||
mentions: '',
|
||||
});
|
||||
const [checked, setChecked] = useState<Record<TermsType, boolean>>({
|
||||
cgu: false,
|
||||
cgv: false,
|
||||
mentions: false,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terms-modal-title"
|
||||
className="fixed inset-0 z-[var(--sumi-z-max)] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg border bg-card shadow-2xl"
|
||||
style={{
|
||||
borderColor: 'var(--sumi-border-faint)',
|
||||
color: 'var(--sumi-text-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="px-6 py-5 border-b" style={{ borderColor: 'var(--sumi-border-faint)' }}>
|
||||
<h2 id="terms-modal-title" className="font-heading text-xl font-semibold">
|
||||
Mise à jour de nos conditions
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm mt-2 leading-relaxed"
|
||||
style={{ color: 'var(--sumi-text-secondary)' }}
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{unaccepted.map((tt) => {
|
||||
const meta = TERMS_LABEL[tt];
|
||||
return (
|
||||
<label
|
||||
key={tt}
|
||||
htmlFor={`terms-${tt}`}
|
||||
className="flex items-start gap-3 cursor-pointer p-3 rounded border transition-colors hover:bg-muted/30"
|
||||
style={{ borderColor: 'var(--sumi-border-faint)' }}
|
||||
>
|
||||
<input
|
||||
id={`terms-${tt}`}
|
||||
type="checkbox"
|
||||
checked={checked[tt]}
|
||||
onChange={(e) =>
|
||||
setChecked((prev) => ({ ...prev, [tt]: e.target.checked }))
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{meta.title}</p>
|
||||
<p
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: 'var(--sumi-text-tertiary)' }}
|
||||
>
|
||||
Version {versions[tt]} ·{' '}
|
||||
<Link
|
||||
to={meta.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="underline underline-offset-2"
|
||||
style={{ color: 'var(--sumi-text-secondary)' }}
|
||||
>
|
||||
Lire le document
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{errorMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm rounded border px-3 py-2"
|
||||
style={{
|
||||
borderColor: 'var(--sumi-color-destructive, #c84a4a)',
|
||||
color: 'var(--sumi-color-destructive, #c84a4a)',
|
||||
}}
|
||||
>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-6 py-4 border-t flex justify-end gap-3"
|
||||
style={{ borderColor: 'var(--sumi-border-faint)' }}
|
||||
>
|
||||
{/* eslint-disable-next-line no-restricted-syntax -- standalone modal must work without the design-system Button (mounted very early) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!allChecked || submitting}
|
||||
className="px-5 py-2 rounded text-sm font-medium transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
backgroundColor: 'var(--sumi-text-primary)',
|
||||
color: 'var(--sumi-bg-base)',
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Enregistrement…' : "J'accepte"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TermsAcceptanceModal;
|
||||
|
|
@ -55,5 +55,8 @@ export {
|
|||
LazyDmca,
|
||||
LazyDmcaNotice,
|
||||
LazyPrivacy,
|
||||
LazyCGU,
|
||||
LazyCGV,
|
||||
LazyMentions,
|
||||
} from './lazy-component';
|
||||
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';
|
||||
|
|
|
|||
|
|
@ -57,4 +57,7 @@ export {
|
|||
LazyDmca,
|
||||
LazyDmcaNotice,
|
||||
LazyPrivacy,
|
||||
LazyCGU,
|
||||
LazyCGV,
|
||||
LazyMentions,
|
||||
} from './lazyExports';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
228
apps/web/src/features/legal/pages/CGUPage.tsx
Normal file
228
apps/web/src/features/legal/pages/CGUPage.tsx
Normal file
|
|
@ -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 (
|
||||
<main
|
||||
style={{
|
||||
backgroundColor: 'var(--sumi-bg-base)',
|
||||
color: 'var(--sumi-text-primary)',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 md:py-24">
|
||||
<header className="mb-12">
|
||||
<Link
|
||||
to="/launch"
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: 'var(--sumi-text-tertiary)' }}
|
||||
>
|
||||
← Veza
|
||||
</Link>
|
||||
<h1 className="font-heading text-4xl md:text-5xl font-bold mt-6">
|
||||
Conditions Générales d'Utilisation
|
||||
</h1>
|
||||
<p className="mt-3 text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
Version {CGU_VERSION}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-8 leading-relaxed">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">1. Objet</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
2. Conditions d'inscription
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Avoir au moins 16 ans révolus à la date d'inscription (RGPD).</li>
|
||||
<li>
|
||||
Disposer d'un email valide et accessible — la vérification est
|
||||
obligatoire avant la première connexion.
|
||||
</li>
|
||||
<li>
|
||||
Fournir des informations exactes (nom d'utilisateur, date de naissance).
|
||||
Toute inscription frauduleuse est nulle et entraîne suppression du compte
|
||||
sans préavis.
|
||||
</li>
|
||||
<li>
|
||||
Un seul compte par personne. Les comptes multiples liés à des fins de
|
||||
contournement (modération, fraude marketplace) sont sanctionnés.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
3. Contenu publié par l'Utilisateur
|
||||
</h2>
|
||||
<p className="mb-3">
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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 à{' '}
|
||||
<Link to="/legal/dmca" className="underline">
|
||||
/legal/dmca
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
4. Comportements interdits
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Harcèlement, menaces, propos haineux ou discriminatoires.</li>
|
||||
<li>Spam, scraping automatisé, contournement des limites de débit.</li>
|
||||
<li>
|
||||
Téléversement de malware, scripts, contenus exploitant des failles de
|
||||
sécurité.
|
||||
</li>
|
||||
<li>
|
||||
Usurpation d'identité, fausse revendication d'œuvres tiers, fraude au
|
||||
paiement.
|
||||
</li>
|
||||
<li>
|
||||
Contournement de la modération (compte multiple, VPN après ban).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
5. Modération & sanctions
|
||||
</h2>
|
||||
<p>
|
||||
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{' '}
|
||||
<strong>support@veza.fr</strong> sous 30 jours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
6. Service marketplace
|
||||
</h2>
|
||||
<p>
|
||||
Pour toute transaction commerciale (achat de licences, ventes
|
||||
créateur, abonnements), les{' '}
|
||||
<Link to="/legal/cgv" className="underline">
|
||||
Conditions Générales de Vente
|
||||
</Link>{' '}
|
||||
s'appliquent en complément des présentes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
7. Disponibilité & responsabilité
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
8. Données personnelles
|
||||
</h2>
|
||||
<p>
|
||||
Le traitement des données personnelles est décrit dans la{' '}
|
||||
<Link to="/legal/privacy" className="underline">
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
. L'Utilisateur peut exercer ses droits RGPD (accès, rectification,
|
||||
effacement, portabilité, opposition) à <strong>privacy@veza.fr</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
9. Modifications
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
10. Droit applicable
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
className="mt-16 pt-8 border-t"
|
||||
style={{ borderColor: 'var(--sumi-border-faint)' }}
|
||||
>
|
||||
<p className="text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
<Link to="/legal/cgv" className="underline">
|
||||
CGV
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/mentions" className="underline">
|
||||
Mentions légales
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/privacy" className="underline">
|
||||
Politique de confidentialité
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/dmca" className="underline">
|
||||
DMCA
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
225
apps/web/src/features/legal/pages/CGVPage.tsx
Normal file
225
apps/web/src/features/legal/pages/CGVPage.tsx
Normal file
|
|
@ -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 (
|
||||
<main
|
||||
style={{
|
||||
backgroundColor: 'var(--sumi-bg-base)',
|
||||
color: 'var(--sumi-text-primary)',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 md:py-24">
|
||||
<header className="mb-12">
|
||||
<Link
|
||||
to="/launch"
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: 'var(--sumi-text-tertiary)' }}
|
||||
>
|
||||
← Veza
|
||||
</Link>
|
||||
<h1 className="font-heading text-4xl md:text-5xl font-bold mt-6">
|
||||
Conditions Générales de Vente
|
||||
</h1>
|
||||
<p className="mt-3 text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
Version {CGV_VERSION} · Marketplace Veza
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-8 leading-relaxed">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
1. Champ d'application
|
||||
</h2>
|
||||
<p>
|
||||
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{' '}
|
||||
<Link to="/legal/cgu" className="underline">
|
||||
CGU
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">2. Parties</h2>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>
|
||||
<strong>Vendeur</strong> : utilisateur disposant du rôle créateur
|
||||
ayant complété son onboarding Stripe Connect (KYC).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Acheteur</strong> : tout utilisateur authentifié majeur ou
|
||||
disposant d'un consentement parental conforme.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Veza</strong> : 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.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
3. Prix & commissions
|
||||
</h2>
|
||||
<p>
|
||||
Le Vendeur fixe librement ses prix. La commission de Veza est de{' '}
|
||||
<strong>15 % HT</strong> 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
4. Paiement
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
5. Droit de rétractation
|
||||
</h2>
|
||||
<p>
|
||||
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 <strong>ne s'applique pas</strong> 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.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Pour les pistes audio non encore téléchargées, l'Acheteur conserve
|
||||
son droit de rétractation pendant 14 jours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
6. Remboursements
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>
|
||||
Demande possible sous <strong>14 jours</strong> calendaires après la
|
||||
transaction.
|
||||
</li>
|
||||
<li>
|
||||
Acceptée si : œuvre non livrée techniquement, fichier corrompu,
|
||||
violation manifeste des droits par le Vendeur (DMCA confirmée).
|
||||
</li>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
Procédé : POST /orders/:id/refund. Délai de remboursement bancaire :
|
||||
5 à 7 jours ouvrés selon le PSP.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
7. Licences accordées
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
8. TVA & obligations fiscales
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
9. Litiges entre Acheteur et Vendeur
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
10. Modifications
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
className="mt-16 pt-8 border-t"
|
||||
style={{ borderColor: 'var(--sumi-border-faint)' }}
|
||||
>
|
||||
<p className="text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
<Link to="/legal/cgu" className="underline">
|
||||
CGU
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/mentions" className="underline">
|
||||
Mentions légales
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/privacy" className="underline">
|
||||
Politique de confidentialité
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/dmca" className="underline">
|
||||
DMCA
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
190
apps/web/src/features/legal/pages/MentionsPage.tsx
Normal file
190
apps/web/src/features/legal/pages/MentionsPage.tsx
Normal file
|
|
@ -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 (
|
||||
<main
|
||||
style={{
|
||||
backgroundColor: 'var(--sumi-bg-base)',
|
||||
color: 'var(--sumi-text-primary)',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 md:py-24">
|
||||
<header className="mb-12">
|
||||
<Link
|
||||
to="/launch"
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: 'var(--sumi-text-tertiary)' }}
|
||||
>
|
||||
← Veza
|
||||
</Link>
|
||||
<h1 className="font-heading text-4xl md:text-5xl font-bold mt-6">
|
||||
Mentions légales
|
||||
</h1>
|
||||
<p className="mt-3 text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
Version {MENTIONS_VERSION}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-8 leading-relaxed">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Éditeur du site
|
||||
</h2>
|
||||
<p>
|
||||
Veza<br />
|
||||
<em style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
(Forme juridique, capital social, RCS, n° SIREN — à compléter
|
||||
avant v2.0.0)
|
||||
</em>
|
||||
<br />
|
||||
Adresse postale : <em style={{ color: 'var(--sumi-text-tertiary)' }}>(à compléter)</em>
|
||||
<br />
|
||||
Email : <strong>contact@veza.fr</strong>
|
||||
<br />
|
||||
Téléphone : <em style={{ color: 'var(--sumi-text-tertiary)' }}>(à compléter)</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Directeur de la publication
|
||||
</h2>
|
||||
<p>
|
||||
<em style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
(Nom et fonction du directeur de la publication — à compléter)
|
||||
</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Hébergement
|
||||
</h2>
|
||||
<p>
|
||||
Le site est auto-hébergé sur infrastructure dédiée (Incus / R720 lab).
|
||||
<br />
|
||||
Pour toute requête liée à l'hébergement : <strong>infra@veza.fr</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Propriété intellectuelle
|
||||
</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
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{' '}
|
||||
<Link to="/legal/cgu" className="underline">
|
||||
CGU
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Signalement de contenu illicite
|
||||
</h2>
|
||||
<p>
|
||||
Conformément à l'article 6 de la LCEN, tout contenu manifestement
|
||||
illicite peut être signalé à <strong>abuse@veza.fr</strong> ou via le
|
||||
formulaire <Link to="/legal/dmca/notice" className="underline">DMCA</Link>.
|
||||
Veza traite les signalements sous 72 heures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Données personnelles
|
||||
</h2>
|
||||
<p>
|
||||
Le traitement des données personnelles est régi par notre{' '}
|
||||
<Link to="/legal/privacy" className="underline">
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
. Délégué à la protection des données :{' '}
|
||||
<strong>privacy@veza.fr</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Cookies
|
||||
</h2>
|
||||
<p>
|
||||
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{' '}
|
||||
<Link to="/legal/privacy" className="underline">
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-semibold mb-3">
|
||||
Médiation de la consommation
|
||||
</h2>
|
||||
<p>
|
||||
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 à
|
||||
<strong> support@veza.fr</strong>.
|
||||
<br />
|
||||
<em style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
(Coordonnées du médiateur agréé — à compléter avant v2.0.0)
|
||||
</em>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
className="mt-16 pt-8 border-t"
|
||||
style={{ borderColor: 'var(--sumi-border-faint)' }}
|
||||
>
|
||||
<p className="text-sm" style={{ color: 'var(--sumi-text-tertiary)' }}>
|
||||
<Link to="/legal/cgu" className="underline">
|
||||
CGU
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/cgv" className="underline">
|
||||
CGV
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/privacy" className="underline">
|
||||
Politique de confidentialité
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link to="/legal/dmca" className="underline">
|
||||
DMCA
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: <ErrorBoundary><LazyDmca /></ErrorBoundary> },
|
||||
{ path: '/legal/dmca/notice', element: <ErrorBoundary><LazyDmcaNotice /></ErrorBoundary> },
|
||||
{ path: '/legal/privacy', element: <ErrorBoundary><LazyPrivacy /></ErrorBoundary> },
|
||||
{ path: '/legal/cgu', element: <ErrorBoundary><LazyCGU /></ErrorBoundary> },
|
||||
{ path: '/legal/cgv', element: <ErrorBoundary><LazyCGV /></ErrorBoundary> },
|
||||
{ path: '/legal/mentions', element: <ErrorBoundary><LazyMentions /></ErrorBoundary> },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
47
apps/web/src/services/api/legalTerms.ts
Normal file
47
apps/web/src/services/api/legalTerms.ts
Normal file
|
|
@ -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<TermsType, string>;
|
||||
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<CurrentVersionsResponse> {
|
||||
// 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<CurrentVersionsResponse>('/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<void> {
|
||||
await apiClient.post('/legal/terms/accept', { acceptances: items });
|
||||
}
|
||||
|
|
@ -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")
|
||||
{
|
||||
|
|
|
|||
141
veza-backend-api/internal/handlers/terms_handler.go
Normal file
141
veza-backend-api/internal/handlers/terms_handler.go
Normal file
|
|
@ -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)})
|
||||
}
|
||||
218
veza-backend-api/internal/services/terms_service.go
Normal file
218
veza-backend-api/internal/services/terms_service.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
62
veza-backend-api/migrations/991_terms_acceptance.sql
Normal file
62
veza-backend-api/migrations/991_terms_acceptance.sql
Normal file
|
|
@ -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);
|
||||
Loading…
Reference in a new issue