Skip to content

Routage & gardes

Le routeur vit dans src/router/ : index.ts déclare les routes et l'historique, guards.ts isole la logique pure des gardes (testable sans monter d'app).

Historique

createWebHistory(import.meta.env.BASE_URL) : les URL reflètent la navigation et les liens profonds fonctionnent. (En production, le serveur doit renvoyer index.html pour toute route — fallback SPA ; le serveur de dev Vite le fait déjà.)

Routes

CheminNomMétaÉcran
/loginloginrequiresGuest, guestPageviews/Login.vue
/activate/:tokenactivateguestPageviews/ActivateAccount.vue
/invitation/:tokeninvitationguestPageviews/InvitationAccept.vue
/forgot-passwordforgot-passwordrequiresGuest, guestPageviews/ForgotPassword.vue
/reset-passwordreset-passwordrequiresGuest, guestPageviews/ResetPassword.vue
/codes/unavailablecode-unavailableguestPageviews/CodeUnavailable.vue
/homeredirection → /dashboard
/dashboard/:section?dashboardrequiresAuthviews/Dashboard.vue
/dashboard/:section(resellers)/:resellerIdreseller-detailrequiresAuthviews/Dashboard.vue
/dashboard/:section(codes)/batchescodes-batchesrequiresAuthviews/Dashboard.vue
/dashboard/:section(roadtrip)/:cardIdroadtrip-journeyrequiresAuthviews/Dashboard.vue
/dashboard/:section(roadtrip)/:cardId/steps/:stepIdroadtrip-step-detailrequiresAuthviews/Dashboard.vue
/:pathMatch(.*)*not-foundviews/NotFound.vue

roadtrip-journey et roadtrip-step-detail suivent le patron de reseller-detail : :section est contraint au slug littéral roadtrip, donc useDashboardSection garde la section active sans logique dédiée et Dashboard.vue reste l'écran rendu. Les deux portent le :cardId du voyage : sans lui, seul le choix déterministe de pickActiveTourCard était atteignable — un voyageur avec plusieurs voyages terminés ne pouvait ouvrir que le plus récent, les autres étaient inadressables. Pire pour une étape : comme une étape appartient au parcours, retrouver le voyage en rejouant le choix renvoyait un voyageur ayant fait deux fois le même parcours sur l'étape du mauvais voyage, au lieu d'un 404. Le /dashboard/roadtrip nu reste servi par dashboard (:section?) et conserve le choix automatique.

invitation n'a pas requiresGuest : le jeton dans l'URL est l'identifiant, la page reste accessible même connecté. guestPage est un marqueur orthogonal à l'auth : il désigne les pages du flux public et pilote la garde de langue.

