Aller au contenu principal

Journal de bord · Phase 0 · 4/7

La préinscription de bout en bout

Formulaire optimiste React 19, anti-bot sans captcha, quatre statuts pour un POST et un compteur qui ne compte que les confirmés : le parcours de préinscription d'Aubia, arbitrage par arbitrage.

Publié le 3 août 20266 min de lecture
  • waitlist
  • react
  • security
  • gdpr

L'article précédent montrait qu'aubia.dev n'a aucune base de données, et que l'inscription part vers aubia.cloud par un proxy signé. Ici, je remonte d'un cran pour raconter le parcours complet, du champ email jusqu'à l'email de confirmation, et surtout les arbitrages derrière chaque décision. Une préinscription paraît triviale. J'en ai fait le morceau le plus soigné de la vitrine.

Le flux animé du double opt-in : POST du visiteur vers aubia.dev, transmission signée HMAC vers api.aubia.cloud, email avec token à usage unique, clic de confirmation sous 48 heures.

Un formulaire optimiste

Le formulaire est un composant React 19 branché sur Inertia. Rien ne recharge : à la soumission, un signal optimiste part vers un toast global, construit avec le hook useOptimistic de React 19. L'interface confirme immédiatement, puis le retour serveur réel réconcilie l'état, sans rien faire en cas de succès et en annulant l'affichage en cas d'erreur.

Un détail soigne la saisie : au moment où vous quittez le champ, une routine de suggestion repère les fautes de frappe courantes dans le domaine et propose une correction cliquable, du genre gmial.com corrigé en gmail.com. Rien de tout cela n'est cosmétique. Chaque email mal tapé est un inscrit perdu qui ne recevra jamais sa confirmation.

L'accessibilité suit la même exigence : label associé, aria-invalid sur le champ en erreur, role="alert" sur les messages, et une suggestion reliée par aria-describedby.

Anti-bot sans friction : honeypot plutôt que captcha

Pas de captcha, pour commencer. Un captcha protège des robots en taxant chaque humain d'une corvée, et il fait entrer un tiers dans le parcours. J'ai préféré deux pièges invisibles. Le premier est un champ leurre, caché en CSS, que seul un robot remplit et que le serveur rejette s'il n'est pas vide. Le second est un piège temporel : un horodatage posé au montage de la page, comparé à l'instant de la soumission. Un robot qui poste dans la foulée du chargement se trahit tout seul.

public function rules(): array
{
    return [
        'email' => ['required', 'email:rfc', 'max:254'],
        '_fax' => ['prohibited'],              // champ leurre, doit rester vide
        'rendered_at' => ['required', 'integer', 'min:1'],
        'locale' => ['required', 'in:fr,en,es,de,it,pt'],
        // ... utm nullable
    ];
}

public function withValidator(Validator $validator): void
{
    $validator->after(function (Validator $validator): void {
        $nowMs = (int) (microtime(true) * 1000);

        if (($nowMs - (int) $this->input('rendered_at')) < self::MIN_FILL_DURATION_MS) {
            $validator->errors()->add('rendered_at', 'Soumission trop rapide.');
        }
    });
}

Un throttle nommé complète le dispositif côté serveur, avec trois limites simultanées : une par adresse IP, une plus stricte par adresse email, et un plafond global qui absorbe une attaque distribuée. Vous ne voyez rien de tout ça. Un robot, lui, se cogne aux trois d'un coup.

Quatre statuts pour un seul POST

Une même action, s'inscrire, recouvre plusieurs situations réelles. Le cloud les distingue et la vitrine les traduit en messages précis, plutôt qu'un vague « c'est envoyé ». Le proxy lit le statut renvoyé par aubia.cloud et le mappe sur un enum métier.

return match (true) {
    $httpStatus >= 500 => self::CloudDown,
    $cloudStatus === 'confirmation_sent' => self::ConfirmationSent,
    $cloudStatus === 'confirmation_resent' => self::ConfirmationResent,
    $cloudStatus === 'already_pending' => self::AlreadyPending,
    $cloudStatus === 'already_confirmed' => self::AlreadyConfirmed,
    default => self::CloudDown,
};

Quatre issues de succès : une nouvelle inscription, un renvoi de confirmation après un délai, une inscription déjà en attente, une inscription déjà confirmée. Chacune mérite son message, parce qu'un visiteur qui a déjà confirmé et qui se réinscrit ne doit pas croire qu'il vient de repartir de zéro.

Le double opt-in, ou pourquoi je préfère une liste plus petite

Vient le choix qui structure tout le reste. J'aurais pu enregistrer chaque email et considérer l'inscription faite. J'ai choisi le double opt-in : le cloud envoie un email avec un lien de confirmation à usage unique, valable quarante-huit heures, et tant que ce lien n'est pas cliqué, aucune communication ne part. Les inscriptions non confirmées sont purgées après trente jours.

Ce choix rétrécit volontairement la liste. C'est le but. Une liste de mille adresses réellement confirmées vaut mieux qu'une liste de cinq mille dont je ne sais rien. Elle prouve un consentement daté, elle valide que l'adresse existe, et elle garantit qu'au lancement de la beta 0.1 je m'adresse à des gens qui ont vraiment levé la main. La qualité de la liste prime sur son volume affiché.

Une confirmation en plusieurs issues, hors index

Le clic sur le lien atterrit sur une page dédiée d'aubia.dev. Elle lit le token dans l'URL, interroge le cloud, et affiche l'une de cinq issues : confirmé, déjà confirmé, lien invalide, introuvable, ou expiré. Cette page, comme celle de désinscription, est explicitement retirée de l'index des moteurs.

<SeoHead
    path="/waitlist/confirmed"
    robots="noindex"
    title={t('waitlist.confirmed.seo_title')}
/>

Ces URL portent un token à usage unique et n'ont aucune valeur en recherche. Les exclure de l'index évite qu'un lien de confirmation traîne dans des résultats et protège la propreté du référencement. La désinscription, elle, se fait en un clic depuis chaque email, conformément à la norme RFC 8058, et sa logique vit côté cloud, jamais sur la vitrine.

La sécurité de cette page tient à la sémantique du token lui-même : usage unique, haché côté cloud, effacé après consommation, expiré au bout de quarante-huit heures.

Un compteur qui ne compte que les confirmés

Reste la preuve sociale. La landing affiche un nombre d'inscrits, mais il ne compte que les personnes ayant confirmé, jamais les adresses en attente. Et il ne s'affiche pas tant qu'un seuil minimal n'est pas atteint : en dessous, le composant ne rend rien. Mieux vaut ne pas afficher de chiffre que d'en afficher un faible et décourageant en début de campagne. Un compteur honnête ne peut pas jouer sur les deux tableaux. Soit il gonfle le nombre avec des inscriptions non confirmées et il ment, soit il ne montre que le réel.

Toute la logique de préinscription penche du même côté : le réel, mesuré sobrement, et rien qui ne soit prouvable. Côté RGPD, cela se résume à une phrase sous le formulaire, une finalité unique, l'invitation à la beta, et aucune donnée personnelle au repos sur la vitrine.

Tout ça pour un champ email

Ce parcours mobilise plusieurs briques : un formulaire, une validation, un enum de statuts, un proxy, un compteur. Sur un si petit repo, tout cela mérite d'être rangé avec méthode plutôt que dispersé. Cette organisation en modules, je vous l'ouvre juste après.

Pour être prévenu à l'ouverture de la beta 0.1, rejoignez la liste d'attente.

Ce chantier se raconte ici, article après article. Embarquez dans l'aventure : vos retours dessineront la suite.

Rejoindre la liste d'attente