Skip to content

ADR 0018 — Modal système global (service impératif + host shell), login de session expirée

Date : 2026-07-15 Statut : accepté

Contexte

La seule surface d'erreur user-facing était le toast natif Capacitor (services/error-handler.ts). Rien ne permettait d'ouvrir un dialogue riche/interactif depuis du code hors composant (handler d'erreur, api-client) — notamment pour proposer une reconnexion sur place quand la session expire, au lieu de laisser toutes les requêtes protégées échouer en silence (logout muet + toast générique).

Deux contraintes propres au repo :

  1. ADR 0009 : tout état UI « back-able » doit être une vraie entrée d'historique. Un modal piloté par un simple ref ne serait pas fermé par le back Android / geste iOS — interdit par patterns.md.
  2. useOverlayRoute exige un contexte setup() de composant (useRoute/useRouter) — inutilisable depuis un module de service.

Décision

Un service modal singleton (composables/modal.ts) + un host unique au niveau shell (components/overlays-pages/SystemModal.vue, monté dans BuchardApp.vue comme LoadingOverlay) :

  • État : ref module-scope portant un descripteur ({ kind: 'message', title, message, buttons } ou { kind: 'login', message? }) + le resolve d'une promesse. Même pattern que usePageLoading.
  • Historique : le router est injecté depuis main.ts (setModalRouter, même pattern que setBackButtonRouter — évite le cycle d'import @/router). showModal() pousse ?overlay=system-modal ; toute fermeture finit par dépiler cette entrée (markProgrammaticNav() + router.back(), miroir de useOverlayRoute.close()). Le back natif ferme donc le modal gratuitement.
  • Point de règlement unique : le host watch le param ?overlay= ; quand il quitte l'URL (notre back, un geste natif, ou une navigation ailleurs), onModalRouteLeft() clôt le descripteur et résout la promesse (id du bouton tapé, 'success' pour le login, sinon MODAL_DISMISSED).
  • Dédoublonnage : un showModal pendant qu'un modal est ouvert remplace le descripteur (l'ancienne promesse résout dismissed) et réutilise l'entrée d'historique — pas de double push quand deux requêtes concurrentes échouent en même temps.
  • API : showModal({title, message, buttons?}): Promise<string> et showLoginModal(message?): Promise<boolean>.

Session expirée (services/api-client.ts) : les deux chemins « session morte » — échec du refresh proactif (ensureValidToken) et échec du refresh/replay réactif sur 401 — convergent sur un unique sessionExpired() : authStore.logout() + showLoginModal('Votre session a expiré…') (fire-and-forget, défensif si le router n'est pas encore injecté) + throw ApiError(401). La requête échouée n'est pas rejouée après reconnexion (v1) : l'utilisateur retente son action. Le formulaire de login est extrait de Login.vue en components/LoginForm.vue (émet success au lieu de router.replace('/')), réutilisé par la page et le modal.

Alternatives rejetées

  • Modal ref-only sans historique : moitié moins de code, mais viole ADR 0009 (le back Android naviguerait la page sous le modal).
  • BModal/orchestrateur bootstrap-vue-next : exigerait d'enregistrer createBootstrap() dans main.ts (jamais fait — seuls des composants granulaires sont importés) ; le markup Bootstrap à la main est le pattern existant (PassengerSelectModal).
  • Replay automatique de la requête échouée après reconnexion (pattern intercepteur avec file d'attente) : reporté — complexité réelle (queue des 401 concurrents, timeouts) pour un gain marginal.
  • Guard requiresAuth → modal au lieu de redirect /login : hors périmètre — il faudrait annuler la navigation et rester sur une page dont les fetches supposent l'auth.

Conséquences

  • Tout code (composant ou service) peut ouvrir un message/confirm/login modal via composables/modal.ts ; le résultat arrive en promesse.
  • Un seul modal système à la fois (le dernier gagne). Les overlays par-page (useOverlayRoute) restent inchangés pour les écrans qui possèdent leur overlay.
  • Le z-index du host (10000000) est au-dessus de LoadingOverlay (9999999) : un modal de session expirée reste utilisable même si un chargement de page échoué a laissé l'overlay affiché.
  • handleError/toasts inchangés : l'appel échoué throw toujours, donc un toast d'erreur peut apparaître en plus du modal de login (assumé v1).
  • Tests : unit src/composables/__tests__/modal.test.ts (cycle complet avec router factice), e2e e2e/specs/session-expired.spec.ts (modal en place, re-login sans quitter la page, back natif).

Contributors

No contributors

Changelog

No recent changes