Auth & Onboarding
Socle d'authentification
Better Auth est le magasin d'identité canonique de la plateforme. Les données d'authentification sont stockées dans PostgreSQL via Prisma dans trois tables principales :
| Table Prisma | Modèle | Rôle |
|---|---|---|
user | User | Compte utilisateur, rôle, statut, CGU |
session | Session | Sessions actives avec expiration |
account | Account | Comptes liés (email/mot de passe, providers OAuth) |
La table user porte également les champs applicatifs role, actorType, organizationId, status, cguVersionAccepted et cguAcceptedAt.
Better Auth gère la vérification email avec auto-connexion après clic (emailVerification.autoSignInAfterVerification=true) : l'utilisateur est directement authentifié et redirigé par le middleware frontend vers l'onboarding ou son workspace.
Types d'acteurs et rôles
Quatre rôles applicatifs sont définis dans app/mypromo/server/modules/auth/domain/roles.ts :
export const APP_ROLES = ['admin', 'distributor', 'industrial', 'printer'] as const
| Rôle | Workspace | Permissions principales |
|---|---|---|
admin | /admin | Gestion plateforme complète, revue KYB, gestion utilisateurs et organisations |
industrial | /industrial | Recherche catalogue, création et suivi de campagnes |
distributor | /distributor | Gestion catalogue propre (magasins, espaces), revue campagnes |
printer (imprimeur) | /printer | Consultation commandes, suivi billing |
Le rôle printer (imprimeur) est un acteur de production invité par l'admin. Il n'a pas de parcours d'onboarding standard : son profil est géré via PrinterSupplierProfile (table auth_printer_supplier_profiles) et ses permissions sont limitées à campaigns.view.own et billing.view.own.
Rôles de membership organisation
Au-delà du rôle applicatif, l'accès aux ressources d'une organisation est contrôlé par le rôle de membership :
export const ORGANIZATION_MEMBERSHIP_ROLES = ['manager', 'member', 'billing'] as const
| Rôle | Permissions dans l'organisation |
|---|---|
manager | CRUD complet, invitation/retrait de membres, soumission KYB |
member | Lecture des ressources, actions non-critiques |
billing | Accès aux données de facturation |
L'autorisation effective combine (platformRole, organizationMembershipRole, organizationId). Les gardes échouent de façon fermée : un membership absent ou invalide retourne 403.
Modèle organisation
organizations ← source de vérité (actorType, kybReview, legal)
organization_members ← memberships actifs (manager / member / billing)
organization_billing_profiles ← profil facturation + identité signataire
L'organizationId est un identifiant métier stable : <actorType>:<siren> (ex. industrial:123456789).
Flux d'inscription et d'onboarding
Étapes du funnel
Le middleware app/mypromo/app/middleware/onboarding.global.ts appelle GET /api/me à chaque navigation et évalue un OnboardingNextStep :
Étape (nextStep) | Route cible | Condition |
|---|---|---|
auth | /auth | Utilisateur non authentifié |
email_verification | /auth/email-verification | Email non vérifié |
company_onboarding | /onboarding?resume=1 | Profil organisation incomplet |
signature_pending | /onboarding-signature-pending | Enveloppe de signature onboarding non signée |
admin_approval_pending | /onboarding-admin-approval-pending | KYB non approuvé |
actor_workspace | /industrial, /distributor ou /printer | Onboarding complet |
La logique de gating est dans app/mypromo/server/modules/auth/application/funnel.ts. Le nextStep signature_pending est injecté par app/mypromo/server/api/me.get.ts quand une signature est en attente et que l'étape serait autrement actor_workspace ou admin_approval_pending.
Onboarding industriel (4 étapes)
- Identification SIRET — vérification via Pappers (
PAPPERS_API_TOKEN) avec cache local 14 jours (auth_company_verification_caches). - Identification du signataire — données utilisées pour le contrat Yousign.
- Coordonnées de facturation.
- Coordonnées bancaires (SEPA).
À la soumission, le backend écrit organizations + organization_members + organization_billing_profiles et déclenche la création automatique de la demande de signature électronique.
Onboarding distributeur (4 étapes)
- Vérification SIREN + sélection enseigne.
- Identification du signataire.
- Détails facturation pour compte.
- Coordonnées bancaires.
Le distributeur suit le même gating de signature que l'industriel une fois l'onboarding soumis.
Duplication SIREN distributeur
Si le même organizationId = distributor:<siren> existe déjà et que l'utilisateur n'est pas membre, la soumission retourne 409. Une exception existe pour l'enrichissement de profil par un distributeur déjà onboardé.
Vérification d'identité Pappers (KYB)
Le service de vérification (verifyCompanyBySiren) utilise un CompanyDataProvider port implémenté par un adaptateur Pappers. Les données retournées incluent les signataires avec autorité de signature (hors commissaires aux comptes) pour préremplir l'identité du signataire. Les représentants personnes morales sont traversés récursivement via leur SIREN propre.
Variable d'environnement requise : PAPPERS_API_TOKEN.
Gating KYB
La table organizations.kybReview est la source de vérité du statut de revue KYB. Le cycle de vie admin est calculé par app/mypromo/server/modules/kyb-core/application/derive-lifecycle.ts :
KybLifecycleStatus | Signification |
|---|---|
onboarding_incomplete | Données d'onboarding incomplètes |
signature_pending | En attente de signature électronique |
approval_pending | En attente de validation admin |
approved | Accès complet selon membership |
rejected | Flux correction/resoumission activé |
Statuts de revue (KybReviewStatus) : submitted, in_review, approved, rejected.
En statut submitted ou in_review, l'accès workspace est accordé en lecture. Les actions critiques (publication campagne, publication espace, exports facturation) sont bloquées côté API.
Magic link
Le parcours magic link est accessible depuis /auth via le bouton "Recevoir un lien magique" (POST /api/auth/sign-in/magic-link).
Politique 2FA : le magic link est interdit pour les comptes avec 2FA activée. Un contrôle serveur via assertMagicLinkAllowedForUser rejette la demande avec le code MAGIC_LINK_2FA_BLOCKED. La page /auth affiche un message dédié orientant l'utilisateur vers le login mot de passe + 2FA.
Événements d'audit émis :
auth.magic_link.request— à la demandeauth.magic_link.clicked— au clic du lien
Variables d'environnement requises :
| Variable | Rôle |
|---|---|
BETTER_AUTH_SECRET | Secret JWT (minimum 32 caractères) |
BETTER_AUTH_URL | URL canonique de l'application |
APP_BASE_URL | Base URL pour les liens dans les emails |
Invitation imprimeur
Un imprimeur n'a pas de parcours d'inscription : l'admin le provisionne depuis /admin/imprimeries. provisionImprimerie crée l'organisation, le compte Better Auth (mot de passe aléatoire, emailVerified: true, rôle printer), la membership manager et le PrinterSupplierProfile, puis déclenche auth.api.requestPasswordReset. Le lien d'invitation est un lien de réinitialisation de mot de passe standard — il n'existe pas de token d'invitation distinct.
Durée de validité du lien
| Situation | Validité |
|---|---|
| Invitation initiale ou renvoi admin, tant que le compte n'est pas activé | 7 jours |
| Invitation restée en attente plus de 14 jours sans renvoi | 1 heure |
| « Mot de passe oublié » d'un imprimeur déjà activé | 1 heure |
| Tous les autres rôles | 1 heure |
Better Auth n'expose resetPasswordTokenExpiresIn que comme constante globale : la durée ne peut pas dépendre du destinataire. La prolongation est donc appliquée après coup dans le callback sendResetPassword, qui reçoit le token généré — la ligne verification correspondante est déjà écrite à cet instant. extendPendingPrinterInvitationToken la retrouve par son identifiant reset-password:<token> et repousse expiresAt.
Le TTL long n'est accordé que si un PrinterSupplierProfile porte cet email avec invitationActivatedAt = null. accountEmail est normalisé en minuscules à l'écriture (normalizeAccountEmail), comme Better Auth le fait pour l'email du compte ; le rapprochement reste malgré tout insensible à la casse, pour les profils écrits avant cette normalisation. Le plafond de 14 jours, mesuré depuis le dernier envoi (invitationLastResendAt sinon invitationSentAt), empêche qu'une invitation oubliée — ou qu'un profil devenu inactivable parce que la membership a été révoquée — continue indéfiniment de produire des liens longue durée. Un renvoi admin rouvre la fenêtre.
Un échec de la prolongation n'interrompt jamais l'envoi de l'email : l'erreur est journalisée (scope: auth) et le lien reste valable 1 heure.
Lien expiré
Quand le token est refusé, Better Auth redirige vers /reset-password?error=INVALID_TOKEN, sans paramètre token. La page distingue trois états via resolveResetPasswordLinkState (app/utils/auth-feedback.ts) :
| État | Origine | Affichage |
|---|---|---|
ready | ?token=… présent | Formulaire de nouveau mot de passe |
expired | ?error=INVALID_TOKEN | Alerte « Lien expiré » + bouton « Demander un nouveau lien » |
missing | Page ouverte sans lien | Alerte « Token manquant » + le même bouton |
Le bouton pointe vers /auth?resetPassword=1 : au montage, la page /auth ouvre directement la modale « Mot de passe oublié ». Cela vaut pour tous les rôles, pas seulement l'imprimeur — sans cette sortie, un lien expiré obligeait à contacter un admin.
Activation
Au premier changement de mot de passe, onPasswordReset bascule invitationActivatedAt pour les profils en attente dont l'utilisateur est membre actif de l'organisation. Ensuite l'imprimeur apparaît active dans la liste admin, le renvoi d'invitation est refusé (409 CONFLICT) et ses demandes « mot de passe oublié » repassent à 1 heure comme pour les autres rôles.
Variables d'environnement auth
| Variable | Obligatoire | Usage |
|---|---|---|
BETTER_AUTH_SECRET | oui | Secret interne Better Auth (>= 32 caractères) |
BETTER_AUTH_URL | recommandé en production | URL de base Better Auth |
APP_BASE_URL | oui | Génération des liens magic link et vérification email |
PAPPERS_API_TOKEN | oui | API Pappers pour vérification SIRET |
EMAIL_PROVIDER | oui | scaleway, resend ou devlog |
EMAIL_FROM | oui | Adresse d'expédition des emails transactionnels |
Admin par défaut
Si DEFAULT_ADMIN_EMAIL et DEFAULT_ADMIN_PASSWORD sont définis, un plugin serveur garantit l'existence d'un compte admin par défaut au démarrage.
Points d'entrée code
| Composant | Chemin |
|---|---|
| Rôles et permissions | app/mypromo/server/modules/auth/domain/roles.ts |
| Logique funnel | app/mypromo/server/modules/auth/application/funnel.ts |
| Middleware frontend | app/mypromo/app/middleware/onboarding.global.ts |
Endpoint /api/me | app/mypromo/server/api/me.get.ts |
| KYB lifecycle | app/mypromo/server/modules/kyb-core/application/derive-lifecycle.ts |
| Adaptateur Pappers | app/mypromo/server/modules/auth/infrastructure/pappers-client.ts |
| Magic link | app/mypromo/server/modules/auth/infrastructure/magic-link.ts |
| Profil imprimeur | app/mypromo/server/modules/auth/domain/printer.ts |
| Invitation imprimeur | app/mypromo/server/modules/auth/application/printer-invitation.ts |
| TTL du lien imprimeur | app/mypromo/server/modules/auth/domain/printer-invitation.ts |