code-unavailable est la porte de sortie publique du PDF de code d'activation : quand l'état du code ne permet plus de servir le document, le backend (GET /api/activation-codes/{id}/pdf, lien signé éternel) redirige le navigateur vers /codes/unavailable?reason=…&ref=…. La page (accessible connecté ou non — seul guestPage s'applique, pour ?lang) mappe reason sur un message i18n (raisons stables du backend : not_found, not_purchased, already_activated, expired, pending_reimbursement, cancelled, reimbursed ; toute autre valeur → message générique) et offre un lien mailto: vers l'alias support (SUPPORT_EMAIL, voir Environnements) au sujet prérempli avec ref. Aucune requête API : une fois le code renouvelé, le même lien sert à nouveau le PDF.

Tous les portails partagent une URL unique (/dashboard). C'est l'écran Dashboard.vue qui choisit la vue à rendre selon la casquette activeSuperadminDashboard, TravellerDashboard, RegionDashboard (organisation de type region), ResellerDashboard (organisation de type reseller), ou PortalUnavailable en repli — sans changer d'URL. Une casquette d'organisation est ainsi routée selon son type. Changer de casquette via le sélecteur fait donc basculer la vue de façon réactive (le currentHat du store pilote un computed sur le composant).

Une casquette d'organisation est d'abord sondée (composables/useOrganizationAccess.ts) : GET /organization part avec le X-Viewing-As de la casquette, et un 403 organization_not_approved (organisation suspendue) rend l'écran pleine page views/OrganizationSuspended.vue — message, déconnexion, et sélecteur de casquette si l'utilisateur en a plusieurs (sinon il serait enfermé, la casquette par défaut étant l'organisation) — à la place du portail. Toute autre issue (succès, erreur réseau ou 5xx) rend le portail (« fail open ») ; un indicateur de chargement s'affiche pendant la sonde pour ne jamais faire flasher le portail. La sonde n'est relancée qu'au changement d'organisation active, pas à la navigation de section.

reseller-detail est la première route de détail profonde : elle rend le détail d'un revendeur du portail super-admin à une URL partageable (/dashboard/resellers/{uuid}). Le param section y est contraint au slug resellers : useDashboardSection voit ainsi la section active sans logique dédiée (surlignage de la barre latérale, rendu de ResellersManager), et Dashboard.vue reste l'écran rendu — le choix du portail suit toujours la casquette active. Pour une casquette non super-admin, le slug resellers est inconnu de son portail et l'URL est normalisée vers /dashboard par le watch existant de useDashboardSection, comme /dashboard/resellers aujourd'hui.

codes-batches applique le même mécanisme à la vue Lots de la section codes : /dashboard/codes rend la vue Codes, /dashboard/codes/batches la vue Lots (les deux vues sont adressables et partageables ; CodesManager dérive sa vue active de route.name et le sélecteur de vue navigue par router.push). Le param section y est contraint au slug codes, mêmes conséquences que reseller-detail (section active vue par useDashboardSection, normalisation pour les autres casquettes). Les filtres des deux vues sont canoniques dans l'URL (vue Codes : ?status, ?org, ?expiry, ?q, ?batch, ?page ; vue Lots : ?org, ?q, ?page — chaque vue ayant son chemin, pas de collision de noms ; sérialisation dans src/components/superadmin/codes-url.ts, seules les valeurs non par défaut apparaissent) : la saisie s'écrit en router.replace (pas d'historique intra-vue), les navigations entre vues et le drill-down lot → codes sont des push. Les autres URL à deux segments (/dashboard/codes/x, /dashboard/tours/batches) restent sur la page 404.

Gardes (guards.ts)

Le choix du portail suit la casquette active (voir État applicatif et src/stores/hats.ts), pas l'utilisateur — mais ce choix est désormais fait dans la vue (Dashboard.vue), plus dans le routeur. Les gardes ne s'occupent donc que de l'authentification.

Une garde globale beforeEach délègue à resolveAuthRedirect(meta, { isAuthenticated, currentHat }, to.fullPath), fonction pure :

  • route requiresAuth + visiteur non connecté → redirection vers login, en emportant la destination demandée en ?redirect=<fullPath> (le lien profond reprend après connexion) ;
  • route requiresGuest + utilisateur connecté → redirection vers l'accueil (/dashboard) ;
  • sinon, navigation autorisée.

À la consommation, Login.vue ne suit ?redirect qu'après validation par resolveLoginRedirect (pure, guards.ts) : uniquement un chemin interne commençant par un seul / — jamais //hôte, /\hôte ni une URL absolue (garde anti-open-redirect), doublons rejetés comme pour ?lang. Limites assumées : un visiteur déjà connecté sur /login?redirect=… repart vers /dashboard (le paramètre est ignoré), et le paramètre ne survit pas au détour mot-de-passe-oublié (seul lang est injecté dans le flux invité).

Les champs requiresAuth / requiresGuest / guestPage sont déclarés sur RouteMeta (module augmentation dans guards.ts) pour que to.meta soit typé.

Garde de langue (?lang)

Un second beforeEach (enregistré après la garde d'auth — une redirection d'auth interrompt la chaîne avant lui) délègue à resolveLanguageSync(to, from.query.lang), fonction pure elle aussi. Sur les routes guestPage uniquement :

  • ?lang valide (fr / de / en) porté par la route cible → la langue est appliquée à i18n et persistée (elle prime sur localStorage). Le montage attendant router.isReady() (main.ts), la langue d'un lien d'e-mail (ex. /invitation/:token?lang=de) est active dès le premier rendu ;
  • navigation vers une route guestPage sans lang alors que la route quittée en avait un valide → le paramètre est injecté par une redirection replace (la langue suit tout le flux invité : invitation → connexion → mot de passe oublié) sans entrée d'historique parasite. Pas de boucle : la redirection porte un lang valide, la seconde passe prend la branche d'application et ne redirige plus ;
  • valeur invalide (?lang=xx, vide, doublons) → ignorée (la résolution existante — localStorage → navigateur → défaut — reste en vigueur) et elle bloque aussi l'injection : on ne réécrit jamais ce que l'URL affirme déjà.

Voir aussi Internationalisation.

Redirection d'accueil

/ ne rend pas d'écran : il redirige via resolveHomeRoute() vers /dashboard, quelle que soit la casquette. C'est ensuite Dashboard.vue qui rend le portail correspondant à la casquette active (super-admin, voyageur, organisation region ou reseller, ou repli PortalUnavailable pour une casquette null).

Après connexion, Login.vue suit un ?redirect interne validé (resolveLoginRedirect) et sinon resolveHomeRoute() ; le sélecteur de casquette (RoleSwitcher) y renvoie aussi après un changement de rôle ; la déconnexion renvoie vers login.

Contributors

No contributors

Changelog

No recent changes