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 :
- ADR 0009 : tout état UI « back-able » doit être une vraie entrée d'historique. Un modal piloté par un simple
refne serait pas fermé par le back Android / geste iOS — interdit parpatterns.md. useOverlayRouteexige un contextesetup()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 :
refmodule-scope portant un descripteur ({ kind: 'message', title, message, buttons }ou{ kind: 'login', message? }) + leresolved'une promesse. Même pattern queusePageLoading. - Historique : le router est injecté depuis
main.ts(setModalRouter, même pattern quesetBackButtonRouter— évite le cycle d'import@/router).showModal()pousse?overlay=system-modal; toute fermeture finit par dépiler cette entrée (markProgrammaticNav()+router.back(), miroir deuseOverlayRoute.close()). Le back natif ferme donc le modal gratuitement. - Point de règlement unique : le host
watchle 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 (iddu bouton tapé,'success'pour le login, sinonMODAL_DISMISSED). - Dédoublonnage : un
showModalpendant qu'un modal est ouvert remplace le descripteur (l'ancienne promesse résoutdismissed) 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>etshowLoginModal(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'enregistrercreateBootstrap()dansmain.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), e2ee2e/specs/session-expired.spec.ts(modal en place, re-login sans quitter la page, back natif).

