Auth & Onboarding

Better Auth, RBAC, flux d'inscription et d'onboarding KYB pour les quatre types d'acteurs de la plateforme.

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 PrismaModèleRôle
userUserCompte utilisateur, rôle, statut, CGU
sessionSessionSessions actives avec expiration
accountAccountComptes 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ôleWorkspacePermissions principales
admin/adminGestion plateforme complète, revue KYB, gestion utilisateurs et organisations
industrial/industrialRecherche catalogue, création et suivi de campagnes
distributor/distributorGestion catalogue propre (magasins, espaces), revue campagnes
printer (imprimeur)/printerConsultation 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ôlePermissions dans l'organisation
managerCRUD complet, invitation/retrait de membres, soumission KYB
memberLecture des ressources, actions non-critiques
billingAccè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 cibleCondition
auth/authUtilisateur non authentifié
email_verification/auth/email-verificationEmail non vérifié
company_onboarding/onboarding?resume=1Profil organisation incomplet
signature_pending/onboarding-signature-pendingEnveloppe de signature onboarding non signée
admin_approval_pending/onboarding-admin-approval-pendingKYB non approuvé
actor_workspace/industrial, /distributor ou /printerOnboarding 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)

  1. Identification SIRET — vérification via Pappers (PAPPERS_API_TOKEN) avec cache local 14 jours (auth_company_verification_caches).
  2. Identification du signataire — données utilisées pour le contrat Yousign.
  3. Coordonnées de facturation.
  4. 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)

  1. Vérification SIREN + sélection enseigne.
  2. Identification du signataire.
  3. Détails facturation pour compte.
  4. 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 :

KybLifecycleStatusSignification
onboarding_incompleteDonnées d'onboarding incomplètes
signature_pendingEn attente de signature électronique
approval_pendingEn attente de validation admin
approvedAccès complet selon membership
rejectedFlux 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.

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 demande
  • auth.magic_link.clicked — au clic du lien

Variables d'environnement requises :

VariableRôle
BETTER_AUTH_SECRETSecret JWT (minimum 32 caractères)
BETTER_AUTH_URLURL canonique de l'application
APP_BASE_URLBase 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

SituationValidité
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 renvoi1 heure
« Mot de passe oublié » d'un imprimeur déjà activé1 heure
Tous les autres rôles1 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) :

ÉtatOrigineAffichage
ready?token=… présentFormulaire de nouveau mot de passe
expired?error=INVALID_TOKENAlerte « Lien expiré » + bouton « Demander un nouveau lien »
missingPage ouverte sans lienAlerte « 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

VariableObligatoireUsage
BETTER_AUTH_SECRETouiSecret interne Better Auth (>= 32 caractères)
BETTER_AUTH_URLrecommandé en productionURL de base Better Auth
APP_BASE_URLouiGénération des liens magic link et vérification email
PAPPERS_API_TOKENouiAPI Pappers pour vérification SIRET
EMAIL_PROVIDERouiscaleway, resend ou devlog
EMAIL_FROMouiAdresse 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

ComposantChemin
Rôles et permissionsapp/mypromo/server/modules/auth/domain/roles.ts
Logique funnelapp/mypromo/server/modules/auth/application/funnel.ts
Middleware frontendapp/mypromo/app/middleware/onboarding.global.ts
Endpoint /api/meapp/mypromo/server/api/me.get.ts
KYB lifecycleapp/mypromo/server/modules/kyb-core/application/derive-lifecycle.ts
Adaptateur Pappersapp/mypromo/server/modules/auth/infrastructure/pappers-client.ts
Magic linkapp/mypromo/server/modules/auth/infrastructure/magic-link.ts
Profil imprimeurapp/mypromo/server/modules/auth/domain/printer.ts
Invitation imprimeurapp/mypromo/server/modules/auth/application/printer-invitation.ts
TTL du lien imprimeurapp/mypromo/server/modules/auth/domain/printer-invitation.ts