Skip to content

Session utilisateur et droits ​

Ce que chaque application réécrit ​

La session est réécrite dans presque chaque application de gestion, et presque toujours de la même façon :

  • le garde de route — if (loading) <Spinner/>; if (!user) <Navigate to=LOGIN/> ;
  • la séquence de connexion — login → saveTokens → me() → setUser ;
  • le nettoyage en finally, pour vider le local même si l'appel réseau échoue ;
  • des magasins clonés pour chaque espace (client, administration), à quelques identifiants près.

Ce qui diffère pour de bon d'une application à l'autre, et qui ne doit donc pas être uniformisé :

AspectLes formes rencontrées
Support d'étatreact-query, zustand + persist, useState, SDK d'un fournisseur (Supabase…)
Source de véritéle serveur ; le stockage local puis le serveur ; un SDK qui pousse l'état
Réaction au 401dialogue de reconnexion sur place, redirection, rien du tout
Modèle de droitsrègles et jokers, expression &/|, rôles seuls, droits portés par une association

Deux défauts reviennent souvent, et la machine à états ci-dessous les rend impossibles. Se déclarer connecté d'après le stockage local avant toute vérification : l'application s'affiche, puis éjecte la personne quand le serveur refuse. Ne câbler aucune réaction au 401 : une session expirée ne vide rien, et l'interface reste « connectée » jusqu'au rechargement.

Une machine à états, pas un booléen ​

checking n'est pas anonymous. C'est la distinction qu'une session écrite à la main finit par perdre ; une machine à états rend la faute impossible à écrire, puisqu'il n'existe aucun chemin qui parte de authenticated.

incomplete couvre les parcours où l'on est authentifié sans être connu — un code reçu par SMS, un profil pas encore rempli. Sans lui, cet état se range dans un booléen à côté du statut, et les deux finissent par mentir.

Le magasin vit hors de React ​

ts
export const session = createSessionStore({
  hydrate: (signal) => api.get<Utilisateur>("/me", { signal }),
  signIn: (identifiants) => api.post<Utilisateur>("/auth/login", { body: identifiants }),
  signOut: () => api.post("/auth/logout"),
  permissionsOf: (user) => user.permissions,
  tenantOf: (user) => user.agence,
});

Comme la file d'annonces : un greffon du client HTTP doit pouvoir appeler expire(), et un greffon n'est pas un composant. useSyncExternalStore fait le pont dans l'autre sens.

ts
api.use(
  createUnauthorizedPlugin({
    // Il notifie, il ne redirige pas : cela permet aussi bien le dialogue
    // de reconnexion que le départ.
    onUnauthorized: () => session.expire(),
    shouldIgnore: (ctx) => ctx.url.includes("/auth/login"),
  }),
);

createUnauthorizedPlugin existe déjà dans @sia-ui/api et fait exactement cela. Rien n'a été réécrit.

shouldIgnore n'est pas un détail : sans lui, un mot de passe refusé fait expirer une session qui n'existe pas encore.

Trois décisions qui se voient à l'usage ​

L'hydratation ne part qu'une fois. start() est idempotent, et useSession l'appelle à la première lecture — un magasin qu'on oublie de démarrer resterait en checking pour toujours, derrière un écran de chargement que rien ne viendrait remplacer.

Ne pas avoir de session n'est pas une panne. Une hydratation qui échoue place en anonymous, pas dans un état d'erreur. L'erreur reste lisible dans state.error, pour qui veut distinguer un refus d'une coupure réseau.

signOut aboutit même quand le réseau tombe. Le local est vidé, et la promesse est tenue : l'appelant enchaîne presque toujours sur une navigation, et la faire échouer laisserait la personne devant une application vidée dont elle ne sort pas. L'échec se lit dans state.error.

Les droits : l'évaluateur est injecté ​

C'est le point où les applications divergent vraiment — et c'est le serveur qui décide de la forme, pas le design system.

ts
import { matchRules, matchExpression, matchExact } from "@sia-ui/headless";
ÉvaluateurGrammairePour un serveur qui renvoie
matchRules (défaut)"a.b", ["a", "b"], { anyOf }, { allOf }, { not }, jokers credit.*des permissions, avec jokers
matchExpression"a.b & c.d | e.f" — un ou de et, rien de plusdes règles écrites en expression
matchExactégalité stricteune liste fermée de rôles ou de droits

Le joker est du côté accordé, jamais du demandé. credit.* autorise credit.valider; demander facture.* n'ouvre rien. L'inverse ferait d'une faute de frappe un passe-droit.

Une expression vide autorise. Un écran sans condition est public.

Les rôles ​

rolesOf remplissait la session sans que rien ne le lise. Les rôles rejoignent désormais l'ensemble accordé sous la forme role:<nom> : une règle qui exige un rôle s'écrit donc avec n'importe quel évaluateur, sans qu'aucun d'eux ait à connaître les rôles.

ts
import { role } from "@sia-ui/headless";

can(role("admin"));                                  // "role:admin"
can({ anyOf: [role("admin"), "facture.valider"] });  // matchRules
can("role:admin & facture.lire");                    // matchExpression

Le préfixe sépare les deux espaces : une permission nommée admin n'accorde pas role:admin. Un joker * accordé, lui, couvre tout — rôles compris. createAccessLayer prend les rôles par roles: () => …, relus à chaque appel comme les permissions.

Les permissions se relisent, jamais ne se capturent ​

ts
createAccessLayer({ granted: () => session.getSnapshot().permissions });

Une fonction et non un tableau : les droits changent — une session qui s'hydrate, un locataire dont on change, un en-tête x-permissions-refreshed qui annonce une révision. Un tableau capturé à la construction serait périmé au premier de ces événements.

C'est aussi ce qui permet de servir un modèle où les permissions sont portées par l'association (ou l'organisation) courante et non par l'utilisateur :

ts
const access = createAccessLayer({ granted: () => droitsDeLAssociation });

La garde ne connaît aucun routeur ​

tsx
<RequireSession
  session={session}
  require="facture.valider"
  onUnauthenticated={() => navigate("/login", { state: { from: location } })}
  onForbidden={() => navigate("/")}
  forbidden={<AccèsRefusé />}
>
  <PageFactures />
</RequireSession>

Elle rend un état et prévient; l'application décide où aller. C'est ce qui lui permet de servir react-router, Next, TanStack Router ou rien du tout.

Deux choses qu'elle ne confond pas :

  • Attendre n'est pas refuser. Pendant checking, elle affiche pending et n'appelle personne. Rediriger là renverrait à la page de connexion quelqu'un qui est connecté — le serveur n'a simplement pas encore répondu.
  • onForbidden n'est pas onUnauthenticated. Renvoyer vers la connexion quelqu'un de connecté mais sans droit le fait se reconnecter pour retomber sur le même mur.

<Can> fait la même chose pour un morceau d'interface — un bouton, une colonne. PermissionGate, qui existait déjà, reste utile quand les permissions viennent d'ailleurs que de la session : il prend un tableau.

Ceci protège l'interface, pas les données. Une route masquée reste une route ouverte tant que le serveur ne refuse pas l'appel.

Où vit quoi ​

Rien de tout cela ne touche le DOM : la version react-native reprendra le magasin et les évaluateurs tels quels, et n'écrira que sa garde.

Ce qui n'a pas été fait ​

  • Le stockage des jetons reste au client HTTP. TokenStorage existe déjà dans @sia-ui/api; la session n'a pas à le connaître. C'est ce qui permet à une application Supabase — où le SDK gère tout — d'utiliser le magasin sans toucher à un jeton.
  • Aucun switchTenant. Le locataire est lu (tenantOf), pas commutable. Changer d'organisation passe le plus souvent par un changement de route — ce qui est probablement la bonne réponse.
  • Aucun parcours de compte. Inscription, mot de passe oublié, activation, OTP : les mettre dans le contexte de session double la taille de celui-ci. Ce sont des écrans, pas de la session.

Vérification ​

  • 18 assertions dans packages/react-web/src/session.test.tsx.
  • Les cas retenus sont les erreurs les plus courantes : se déclarer connecté avant d'avoir demandé, rediriger pendant la vérification, ne rien vider sur un 401, perdre la déconnexion quand l'appel réseau échoue, confondre l'absence de session et le manque de droits.

Publié sous licence MIT.