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é :
| Aspect | Les formes rencontrées |
|---|---|
| Support d'état | react-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 401 | dialogue de reconnexion sur place, redirection, rien du tout |
| Modèle de droits | rè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
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.
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"),
}),
);
createUnauthorizedPluginexiste déjà dans@sia-ui/apiet 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.
import { matchRules, matchExpression, matchExact } from "@sia-ui/headless";| Évaluateur | Grammaire | Pour 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 plus | des règles écrites en expression |
matchExact | égalité stricte | une 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.
import { role } from "@sia-ui/headless";
can(role("admin")); // "role:admin"
can({ anyOf: [role("admin"), "facture.valider"] }); // matchRules
can("role:admin & facture.lire"); // matchExpressionLe 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
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 :
const access = createAccessLayer({ granted: () => droitsDeLAssociation });La garde ne connaît aucun routeur
<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 affichependinget n'appelle personne. Rediriger là renverrait à la page de connexion quelqu'un qui est connecté — le serveur n'a simplement pas encore répondu. onForbiddenn'est pasonUnauthenticated. 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.
TokenStorageexiste 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.