Le gratuit n'a pas d'entreprise derrière, et trois écrans faisaient comme si
Trois retours à l'usage, une heure après la bascule. Les pages légales réclamaient cinq mentions en gratuit — identité, siège, responsable de publication, hébergeur, contact — et affichaient « MIAW » comme éditeur. Or il n'y a pas d'entreprise : chacun de ces champs restait en « à compléter » avec un encart rouge en tête des trois pages, indéfiniment. En gratuit, il n'en reste qu'une : l'adresse de contact, qui n'est pas une identité d'entreprise mais une boîte, et que l'art. 13 du RGPD impose pour exercer ses droits. Le reste est retiré, pas mis à blanc. Ce qui est assumé plutôt que maquillé : l'anonymat de l'art. 6 III-2 LCEN suppose qu'un hébergeur TIERS détienne l'identité, ce qu'un service auto-hébergé n'a pas. Le produit cesse de réclamer ces champs à un écran — c'est la décision de l'éditeur — sans prétendre que leur absence est sans conséquence. En payant, les neuf mentions et l'encart reviennent d'eux-mêmes. Le mur annonçait « il reste 100 accès » puis « 100 accès ouverts à ce jour ». Le premier fabrique une urgence là où le produit s'interdit tout compte à rebours ; le second publie la taille du service à qui passe. Les deux disparaissent. Le seul chiffre qu'un visiteur voit est son propre rang, une fois inscrit. Et le refus explique au lieu de constater : « on est victimes de notre succès », avec sa raison et le geste qui suit. Le lien magique portait six contextes ; il en porte deux. Les quatre messages d'accès — paiement passé, inscription, entrée en file, admission — sont retombés sur un texte générique, seule façon d'être vrai dans les quatre : « ton calendrier est déverrouillé » ment à qui attend une place, et « ta place s'est libérée » suppose qu'il se souvienne d'avoir patienté. Le lien mène de toute façon au même endroit, et la page dit l'état mieux qu'un objet de message. Il ne parle ni de prix, ni de gratuité : la bascule de mode ne le touche pas. La cible d'attaque du sceau interne se déplace sans disparaître. `invitation` reste le seul contexte qui affirme quelque chose, mais `urlFoyer` est rendu en lien cliquable : un metadata non scellé qui le poserait ferait partir le lien d'un attaquant depuis un domaine aligné SPF/DKIM/DMARC. lireMetadata le jetait déjà ; un test le grave, à la place de celui qui gardait « activation ». Ce qu'on perd et qui est assumé : le ton d'accueil du premier accès. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
786046ea97
commit
b943351a97
19 changed files with 357 additions and 239 deletions
|
|
@ -37,7 +37,8 @@ Documents de référence par pilier : [logement](docs/pilier-logement.md) · [v
|
|||
- **Le mode se lit côté serveur, et descend en `props`.** `process.env.FOKAN_MODE` n'existe pas dans le bundle navigateur : un import de `lib/mode-serveur.ts` depuis un composant `"use client"` répondrait « payant » quoi qu'il arrive, et la page se contredirait à l'hydratation, en silence. Le vocabulaire partagé est dans `lib/mode.ts` (pur, isomorphe) ; **les phrases entières dont la structure change restent dans leur composant** — on mutualise le vocabulaire, jamais le discours. Quand une phrase peut être dite *vraie dans les deux modes*, on la neutralise plutôt que de la dupliquer (c'est ce qui a été fait pour le quota de rejeux : « par an », pas « par année d'abonnement »).
|
||||
- **La liste d'attente n'existe pas.** Pas de table, pas de file, pas de rang stocké : un foyer sans place porte `subscription_status = "attente"`, et le passage de 4 h admet les plus anciens (`attente_depuis`) quand il reste de la place. **Relever `FOKAN_PLACES_GRATUITES` suffit** — la variable est relue à chaque exécution du cron. La décision se prend à l'écriture et non à l'affichage, sans quoi la place irait à qui recharge sa page le plus souvent. `0` est un plafond légitime, pas un synonyme de « vide ».
|
||||
- **Le full reveal est le même, la contrepartie change.** `POST /api/activer` est le pendant de `/api/checkout` : même écran, même position, une adresse au lieu d'une carte. **Aucune session n'est ouverte sur une adresse simplement tapée** — ce serait donner le foyer à qui saisirait l'adresse d'un autre ; le lien magique est ce qui prouve la boîte. Le mode gratuit ferme `/api/checkout` **au serveur** (une clé Stripe encore présente la ferait aboutir), mais **jamais `/api/stripe/portal`** : un abonnement souscrit en ligne doit se résilier en ligne (art. L215-1-1), et fermer la sortie enfermerait ceux qui ont payé.
|
||||
- **Cinq mentions légales ne tiennent qu'au commerce**, et les afficher comme manquantes sur un service gratuit serait faux, pas seulement inutile : SIREN (art. 6 III-1 LCEN, éditeur professionnel), téléphone (art. L221-5, vente à distance), médiateur (art. L616-1, litiges de consommation — et adhésion payante), régime de TVA, garantie légale de conformité (art. L224-25-1). Restent dues dans les deux modes : identité, siège, responsable de publication, contact, hébergeur. `/cgv` **garde son URL** et change de titre — c'est le titre qui nomme un document, pas son chemin. La politique de confidentialité perd Stripe quatre fois : déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact (art. 13 RGPD).
|
||||
- **En gratuit, les pages légales n'affichent AUCUNE identité** (D-066, qui révise D-065) : le service est édité sans structure, à titre non professionnel. Plus de marque « MIAW », plus de siège, de SIREN, de responsable de publication ni d'hébergeur nommé — retirés, pas mis en « à compléter », donc plus d'encart rouge. **Une seule mention reste réclamée : l'adresse de contact**, qui n'est pas une identité d'entreprise mais une boîte, et que l'art. 13 du RGPD impose pour exercer ses droits. Assumé et écrit dans `mentionsManquantes` : l'anonymat du III-2 LCEN suppose un hébergeur **tiers** détenant l'identité, ce qu'un service auto-hébergé n'a pas. En payant, les neuf mentions et l'encart reviennent d'eux-mêmes — même code, autre branche. `/cgv` **garde son URL** et change de titre. La confidentialité perd Stripe quatre fois : déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact.
|
||||
- **Aucun compteur d'accès n'est affiché, et un seul mail existe** (D-066). Ni « il reste 12 places » (une urgence là où le § 4.5 interdit tout compte à rebours), ni « 100 accès ouverts » (la taille du service publiée à qui passe) : le seul chiffre montré est le rang du visiteur, une fois inscrit. Le refus se dit « on est victimes de notre succès », avec sa raison. Et le lien magique ne porte plus que **deux contextes** — `connexion` et `invitation` : les quatre messages d'accès (paiement, inscription, entrée en file, admission) sont retombés sur un texte générique, seule façon d'être vrai dans les quatre. « Ton calendrier est déverrouillé » ment à qui attend une place ; « ta place s'est libérée » suppose qu'il se souvienne d'avoir patienté. Conséquence de sécurité : `invitation` est le seul contexte qui affirme quelque chose, mais **`urlFoyer` reste la cible à protéger** — rendu en lien cliquable, un `metadata` non scellé qui le poserait ferait partir le lien d'un attaquant depuis un domaine aligné SPF/DKIM/DMARC.
|
||||
|
||||
## Ce qu'une tranche de revenus autorise, et ce qu'elle n'autorise pas
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
# Audit du service mail — 30/07/2026
|
||||
|
||||
> ⚠️ **Amendé le 08/08/2026 par [D-065](decisions.md).** Cet audit décrit un produit à porte
|
||||
> payante ; le passage en mode gratuit ne change ni les cinq messages recensés, ni leurs
|
||||
> déclencheurs, ni les défauts du § 4 — mais **le lien magique porte désormais six contextes au
|
||||
> lieu de trois** : `inscription` (l'accès s'ouvre), `attente` (la place est prise, l'accès non)
|
||||
> et `place-liberee` (le passage de 4 h a admis le foyer depuis la liste d'attente) s'ajoutent à
|
||||
> `connexion`, `invitation` et `activation`. Ce sont des variantes du **même** message, soumises
|
||||
> au même sceau interne (§ 4.1) — pas trois envois de plus. Le § 6 (décisions produit ouvertes)
|
||||
> et le § 7 (chantiers hors canal) restent valables tels quels, à une exception : le mail de fin
|
||||
> d'abonnement qu'ils appellent n'a plus d'objet tant que `FOKAN_MODE=gratuit`.
|
||||
> ⚠️ **Amendé le 08/08/2026 par [D-065](decisions.md) puis [D-066](decisions.md).** Cet audit
|
||||
> décrit un produit à porte payante ; le passage en mode gratuit ne change ni les cinq messages
|
||||
> recensés, ni leurs déclencheurs, ni les défauts du § 4. En revanche **le lien magique ne porte
|
||||
> plus que deux contextes au lieu de trois** : `connexion` et `invitation`. `activation` (« le
|
||||
> paiement est passé ») a disparu, absorbé par `connexion` — comme les trois contextes que D-065
|
||||
> avait brièvement ajoutés pour le mode gratuit. Un seul texte générique sert désormais toute
|
||||
> ouverture d'accès, quel que soit le mode et quel que soit l'état de la liste d'attente, parce
|
||||
> que c'est la seule formulation qui reste vraie dans les quatre situations où il part.
|
||||
>
|
||||
> Deux conséquences pour qui relit le § 4.1 (le sceau interne) : `invitation` est le seul contexte
|
||||
> qui affirme encore quelque chose, mais la cible d'attaque s'est **déplacée sans disparaître** —
|
||||
> `urlFoyer` est rendu en lien cliquable, et c'est lui que le sceau protège désormais.
|
||||
>
|
||||
> Le § 6 (décisions produit ouvertes) et le § 7 (chantiers hors canal) restent valables tels
|
||||
> quels, à une exception : le mail de fin d'abonnement qu'ils appellent n'a plus d'objet tant que
|
||||
> `FOKAN_MODE=gratuit`.
|
||||
|
||||
Périmètre : tout ce qui peut faire partir un message depuis `noreply@fokan.fr`, du prospect
|
||||
anonyme à l'abonné payant. Trois questions, dans cet ordre : **quels mails**, **sur quel
|
||||
|
|
|
|||
|
|
@ -1,5 +1,35 @@
|
|||
# Journal des décisions post-cadrage
|
||||
|
||||
## D-066 — 8 août 2026 · Le gratuit n'a pas d'entreprise derrière : plus d'identité, plus de compteur, un seul mail
|
||||
|
||||
**Révise D-065**, livré le matin même, sur trois points relevés à l'usage.
|
||||
|
||||
### 1. Un service édité sans structure ne peut pas afficher une identité qu'il n'a pas
|
||||
|
||||
Les trois pages légales réclamaient neuf mentions en payant, cinq en gratuit — identité, siège, responsable de la publication, hébergeur, contact — et affichaient « MIAW » comme éditeur. Or il n'y a **pas d'entreprise** : ni SIREN, ni siège, ni raison sociale. Chacun de ces champs restait donc en « à compléter », avec un encart rouge en tête des trois pages, indéfiniment.
|
||||
|
||||
**En gratuit, il ne reste qu'une mention réclamée : l'adresse de contact.** Tout le reste disparaît — pas mis à blanc, retiré. Le bloc « Éditeur du site » dit ce que le service est (« un service personnel, édité et exploité à titre non professionnel, sans structure commerciale : rien n'y est vendu, aucune publicité n'y est diffusée et aucune donnée n'y est revendue »), l'hébergement dit où il tourne sans nommer d'hébergeur tiers puisqu'il n'y en a pas, et « MIAW » cède la place à « fokan » dans les trois documents.
|
||||
|
||||
**Le contact, lui, n'est pas négociable, et ce n'est pas une identité d'entreprise — c'est une boîte.** L'art. 13 du RGPD impose de dire au lecteur où exercer ses droits ; une politique de confidentialité sans destinataire ne vaut rien, et c'est aussi par là qu'arrivent les signalements d'une règle inexacte, qui profitent à tous les foyers. `contact@fokan.fr` existe déjà, donc l'encart rouge ne s'affiche plus.
|
||||
|
||||
**Ce qui est assumé et doit être écrit ici plutôt que maquillé** : l'art. 6 III-2 LCEN, qui ouvre l'anonymat aux éditeurs **non professionnels**, le subordonne à ce qu'un hébergeur **tiers** détienne l'identité — condition qu'un service auto-hébergé (§ 22) ne remplit pas. Le produit cesse donc de réclamer ces champs à un écran, ce qui est la décision de l'éditeur ; il ne prétend pas pour autant que leur absence est sans conséquence, et le commentaire de `mentionsManquantes` le dit en toutes lettres. Le jour où le mode repasse en payant, les neuf mentions redeviennent obligatoires **et l'encart rouge revient de lui-même** — c'est le même code, avec l'autre branche.
|
||||
|
||||
### 2. Le mur ne compte plus les places, et la file cesse d'être un mérite
|
||||
|
||||
Le mur annonçait « il reste 100 accès à ouvrir » puis « 100 accès ouverts à ce jour ». **Les deux sont retirés.** Le premier fabrique une urgence là où le produit s'interdit tout compte à rebours (§ 4.5) ; le second publie la taille du service à qui passe. Le seul chiffre qu'un visiteur voit désormais est **son propre rang**, une fois inscrit — une information qui le concerne, pas une métrique de l'entreprise.
|
||||
|
||||
Le refus se dit autrement : plus « les accès sont complets », mais **« on est victimes de notre succès »**, suivi de la raison (« la veille interroge une quinzaine de sources publiques par foyer, et on ouvre les accès par vagues pour continuer à le faire correctement ») et du geste (« prendre ma place dans la file »). C'est la même information, mais elle explique au lieu de constater — et « ta place est prise » remplace « tu es sur la liste », qui rangeait le visiteur au lieu de le servir.
|
||||
|
||||
### 3. Un seul mail, quel que soit le mode et quel que soit l'état de la file
|
||||
|
||||
Le lien magique portait six contextes depuis D-065. **Il en porte deux : `connexion` et `invitation`.** `activation` (« le paiement est passé »), `inscription`, `attente` et `place-liberee` sont tous retombés sur `connexion`.
|
||||
|
||||
Ce n'est pas une simplification cosmétique, c'est la seule façon d'écrire un message qui reste **vrai dans les quatre situations** où il part. « Ton calendrier est déverrouillé » ment à qui attend une place. « Ta place s'est libérée » suppose que le lecteur se souvienne d'avoir patienté, et transforme une bonne nouvelle en rappel qu'il a fait la queue. Le lien, lui, mène au même endroit dans tous les cas — son foyer, qui dira son état mieux qu'un objet de message. Le texte ne promet donc qu'une chose : *« Ce lien t'ouvre ton foyer directement, sans mot de passe. »* Il ne parle ni de prix, ni d'abonnement, ni de gratuité, ni de place ; **la bascule de mode ne le touche pas**.
|
||||
|
||||
**Effet de bord heureux sur la sécurité, et déplacement de la cible d'attaque.** `invitation` devient le seul contexte qui AFFIRME quelque chose (« untel t'invite »), donc le seul que le sceau interne ait à protéger : un message générique n'a plus rien à usurper. Mais l'attaque de l'audit du 28/07 (S2) n'a pas disparu, elle a changé de cible — **`urlFoyer` est rendu en lien cliquable**, et un `metadata` non scellé qui le poserait ferait partir depuis `noreply@fokan.fr`, domaine aligné SPF/DKIM/DMARC, un message contenant le lien de l'attaquant. `lireMetadata` le jetait déjà ; un test le grave désormais, à la place de celui qui gardait « activation ».
|
||||
|
||||
**Ce qu'on perd, et qui est assumé** : le ton d'accueil du premier accès. Un foyer qui vient de s'inscrire reçoit « Ton lien de connexion fokan » plutôt qu'un mot de bienvenue. C'est le prix d'un message qui ne peut jamais démentir ce que la page affiche — et l'audit mail § 1 refusait déjà un gabarit de plus pour un chemin que le premier couvre.
|
||||
|
||||
## D-065 — 8 août 2026 · Le mode d'accès devient une variable : gratuit ou payant, sans casser l'existant
|
||||
|
||||
**Contexte** : décision du porteur de projet de passer fokan en accès gratuit, avec un plafond de comptes et une liste d'attente au-delà. La consigne est explicite — **on ne case pas l'existant, on rend modulaire** : le payant doit rester atteignable d'un mot dans un fichier d'environnement, au cas où le modèle y revienne.
|
||||
|
|
|
|||
|
|
@ -73,9 +73,15 @@ async function echantillons(): Promise<{ nom: string; objet: string; html: strin
|
|||
context: "invitation",
|
||||
invitedByEmail: "camille@exemple.fr",
|
||||
}),
|
||||
/*
|
||||
Le même contexte que le n° 3, avec l'adresse durable du foyer en plus (D-066) : c'est ce
|
||||
qui part à toute ouverture d'accès — paiement, inscription gratuite, entrée en file ou
|
||||
admission depuis la file. Il reste dans l'échantillon parce que c'est le seul rendu qui
|
||||
montre le paragraphe de l'adresse durable.
|
||||
*/
|
||||
renderMagicLinkEmail({
|
||||
url: `${BASE}/api/auth/magic-link/verify?token=demo`,
|
||||
context: "activation",
|
||||
context: "connexion",
|
||||
urlFoyer: FOYER,
|
||||
}),
|
||||
renderGarderLienEmail({ url: FOYER, total: 23 }),
|
||||
|
|
@ -86,7 +92,7 @@ async function echantillons(): Promise<{ nom: string; objet: string; html: strin
|
|||
{ nom: "2-rappel-trois-echeances", objet: "[échantillon] 3 choses ce mois-ci", ...rappelTrois },
|
||||
{ nom: "3-lien-connexion", objet: "[échantillon] Ton lien de connexion fokan", ...connexion },
|
||||
{ nom: "4-lien-invitation", objet: "[échantillon] camille@exemple.fr t'invite à rejoindre son foyer sur fokan", ...invitation },
|
||||
{ nom: "5-lien-activation", objet: "[échantillon] Ton foyer est activé sur fokan", ...activation },
|
||||
{ nom: "5-lien-acces-foyer", objet: "[échantillon] Ton lien de connexion fokan", ...activation },
|
||||
{ nom: "6-garder-lien", objet: "[échantillon] Le lien de ton foyer fokan", ...garderLien },
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export async function POST(req: NextRequest) {
|
|||
body: {
|
||||
email,
|
||||
callbackURL: `/foyer/${householdId}`,
|
||||
metadata: metadataInterne("activation", { urlFoyer: `${base}/foyer/${householdId}` }),
|
||||
metadata: metadataInterne("connexion", { urlFoyer: `${base}/foyer/${householdId}` }),
|
||||
},
|
||||
headers: req.headers,
|
||||
}).catch((e) => {
|
||||
|
|
|
|||
|
|
@ -88,8 +88,7 @@ export default function Conditions() {
|
|||
{gratuit ? (
|
||||
<>
|
||||
Les présentes conditions régissent l'utilisation du service « La Veille »
|
||||
proposé sur {EDITEUR.siteNom} par {EDITEUR.raisonSociale} (l'« éditeur »),
|
||||
dont l'identité complète figure dans les{" "}
|
||||
proposé sur {EDITEUR.siteNom} (l'« éditeur »), décrit dans les{" "}
|
||||
<Link href="/mentions-legales">mentions légales</Link>. Elles sont acceptées au
|
||||
moment de la création d'un accès, dans leur version en vigueur ce jour-là.
|
||||
</>
|
||||
|
|
@ -140,7 +139,9 @@ export default function Conditions() {
|
|||
*/}
|
||||
<h2>2. Gratuité</h2>
|
||||
<p>
|
||||
L'accès au service est <strong>gratuit</strong>. Aucun paiement n'est
|
||||
{EDITEUR.siteNom} est un service personnel, édité à titre{" "}
|
||||
<strong>non professionnel</strong> et sans structure commerciale. L'accès en
|
||||
est <strong>gratuit</strong>. Aucun paiement n'est
|
||||
demandé, à aucun moment, et aucun moyen de paiement n'est collecté. Il
|
||||
n'existe ni option payante, ni palier supérieur, ni période d'essai à
|
||||
l'issue de laquelle quelque chose serait facturé.
|
||||
|
|
|
|||
|
|
@ -66,11 +66,25 @@ export default function Confidentialite() {
|
|||
</p>
|
||||
|
||||
<h2>Responsable du traitement</h2>
|
||||
<p>
|
||||
{EDITEUR.raisonSociale} — voir les <Link href="/mentions-legales">mentions légales</Link>.
|
||||
Contact pour toute question ou demande d'exercice de droits :{" "}
|
||||
<Valeur v={EDITEUR.email} />.
|
||||
</p>
|
||||
{gratuit ? (
|
||||
/*
|
||||
Aucune raison sociale ici non plus (D-066) : le service est édité à titre non
|
||||
professionnel, et nommer une marque qui n'existe pas juridiquement ferait croire à
|
||||
une structure derrière laquelle il n'y a rien. Ce que l'art. 13 du RGPD exige et
|
||||
qui reste dû, c'est le MOYEN DE CONTACT — sans lui, aucun droit ne s'exerce.
|
||||
*/
|
||||
<p>
|
||||
L'éditeur de {EDITEUR.siteNom}, service personnel sans structure commerciale —
|
||||
voir les <Link href="/mentions-legales">mentions légales</Link>. Contact pour toute
|
||||
question ou demande d'exercice de droits : <Valeur v={EDITEUR.email} />.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{EDITEUR.raisonSociale} — voir les <Link href="/mentions-legales">mentions légales</Link>.
|
||||
Contact pour toute question ou demande d'exercice de droits :{" "}
|
||||
<Valeur v={EDITEUR.email} />.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2>Ce qui est collecté</h2>
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -26,9 +26,14 @@ import { mots, type ModeAcces, type PlacesGratuites } from "@/lib/mode";
|
|||
* donc rien à quoi renoncer — la maintenir serait une clause sans objet dans un contrat qui n'en
|
||||
* est pas un), et le renvoi aux CGV, qui devient un renvoi aux CGU.
|
||||
*
|
||||
* **Ce que le mode gratuit ajoute** : le plafond de places, quand il y en a un, et la liste
|
||||
* d'attente qu'il ouvre. On l'annonce AVANT le geste, jamais après — découvrir qu'on est 47e
|
||||
* après avoir donné son adresse serait un piège, et c'est exactement ce que la règle d'or refuse.
|
||||
* **Ce que le mode gratuit ajoute** : la liste d'attente, quand le plafond est atteint. On
|
||||
* l'annonce AVANT le geste, jamais après — découvrir qu'on est 47e après avoir donné son adresse
|
||||
* serait un piège, et c'est exactement ce que la règle d'or refuse.
|
||||
*
|
||||
* **Aucun compte d'accès n'est affiché** (D-066). Ni « il reste 12 places », qui fabriquerait une
|
||||
* urgence là où le produit s'interdit tout compte à rebours (§ 4.5), ni « 100 accès ouverts », qui
|
||||
* publierait la taille du service à qui passe. Le seul chiffre que voit un visiteur est son propre
|
||||
* rang une fois inscrit — une information qui le concerne, pas une métrique de l'entreprise.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -290,15 +295,13 @@ function ColonneGratuite({ householdId, mode, places }: Props) {
|
|||
<>
|
||||
<p className="flex items-center gap-2 font-display text-2xl font-semibold tracking-tight">
|
||||
<Users size={20} className="shrink-0 text-accent" aria-hidden />
|
||||
Les accès sont complets
|
||||
On est victimes de notre succès
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
On ouvre les accès par vagues, pour tenir la qualité de la veille. Laisse ton adresse :
|
||||
ton foyer est déjà calculé, il t'attend, et on t'écrit dès qu'une place
|
||||
se libère.
|
||||
{places?.places !== null && places?.places !== undefined && (
|
||||
<> {places.places} accès ouverts à ce jour.</>
|
||||
)}
|
||||
Plus de place pour l'instant : la veille interroge une quinzaine de sources
|
||||
publiques par foyer, et on ouvre les accès par vagues pour continuer à le faire
|
||||
correctement. Laisse ton adresse et tu prends ta place dans la file — ton foyer est
|
||||
déjà calculé, il t'attend, et on t'écrit dès qu'elle s'ouvre.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -306,14 +309,6 @@ function ColonneGratuite({ householdId, mode, places }: Props) {
|
|||
<p className="font-display text-4xl font-semibold tracking-tight">C'est gratuit.</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Pour tout le foyer, sans carte bancaire et sans durée.
|
||||
{places?.restantes !== null && places?.restantes !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
{places.restantes > 1
|
||||
? `Il reste ${places.restantes} accès à ouvrir.`
|
||||
: "Il reste un accès à ouvrir."}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -345,7 +340,7 @@ function ColonneGratuite({ householdId, mode, places }: Props) {
|
|||
disabled={sending || !email}
|
||||
onClick={inscrire}
|
||||
>
|
||||
{sending ? "Un instant…" : complet ? "Me prévenir quand une place s'ouvre" : m.cta}
|
||||
{sending ? "Un instant…" : complet ? "Prendre ma place dans la file" : m.cta}
|
||||
</Button>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
|
@ -379,7 +374,7 @@ function ColonneConfirmee({ resultat, email }: { resultat: ResultatInscription;
|
|||
<Colonne>
|
||||
<p className="flex items-center gap-2 font-display text-2xl font-semibold tracking-tight">
|
||||
<MailCheck size={20} className="shrink-0 text-accent" aria-hidden />
|
||||
{enAttente ? "Tu es sur la liste" : "C'est ouvert"}
|
||||
{enAttente ? "Ta place est prise" : "C'est ouvert"}
|
||||
</p>
|
||||
<p className="mt-3 text-[0.95rem] leading-relaxed text-muted">
|
||||
{enAttente ? (
|
||||
|
|
@ -388,12 +383,12 @@ function ColonneConfirmee({ resultat, email }: { resultat: ResultatInscription;
|
|||
{typeof resultat.rang === "number" && (
|
||||
<>
|
||||
{" "}
|
||||
Tu es <strong className="font-semibold text-ink">{resultat.rang}e</strong> sur la
|
||||
liste.
|
||||
Tu es <strong className="font-semibold text-ink">{resultat.rang}e</strong> dans la
|
||||
file.
|
||||
</>
|
||||
)}{" "}
|
||||
On t'écrit à <strong className="font-semibold text-ink">{email}</strong> le jour
|
||||
où ta place s'ouvre — tu n'as rien à surveiller, et rien à refaire.
|
||||
où ton accès s'ouvre — tu n'as rien à surveiller, et rien à refaire.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -311,14 +311,14 @@ export default async function FoyerPage({
|
|||
<div className="animate-rise rounded-xl border border-line bg-surface-alt px-5 py-4">
|
||||
<p className="flex items-center gap-2.5 font-medium">
|
||||
<Hourglass size={18} className="shrink-0 text-accent" aria-hidden />
|
||||
Ton accès attend une place
|
||||
Ta place est prise
|
||||
{typeof rangAttente === "number" && (
|
||||
<span className="tnum text-muted">— tu es {rangAttente}e sur la liste</span>
|
||||
<span className="tnum text-muted">— tu es {rangAttente}e dans la file</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-2xl text-[0.925rem] text-muted">
|
||||
Ton compte existe et ton foyer est calculé : tout ce que tu vois ci-dessous continue
|
||||
de se tenir à jour. On t'écrit le jour où ta place s'ouvre — tu n'as
|
||||
de se tenir à jour. On t'écrit le jour où ton accès s'ouvre — tu n'as
|
||||
rien à surveiller, et rien à refaire.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Conditions from "./cgv/page";
|
|||
import MentionsLegales from "./mentions-legales/page";
|
||||
import Confidentialite from "./confidentialite/page";
|
||||
import { SiteFooter } from "@/components/footer";
|
||||
import { mentionsManquantes } from "@/lib/legal";
|
||||
|
||||
/**
|
||||
* Les trois pages légales, rendues pour de bon dans les deux modes (D-065).
|
||||
|
|
@ -130,27 +131,40 @@ describe("les conditions générales", () => {
|
|||
});
|
||||
|
||||
describe("les mentions légales", () => {
|
||||
it("ne réclament ni SIREN, ni TVA, ni téléphone en gratuit", () => {
|
||||
/**
|
||||
* En gratuit, le service est édité sans structure et n'affiche AUCUNE identité (D-066) : ni
|
||||
* marque, ni siège, ni SIREN, ni responsable de publication, ni hébergeur nommé. Le test liste
|
||||
* ce qui doit avoir disparu plutôt que de vérifier un rendu, parce que c'est la disparition qui
|
||||
* est l'engagement — une seule de ces mentions qui repasserait annoncerait une structure
|
||||
* derrière laquelle il n'y a rien.
|
||||
*/
|
||||
it("n'affiche aucune identité d'éditeur en gratuit", () => {
|
||||
const t = texte(rendre(MentionsLegales, "gratuit"));
|
||||
expect(t).not.toMatch(/SIREN/);
|
||||
expect(t).not.toMatch(/TVA/);
|
||||
expect(t).not.toMatch(/Téléphone/);
|
||||
// L'identité, elle, reste due : le régime allégé du III-2 LCEN ne joue pas sur un service
|
||||
// auto-hébergé, où aucun hébergeur tiers ne détient l'identité de l'éditeur. La ligne existe
|
||||
// donc, même vide — c'est le « à compléter » qui la rend impossible à oublier.
|
||||
expect(t).toMatch(/Éditeur du site/);
|
||||
expect(t).toMatch(/Responsable de la publication/);
|
||||
expect(t).toMatch(/Hébergement/);
|
||||
for (const interdit of [/MIAW/, /SIREN/, /TVA/, /Téléphone/, /Responsable de la publication/, /siège social/, /RCS/]) {
|
||||
expect(t, String(interdit)).not.toMatch(interdit);
|
||||
}
|
||||
});
|
||||
|
||||
it("les réclament toutes en payant", () => {
|
||||
/**
|
||||
* Ce qui reste, et qui n'est pas négociable : l'adresse de contact. L'art. 13 du RGPD impose de
|
||||
* dire où exercer ses droits, et c'est aussi par là qu'arrivent les signalements d'une règle
|
||||
* inexacte. Une boîte n'est pas une identité d'entreprise.
|
||||
*/
|
||||
it("garde le contact et la nature du service en gratuit", () => {
|
||||
const t = texte(rendre(MentionsLegales, "gratuit"));
|
||||
expect(t).toMatch(/Contact/);
|
||||
expect(t).toMatch(/non professionnel/);
|
||||
expect(t).toMatch(/hébergés .{0,10}en France/);
|
||||
});
|
||||
|
||||
it("réclame toute l'identité en payant", () => {
|
||||
const t = texte(rendre(MentionsLegales, "payant"));
|
||||
expect(t).toMatch(/SIREN/);
|
||||
expect(t).toMatch(/TVA/);
|
||||
expect(t).toMatch(/Téléphone/);
|
||||
for (const attendu of [/MIAW/, /SIREN/, /TVA/, /Téléphone/, /Responsable de la publication/]) {
|
||||
expect(t, String(attendu)).toMatch(attendu);
|
||||
}
|
||||
});
|
||||
|
||||
it("renvoient vers le bon document contractuel", () => {
|
||||
it("renvoie vers le bon document contractuel", () => {
|
||||
expect(texte(rendre(MentionsLegales, "payant"))).toContain("Conditions générales de vente");
|
||||
expect(texte(rendre(MentionsLegales, "gratuit"))).toContain("Conditions générales d'utilisation");
|
||||
});
|
||||
|
|
@ -160,7 +174,7 @@ describe("les mentions légales", () => {
|
|||
* envoyer. Une mention légale qui renvoie vers une information absente est fausse, et le test
|
||||
* est là pour que la prochaine réorganisation de la landing ne la rétablisse pas par accident.
|
||||
*/
|
||||
it("ne renvoient plus les sources vers la page d'accueil", () => {
|
||||
it("ne renvoie plus les sources vers la page d'accueil", () => {
|
||||
for (const mode of ["gratuit", "payant"] as const) {
|
||||
expect(texte(rendre(MentionsLegales, mode)), mode).not.toMatch(
|
||||
/sources utilisées sont nommées sur la page/i,
|
||||
|
|
@ -169,6 +183,26 @@ describe("les mentions légales", () => {
|
|||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* L'encart rouge qui liste ce qui manque.
|
||||
*
|
||||
* En gratuit il ne réclame plus qu'une chose — l'adresse de contact — et jamais l'identité, le
|
||||
* siège ou le SIREN (D-066). Le réclamer apprendrait à l'éditeur à ignorer cet encart, ce qui le
|
||||
* rendrait inutile le jour où il compte.
|
||||
*/
|
||||
describe("l'encart des mentions incomplètes", () => {
|
||||
it("ne réclame que le contact en gratuit", () => {
|
||||
expect(mentionsManquantes("gratuit")).toEqual(["l'adresse de contact"]);
|
||||
});
|
||||
|
||||
it("réclame les neuf mentions en payant", () => {
|
||||
const manques = mentionsManquantes("payant");
|
||||
expect(manques).toContain("le numéro SIREN");
|
||||
expect(manques).toContain("l'identité de l'éditeur");
|
||||
expect(manques.length).toBeGreaterThan(manques.filter((m) => m.includes("contact")).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("la politique de confidentialité", () => {
|
||||
/**
|
||||
* Le point le plus coûteux des trois pages : déclarer un sous-traitant qui ne traite rien
|
||||
|
|
|
|||
|
|
@ -42,72 +42,105 @@ export default function MentionsLegales() {
|
|||
<article className="prose-fokan">
|
||||
<h1>Mentions légales</h1>
|
||||
|
||||
<h2>Éditeur du site</h2>
|
||||
<p>
|
||||
{EDITEUR.siteNom} est édité par <Valeur v={EDITEUR.identite} />, sous la marque{" "}
|
||||
<strong>{EDITEUR.raisonSociale}</strong>
|
||||
{EDITEUR.formeJuridique ? ` (${EDITEUR.formeJuridique})` : null}, dont le siège social
|
||||
est situé <Valeur v={EDITEUR.siege} />.
|
||||
</p>
|
||||
<ul>
|
||||
{/* Le SIREN n'est exigé par l'art. 6 III-1 LCEN que de l'éditeur professionnel : on
|
||||
ne le réclame pas sur un service gratuit — mais on l'affiche s'il existe, car le
|
||||
taire quand on l'a serait retirer une information au lecteur. */}
|
||||
{(!gratuit || EDITEUR.siren) && (
|
||||
<li>
|
||||
SIREN / SIRET : <Valeur v={EDITEUR.siren} />
|
||||
</li>
|
||||
)}
|
||||
{EDITEUR.formeJuridique && (
|
||||
<>
|
||||
<li>
|
||||
RCS : <Valeur v={EDITEUR.rcs} />
|
||||
</li>
|
||||
<li>
|
||||
Capital social : <Valeur v={EDITEUR.capital} />
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{/* Rien à qualifier sans prix : la mention de TVA disparaît entièrement en gratuit. */}
|
||||
{!gratuit && (
|
||||
<li>
|
||||
{/*
|
||||
Deux régimes, deux mentions, et jamais les deux : afficher « TVA intracommunautaire :
|
||||
à compléter » sous franchise en base ferait chercher au lecteur un numéro qui
|
||||
n'existe pas, et annoncer un numéro sous franchise serait une fausse mention.
|
||||
*/}
|
||||
{EDITEUR.regimeTva === "franchise" ? (
|
||||
<>TVA : non applicable, article 293 B du Code général des impôts</>
|
||||
) : (
|
||||
<>
|
||||
TVA intracommunautaire : <Valeur v={EDITEUR.tva} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Responsable de la publication : <Valeur v={EDITEUR.responsablePublication} />
|
||||
</li>
|
||||
<li>
|
||||
Contact : <Valeur v={EDITEUR.email} />
|
||||
</li>
|
||||
{/* Obligatoire en vente à distance (art. L221-5 et R111-1, 4°), sans objet sinon.
|
||||
Affiché malgré tout s'il est renseigné : un moyen de contact de plus ne nuit pas. */}
|
||||
{(!gratuit || EDITEUR.telephone) && (
|
||||
<li>
|
||||
Téléphone : <Valeur v={EDITEUR.telephone} />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{gratuit ? (
|
||||
/*
|
||||
Aucune identité affichée (D-066) : le service est édité sans structure, à titre non
|
||||
professionnel. Ce bloc ne se remplit donc pas de « à compléter » — il n'existe pas.
|
||||
Ce qui reste est ce qui répond aux questions qu'un lecteur se pose vraiment ici :
|
||||
où joindre quelqu'un, où le site tourne, et ce qu'il n'est pas.
|
||||
*/
|
||||
<>
|
||||
<h2>Éditeur du site</h2>
|
||||
<p>
|
||||
{EDITEUR.siteNom} est un service personnel, édité et exploité à titre non
|
||||
professionnel, sans structure commerciale : rien n'y est vendu, aucune
|
||||
publicité n'y est diffusée et aucune donnée n'y est revendue. Les
|
||||
conditions de cet accès gratuit sont détaillées dans les{" "}
|
||||
<Link href="/cgv">{vocabulaire.conditionsTitre.toLowerCase()}</Link>.
|
||||
</p>
|
||||
<p>
|
||||
Contact : <Valeur v={EDITEUR.email} />. C'est la seule adresse du service :
|
||||
une question, l'exercice de tes droits sur tes données, ou le signalement
|
||||
décrit plus bas y arrivent au même endroit.
|
||||
</p>
|
||||
|
||||
<h2>Hébergement</h2>
|
||||
<p>
|
||||
Le site et l'ensemble des données qu'il traite sont hébergés{" "}
|
||||
<strong>en France</strong>, sur un serveur opéré par l'éditeur lui-même. Cet
|
||||
hébergement est assuré par <Valeur v={EDITEUR.hebergeur.nom} />,{" "}
|
||||
<Valeur v={EDITEUR.hebergeur.adresse} />, joignable au{" "}
|
||||
<Valeur v={EDITEUR.hebergeur.telephone} />.
|
||||
</p>
|
||||
<h2>Hébergement</h2>
|
||||
<p>
|
||||
Le site et l'ensemble des données qu'il traite sont hébergés{" "}
|
||||
<strong>en France</strong>, sur un serveur opéré par l'éditeur lui-même.
|
||||
Aucune donnée du foyer n'est confiée à un hébergeur tiers.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Éditeur du site</h2>
|
||||
<p>
|
||||
{EDITEUR.siteNom} est édité par <Valeur v={EDITEUR.identite} />, sous la marque{" "}
|
||||
<strong>{EDITEUR.raisonSociale}</strong>
|
||||
{EDITEUR.formeJuridique ? ` (${EDITEUR.formeJuridique})` : null}, dont le siège social
|
||||
est situé <Valeur v={EDITEUR.siege} />.
|
||||
</p>
|
||||
<ul>
|
||||
{/* Le SIREN n'est exigé par l'art. 6 III-1 LCEN que de l'éditeur professionnel : on
|
||||
ne le réclame pas sur un service gratuit — mais on l'affiche s'il existe, car le
|
||||
taire quand on l'a serait retirer une information au lecteur. */}
|
||||
{(!gratuit || EDITEUR.siren) && (
|
||||
<li>
|
||||
SIREN / SIRET : <Valeur v={EDITEUR.siren} />
|
||||
</li>
|
||||
)}
|
||||
{EDITEUR.formeJuridique && (
|
||||
<>
|
||||
<li>
|
||||
RCS : <Valeur v={EDITEUR.rcs} />
|
||||
</li>
|
||||
<li>
|
||||
Capital social : <Valeur v={EDITEUR.capital} />
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{/* Rien à qualifier sans prix : la mention de TVA disparaît entièrement en gratuit. */}
|
||||
{!gratuit && (
|
||||
<li>
|
||||
{/*
|
||||
Deux régimes, deux mentions, et jamais les deux : afficher « TVA intracommunautaire :
|
||||
à compléter » sous franchise en base ferait chercher au lecteur un numéro qui
|
||||
n'existe pas, et annoncer un numéro sous franchise serait une fausse mention.
|
||||
*/}
|
||||
{EDITEUR.regimeTva === "franchise" ? (
|
||||
<>TVA : non applicable, article 293 B du Code général des impôts</>
|
||||
) : (
|
||||
<>
|
||||
TVA intracommunautaire : <Valeur v={EDITEUR.tva} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Responsable de la publication : <Valeur v={EDITEUR.responsablePublication} />
|
||||
</li>
|
||||
<li>
|
||||
Contact : <Valeur v={EDITEUR.email} />
|
||||
</li>
|
||||
{/* Obligatoire en vente à distance (art. L221-5 et R111-1, 4°), sans objet sinon.
|
||||
Affiché malgré tout s'il est renseigné : un moyen de contact de plus ne nuit pas. */}
|
||||
{(!gratuit || EDITEUR.telephone) && (
|
||||
<li>
|
||||
Téléphone : <Valeur v={EDITEUR.telephone} />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<h2>Hébergement</h2>
|
||||
<p>
|
||||
Le site et l'ensemble des données qu'il traite sont hébergés{" "}
|
||||
<strong>en France</strong>, sur un serveur opéré par l'éditeur lui-même. Cet
|
||||
hébergement est assuré par <Valeur v={EDITEUR.hebergeur.nom} />,{" "}
|
||||
<Valeur v={EDITEUR.hebergeur.adresse} />, joignable au{" "}
|
||||
<Valeur v={EDITEUR.hebergeur.telephone} />.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h2>Objet du site</h2>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ export function Abonnement({
|
|||
/* Aucune date promise : on ne sait pas quand une place se libère, et l'inventer
|
||||
serait pire que le silence (§ 4.5 — pas de compte à rebours). */
|
||||
<>
|
||||
Ton compte existe et ton foyer est calculé. Les accès sont ouverts par vagues ; on
|
||||
t'écrit le jour où le tien s'ouvre — rien à surveiller d'ici là.
|
||||
Ton compte existe et ton foyer est calculé. Les accès s'ouvrent par vagues ;
|
||||
on t'écrit le jour où le tien s'ouvre — rien à surveiller d'ici là.
|
||||
</>
|
||||
) : !isActive ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,7 @@ import type { LigneRappel } from "@/engine/notifications";
|
|||
import type { ContexteLienMagique } from "@/lib/lien-magique";
|
||||
|
||||
/** Tous les contextes du lien magique — la liste vit ici pour qu'un ajout oublié fasse échouer. */
|
||||
const CONTEXTES = [
|
||||
"connexion",
|
||||
"invitation",
|
||||
"activation",
|
||||
"inscription",
|
||||
"attente",
|
||||
"place-liberee",
|
||||
] as const satisfies readonly ContexteLienMagique[];
|
||||
const CONTEXTES = ["connexion", "invitation"] as const satisfies readonly ContexteLienMagique[];
|
||||
|
||||
/**
|
||||
* Les trois gabarits, rendus pour de vrai (audit mail § 4.14 : ils n'avaient aucun test).
|
||||
|
|
@ -229,7 +222,7 @@ describe("renderMagicLinkEmail", () => {
|
|||
// Sans elle, un lien expiré en 15 min laissait un foyer payé inatteignable (§ 4.4).
|
||||
const { html, text } = await renderMagicLinkEmail({
|
||||
url: "https://fokan.fr/api/auth/magic-link/verify?token=x",
|
||||
context: "activation",
|
||||
context: "connexion",
|
||||
urlFoyer: "https://fokan.fr/foyer/abc",
|
||||
});
|
||||
expect(html).toContain("https://fokan.fr/foyer/abc");
|
||||
|
|
@ -262,30 +255,28 @@ describe("renderMagicLinkEmail", () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* Le piège que ce test ferme (D-065) : les trois contextes du mode gratuit passent par la même
|
||||
* table de messages, et « attente » est le seul de la famille à NE PAS annoncer un accès ouvert.
|
||||
* Un copier-coller depuis « inscription » produirait un message intitulé « Ton foyer est ouvert »
|
||||
* suivi d'un lien qui mène à un aperçu verrouillé — ce qui se lit comme une panne, pas comme une
|
||||
* file d'attente, et fait écrire au support quelqu'un qui n'avait aucun problème.
|
||||
* Ce que le message d'accès ne doit JAMAIS affirmer (D-066).
|
||||
*
|
||||
* Il part dans quatre situations que le mail ne distingue plus : paiement passé, inscription
|
||||
* gratuite, entrée en liste d'attente, admission depuis la file. Trois d'entre elles rendraient
|
||||
* fausse toute promesse de déverrouillage — quelqu'un qui attend une place cliquerait sur un
|
||||
* lien menant à un aperçu verrouillé, ce qui se lit comme une panne. Le texte ne promet donc
|
||||
* qu'une chose, vraie partout : un lien vers SON foyer.
|
||||
*/
|
||||
it("ne promet un accès ouvert dans aucun des messages d'attente", async () => {
|
||||
const { text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context: "attente" });
|
||||
expect(text.toLowerCase()).toContain("liste d'attente");
|
||||
expect(text.toLowerCase()).not.toMatch(/déverrouillé|est ouvert\b/);
|
||||
it("ne promet ni déverrouillage, ni place, ni paiement", async () => {
|
||||
const { text } = await renderMagicLinkEmail({
|
||||
url: "https://fokan.fr/x",
|
||||
context: "connexion",
|
||||
urlFoyer: "https://fokan.fr/foyer/abc",
|
||||
});
|
||||
expect(text.toLowerCase()).not.toMatch(/déverrouill|liste d'attente|place|paiement|payé|abonnement/);
|
||||
});
|
||||
|
||||
it("dit à l'admis pourquoi il reçoit un message qu'il n'a pas demandé", async () => {
|
||||
const { text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context: "place-liberee" });
|
||||
expect(text.toLowerCase()).toContain("liste d'attente");
|
||||
expect(text.toLowerCase()).toContain("ouvert");
|
||||
});
|
||||
|
||||
/** Aucun contexte ne doit parler de paiement en dehors de celui qui en vient. */
|
||||
it("ne parle de paiement que dans le contexte du paiement", async () => {
|
||||
/** Le message est le même en gratuit et en payant : rien dedans ne dépend du modèle. */
|
||||
it("ne nomme jamais le modèle économique, quel que soit le contexte", async () => {
|
||||
for (const context of CONTEXTES) {
|
||||
const { text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context });
|
||||
if (context === "activation") continue;
|
||||
expect(text.toLowerCase(), context).not.toMatch(/paiement|payé|abonnement/);
|
||||
expect(text.toLowerCase(), context).not.toMatch(/gratuit|€|19,99|tarif/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ACCENT, Bouton, MailLayout, SOURDINE, baseUrl, styleTexte, styleTitre }
|
|||
type Props = {
|
||||
url: string;
|
||||
/** Ton différent selon le contexte — jamais dans le quiz, toujours au bon moment (§ 5.6, § 6.3). */
|
||||
context: "connexion" | "invitation" | "activation" | "inscription" | "attente" | "place-liberee";
|
||||
context: "connexion" | "invitation";
|
||||
invitedByEmail?: string;
|
||||
/**
|
||||
* L'URL durable du foyer, affichée sous le bouton quand elle est connue (audit mail § 4.4).
|
||||
|
|
@ -19,15 +19,18 @@ type Props = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Le titre et le corps, par contexte.
|
||||
* Le titre et le corps, par contexte — il n'en reste que deux (D-066).
|
||||
*
|
||||
* Trois contextes sont nés avec le mode gratuit (D-065), et l'un d'eux — « attente » — est le
|
||||
* seul de la famille à ne PAS annoncer un accès ouvert. Il faut donc qu'il le dise franchement
|
||||
* dans son titre : un message intitulé « Ton foyer est ouvert » suivi d'un lien qui mène à un
|
||||
* aperçu verrouillé se lit comme une panne, pas comme une file d'attente.
|
||||
* **Le message d'accès est volontairement générique.** Il servait auparavant quatre situations
|
||||
* distinctes (paiement passé, inscription gratuite, entrée en liste d'attente, admission depuis
|
||||
* la file) avec quatre textes, dont trois pouvaient devenir faux : « ton calendrier est
|
||||
* déverrouillé » ment à qui attend une place, « ta place s'est libérée » suppose que le lecteur
|
||||
* se souvienne d'avoir patienté. Un seul texte qui ne promet que ce qu'il tient — un lien vers
|
||||
* TON foyer — reste vrai dans les quatre, et laisse la page dire l'état, ce qu'elle fait mieux
|
||||
* qu'un objet de message.
|
||||
*
|
||||
* « place-liberee » est l'inverse : il arrive sans que personne ne l'ait demandé ce jour-là, et
|
||||
* doit donc rappeler en un mot pourquoi il arrive.
|
||||
* C'est aussi ce qui rend le message identique en gratuit et en payant : il ne parle ni de prix,
|
||||
* ni d'abonnement, ni de place. La bascule de mode ne le touche pas.
|
||||
*/
|
||||
const MESSAGES: Record<Props["context"], { titre: string; corps: string; bouton: string }> = {
|
||||
invitation: {
|
||||
|
|
@ -36,35 +39,11 @@ const MESSAGES: Record<Props["context"], { titre: string; corps: string; bouton:
|
|||
"Un tap et tu deviens membre à part entière du foyer : tu recevras directement tes propres rappels, sans jamais passer par quelqu'un d'autre.",
|
||||
bouton: "Rejoindre le foyer",
|
||||
},
|
||||
activation: {
|
||||
titre: "Ton foyer est activé sur fokan",
|
||||
corps:
|
||||
"Le paiement est passé, ton compte existe et ton calendrier est complet. Un tap et tu y es, sans mot de passe.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
inscription: {
|
||||
titre: "Ton foyer est ouvert sur fokan",
|
||||
corps:
|
||||
"Ton compte existe et ton calendrier est complet — dates, raisons, périmètres, tout est déverrouillé. Un tap et tu y es, sans mot de passe.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
attente: {
|
||||
titre: "Tu es sur la liste d'attente de fokan",
|
||||
corps:
|
||||
"Ton compte est créé et le calendrier de ton foyer est calculé : il t'attend. Le nombre d'accès ouverts est plafonné pour l'instant, et le tien s'ouvrira dès qu'une place se libère — on t'écrira ce jour-là, tu n'as rien à surveiller.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
"place-liberee": {
|
||||
titre: "Ta place s'est libérée sur fokan",
|
||||
corps:
|
||||
"Tu étais sur la liste d'attente : c'est fait, ton accès est ouvert. Le calendrier de ton foyer est déverrouillé — dates, raisons et périmètres compris. Un tap et tu y es.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
connexion: {
|
||||
titre: "Ton lien de connexion fokan",
|
||||
corps:
|
||||
"Ce lien te connecte directement, sans mot de passe. Il expire dans quelques minutes et ne sert qu'une fois.",
|
||||
bouton: "Me connecter",
|
||||
"Ce lien t'ouvre ton foyer directement, sans mot de passe. Il expire dans quelques minutes et ne sert qu'une fois.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -53,10 +53,8 @@ export const auth = betterAuth({
|
|||
*/
|
||||
const subjects: Record<ContexteLienMagique, string> = {
|
||||
invitation: "Tu es invité·e à rejoindre un foyer sur fokan",
|
||||
activation: "Ton foyer est activé — accède à ton calendrier",
|
||||
inscription: "Ton foyer est ouvert — accède à ton calendrier",
|
||||
attente: "Tu es sur la liste d'attente de fokan",
|
||||
"place-liberee": "Ta place s'est libérée — ton calendrier est ouvert",
|
||||
// Le même objet pour toute ouverture d'accès, quel que soit le mode et quel que soit
|
||||
// l'état de la file (D-066) : il ne peut donc jamais démentir ce que la page affiche.
|
||||
connexion: "Ton lien de connexion fokan",
|
||||
};
|
||||
const { sent } = await sendMail({ to: email, subject: subjects[context], html, text });
|
||||
|
|
|
|||
|
|
@ -75,14 +75,14 @@ export async function inscrireFoyer({
|
|||
// Déjà ouvert : on ne réécrit ni le statut ni la date d'ouverture — l'état utilisateur est
|
||||
// inviolable (invariant n° 3), et « acces_le » est une date d'histoire, pas un compteur.
|
||||
if (foyer.statut === "active") {
|
||||
await envoyerLien(email, householdId, urlFoyer, "connexion", entetes);
|
||||
await envoyerLien(email, householdId, urlFoyer, entetes);
|
||||
return { statut: "deja-ouvert" };
|
||||
}
|
||||
|
||||
// Déjà en attente : même chose, on renvoie seulement le lien et son rang. Réécrire
|
||||
// `attente_depuis` ferait reculer dans la file quelqu'un qui redemande son lien.
|
||||
if (foyer.statut === "attente") {
|
||||
await envoyerLien(email, householdId, urlFoyer, "attente", entetes);
|
||||
await envoyerLien(email, householdId, urlFoyer, entetes);
|
||||
return { statut: "attente", rang: (await rangEnAttente(householdId)) ?? 1 };
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ export async function inscrireFoyer({
|
|||
})
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
|
||||
await envoyerLien(email, householdId, urlFoyer, "inscription", entetes);
|
||||
await envoyerLien(email, householdId, urlFoyer, entetes);
|
||||
return { statut: "ouvert" };
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ export async function inscrireFoyer({
|
|||
})
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
|
||||
await envoyerLien(email, householdId, urlFoyer, "attente", entetes);
|
||||
await envoyerLien(email, householdId, urlFoyer, entetes);
|
||||
return { statut: "attente", rang: (await rangEnAttente(householdId)) ?? 1 };
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +133,6 @@ async function envoyerLien(
|
|||
email: string,
|
||||
householdId: string,
|
||||
urlFoyer: string,
|
||||
context: "inscription" | "attente" | "connexion",
|
||||
entetes: Headers,
|
||||
): Promise<void> {
|
||||
await auth.api
|
||||
|
|
@ -141,7 +140,7 @@ async function envoyerLien(
|
|||
body: {
|
||||
email,
|
||||
callbackURL: `/foyer/${householdId}`,
|
||||
metadata: metadataInterne(context, { urlFoyer }),
|
||||
metadata: metadataInterne("connexion", { urlFoyer }),
|
||||
},
|
||||
headers: entetes,
|
||||
})
|
||||
|
|
@ -248,7 +247,7 @@ async function previenirAdmission(householdId: string, userId: string | null): P
|
|||
body: {
|
||||
email: compte.email,
|
||||
callbackURL: `/foyer/${householdId}`,
|
||||
metadata: metadataInterne("place-liberee", { urlFoyer: `${base}/foyer/${householdId}` }),
|
||||
metadata: metadataInterne("connexion", { urlFoyer: `${base}/foyer/${householdId}` }),
|
||||
},
|
||||
// Le passage tourne dans le cron : aucune requête entrante, donc aucun en-tête à relayer.
|
||||
headers: new Headers(),
|
||||
|
|
|
|||
|
|
@ -56,11 +56,20 @@ const mediateurNom = env("LEGAL_MEDIATEUR_NOM");
|
|||
const mediateurUrl = env("LEGAL_MEDIATEUR_URL");
|
||||
|
||||
export const EDITEUR = {
|
||||
/** Le site lui-même. */
|
||||
/** Le site lui-même. Le seul nom qui s'affiche en mode gratuit (D-066). */
|
||||
siteNom: "fokan",
|
||||
siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr",
|
||||
|
||||
// ── Identité juridique — à compléter avant toute mise en ligne commerciale ──
|
||||
/**
|
||||
* La marque sous laquelle le service est édité.
|
||||
*
|
||||
* ⚠️ **Ne s'affiche qu'en mode payant** (D-066) : en gratuit, le service ne se présente que
|
||||
* sous son propre nom, et les trois pages légales disent « fokan » là où elles disaient
|
||||
* « MIAW ». Rattacher un service gratuit à une marque qui n'existe pas juridiquement ferait
|
||||
* exactement l'inverse de ce que ces pages servent — annoncer une structure derrière laquelle
|
||||
* il n'y a rien.
|
||||
*/
|
||||
raisonSociale: "MIAW",
|
||||
/**
|
||||
* L'identité de l'éditeur : nom et prénom d'une personne physique, ou dénomination sociale.
|
||||
|
|
@ -172,18 +181,33 @@ export const A_COMPLETER = "à compléter";
|
|||
* hébergeur TIERS détienne l'identité, alors que le service est auto-hébergé (§ 22).
|
||||
*/
|
||||
export function mentionsManquantes(mode: "gratuit" | "payant"): string[] {
|
||||
/**
|
||||
* **En gratuit, une seule mention est réclamée : l'adresse de contact.**
|
||||
*
|
||||
* Le service est édité sans structure, à titre non professionnel, et n'affiche donc aucune
|
||||
* identité — ni raison sociale, ni siège, ni responsable de publication, ni hébergeur. Ce
|
||||
* choix est celui de l'éditeur, et il faut le nommer plutôt que le maquiller : l'art. 6 III-2
|
||||
* LCEN, qui ouvre l'anonymat aux éditeurs NON professionnels, le subordonne à ce qu'un
|
||||
* hébergeur tiers détienne l'identité — condition que le service, auto-hébergé, ne remplit
|
||||
* pas. Le produit n'a donc pas de raison de réclamer ces champs à un écran ; il n'a pas non
|
||||
* plus de raison de prétendre que leur absence est sans conséquence.
|
||||
*
|
||||
* L'adresse de contact, elle, n'est pas négociable et n'a rien d'une identité d'entreprise :
|
||||
* c'est une boîte. L'art. 13 du RGPD impose de dire au lecteur où exercer ses droits, et une
|
||||
* politique de confidentialité qui n'indique aucun destinataire ne vaut rien — c'est aussi par
|
||||
* là qu'arrivent les signalements d'une règle inexacte, qui profitent à tous les foyers.
|
||||
*/
|
||||
const manques: string[] = [];
|
||||
if (!EDITEUR.email) manques.push("l'adresse de contact");
|
||||
if (mode === "gratuit") return manques;
|
||||
|
||||
if (!EDITEUR.identite) manques.push("l'identité de l'éditeur");
|
||||
if (!EDITEUR.siege) manques.push("l'adresse du siège");
|
||||
if (!EDITEUR.email) manques.push("l'adresse de contact");
|
||||
if (!EDITEUR.responsablePublication) manques.push("le responsable de la publication");
|
||||
if (!EDITEUR.hebergeur.nom || !EDITEUR.hebergeur.adresse) manques.push("l'hébergeur");
|
||||
|
||||
if (mode === "payant") {
|
||||
if (!EDITEUR.siren) manques.push("le numéro SIREN");
|
||||
if (!EDITEUR.telephone) manques.push("le téléphone");
|
||||
if (!EDITEUR.mediateur) manques.push("le médiateur de la consommation");
|
||||
if (!EDITEUR.regimeTva) manques.push("le régime de TVA");
|
||||
}
|
||||
if (!EDITEUR.siren) manques.push("le numéro SIREN");
|
||||
if (!EDITEUR.telephone) manques.push("le téléphone");
|
||||
if (!EDITEUR.mediateur) manques.push("le médiateur de la consommation");
|
||||
if (!EDITEUR.regimeTva) manques.push("le régime de TVA");
|
||||
return manques;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ afterEach(() => {
|
|||
|
||||
describe("lireMetadata", () => {
|
||||
it("honore un contexte scellé par un appelant interne", () => {
|
||||
expect(lireMetadata(metadataInterne("activation", { urlFoyer: "https://fokan.fr/foyer/abc" }))).toEqual({
|
||||
context: "activation",
|
||||
expect(lireMetadata(metadataInterne("connexion", { urlFoyer: "https://fokan.fr/foyer/abc" }))).toEqual({
|
||||
context: "connexion",
|
||||
invitedByEmail: undefined,
|
||||
urlFoyer: "https://fokan.fr/foyer/abc",
|
||||
});
|
||||
|
|
@ -40,10 +40,19 @@ describe("lireMetadata", () => {
|
|||
|
||||
/**
|
||||
* Le cas d'attaque exact : un POST non authentifié sur /api/auth/sign-in/magic-link avec un
|
||||
* `metadata` forgé. Sans sceau, le message affirmait un paiement qui n'a pas eu lieu.
|
||||
* `metadata` forgé.
|
||||
*
|
||||
* Il visait à l'origine le contexte « activation », qui affirmait un paiement n'ayant pas eu
|
||||
* lieu. Ce contexte n'existe plus (D-066) — tous les messages d'accès sont devenus génériques —
|
||||
* et **l'attaque a changé de cible sans disparaître** : `urlFoyer` est rendu en lien cliquable
|
||||
* dans le mail, donc un `metadata` non scellé qui le poserait ferait partir depuis
|
||||
* `noreply@fokan.fr`, domaine aligné SPF/DKIM/DMARC, un message contenant le lien de
|
||||
* l'attaquant. C'est ce que ce test ferme.
|
||||
*/
|
||||
it("refuse un contexte « activation » non scellé", () => {
|
||||
expect(lireMetadata({ context: "activation" }).context).toBe("connexion");
|
||||
it("jette l'adresse de foyer d'un metadata non scellé", () => {
|
||||
expect(lireMetadata({ context: "connexion", urlFoyer: "https://site-pirate.fr/x" })).toEqual({
|
||||
context: "connexion",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuse une invitation non scellée, et jette l'adresse qu'elle prétendait afficher", () => {
|
||||
|
|
@ -52,17 +61,17 @@ describe("lireMetadata", () => {
|
|||
});
|
||||
|
||||
it("refuse un sceau qui n'est pas le bon", () => {
|
||||
expect(lireMetadata({ sceau: "interne:devine", context: "activation" }).context).toBe("connexion");
|
||||
expect(lireMetadata({ sceau: "interne:devine", context: "invitation" }).context).toBe("connexion");
|
||||
});
|
||||
|
||||
it("refuse un sceau volé à une autre configuration", () => {
|
||||
const scelleAilleurs = metadataInterne("activation");
|
||||
const scelleAilleurs = metadataInterne("invitation");
|
||||
process.env.BETTER_AUTH_SECRET = "un-autre-secret";
|
||||
expect(lireMetadata(scelleAilleurs).context).toBe("connexion");
|
||||
});
|
||||
|
||||
it("refuse tout contexte quand aucun secret n'est configuré — le sceau serait devinable", () => {
|
||||
const scelle = metadataInterne("activation");
|
||||
const scelle = metadataInterne("invitation");
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
expect(lireMetadata(scelle).context).toBe("connexion");
|
||||
expect(sceauInterne()).toBe("interne:");
|
||||
|
|
|
|||
|
|
@ -19,28 +19,25 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* Les contextes légitimes. Les trois derniers sont ceux du mode gratuit (D-065) — et ils sont
|
||||
* soumis exactement au même sceau que les autres, pour la même raison : sans lui, n'importe qui
|
||||
* pourrait faire partir depuis `noreply@fokan.fr` un « ta place s'est libérée » avec un lien de
|
||||
* connexion qui fonctionne.
|
||||
* Deux contextes, et c'est tout (D-066).
|
||||
*
|
||||
* Il y en a eu jusqu'à six : `activation` (« le paiement est passé »), puis `inscription`,
|
||||
* `attente` et `place-liberee` pour le mode gratuit. Tous les quatre sont retombés sur
|
||||
* `connexion`, et ce n'est pas une simplification cosmétique — c'est la seule façon d'écrire un
|
||||
* message qui reste vrai dans les quatre situations. Un mail qui annonce « ton calendrier est
|
||||
* déverrouillé » est faux pour qui attend une place ; un mail qui dit « ta place s'est libérée »
|
||||
* suppose que le lecteur sache qu'il était dans une file, et transforme une bonne nouvelle en
|
||||
* rappel qu'il a patienté. Le lien, lui, mène au même endroit dans tous les cas : son foyer, qui
|
||||
* lui dira son état mieux qu'un objet de message.
|
||||
*
|
||||
* **Effet de bord heureux sur la sécurité** : `invitation` est désormais le seul contexte qui
|
||||
* AFFIRME quelque chose (« untel t'invite »), donc le seul que le sceau interne doive protéger.
|
||||
* Un message générique n'a plus rien à usurper.
|
||||
*/
|
||||
export type ContexteLienMagique =
|
||||
| "connexion"
|
||||
| "invitation"
|
||||
| "activation"
|
||||
| "inscription"
|
||||
| "attente"
|
||||
| "place-liberee";
|
||||
export type ContexteLienMagique = "connexion" | "invitation";
|
||||
|
||||
/** L'ensemble des contextes qu'on accepte de lire, dérivé du type pour qu'aucun n'y manque. */
|
||||
const CONTEXTES: ReadonlySet<string> = new Set<ContexteLienMagique>([
|
||||
"connexion",
|
||||
"invitation",
|
||||
"activation",
|
||||
"inscription",
|
||||
"attente",
|
||||
"place-liberee",
|
||||
]);
|
||||
const CONTEXTES: ReadonlySet<string> = new Set<ContexteLienMagique>(["connexion", "invitation"]);
|
||||
|
||||
export type MetadataLienMagique = {
|
||||
context: ContexteLienMagique;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue