diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue index 917bdfe..b1962ca 100644 --- a/frontend/modules/core/pages/logout.vue +++ b/frontend/modules/core/pages/logout.vue @@ -20,10 +20,12 @@ onMounted(async () => { // qu'un user suivant (connecte sur le meme onglet) voie l'etat de // l'ancien. Les trois fonctions reset sont synchrones et ne // peuvent pas throw (juste des assignations reactives). + // navigateTo est dans le finally pour garantir la redirection + // meme si auth.logout() lance une exception (ex: reseau coupé). resetSidebar() resetModules() resetCurrentSite() + await navigateTo('/login') } - await navigateTo('/login') }) diff --git a/frontend/modules/sites/components/SiteSelector.vue b/frontend/modules/sites/components/SiteSelector.vue index 107375b..bb03007 100644 --- a/frontend/modules/sites/components/SiteSelector.vue +++ b/frontend/modules/sites/components/SiteSelector.vue @@ -72,8 +72,14 @@ async function onChange(site: { id: string; name: string; color: string }): Prom return } - // Ignore les clics sur le site deja actif (pas de PATCH superflu). - if (currentSite.value && currentSite.value.id === target.id) return + // TODO(cross-tab) : si l'utilisateur a change de site dans un autre + // onglet, currentSite.value ici peut etre obsolete (state singleton + // non synchronise entre onglets). La garde ci-dessous est donc + // intentionnellement supprimee pour garantir qu'un clic sur le tile + // "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise + // l'etat. Amelioration future : ecouter l'evenement `storage` sur la + // cle `coltura:site-switch` pour mettre a jour les onglets inactifs + // sans clic via auth.fetchUser() / auth.refreshUser(). try { await switchSite(target) diff --git a/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts b/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts index eddb486..442061e 100644 --- a/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts +++ b/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts @@ -29,6 +29,10 @@ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) vi.stubGlobal('watchEffect', watchEffect) vi.stubGlobal('computed', computed) vi.stubGlobal('ref', ref) +// useSidebar et refreshNuxtData sont consommes par useCurrentSite apres +// un switch reussi — stubs minimaux pour eviter ReferenceError au mount. +vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn() })) +vi.stubGlobal('refreshNuxtData', vi.fn()) // Stub de MalioSiteSelector : on se contente de tracker les props recues // et de re-emettre `change` quand on le simule via `trigger`. Evite de @@ -144,13 +148,22 @@ describe('SiteSelector', () => { ) }) - it('clic sur le tile deja actif ne declenche aucun PATCH', async () => { + it('clic sur le tile deja actif declenche un PATCH (resync cross-tab)', async () => { + // Le court-circuit "si deja actif, ne rien faire" a ete supprime + // pour couvrir le cas ou un autre onglet a modifie le site courant + // cote serveur : un clic sur la tile localement "active" (etat + // potentiellement stale) force une resync via PATCH. Le prix est un + // PATCH superflu quand l'etat local est effectivement a jour. const wrapper = mountSelector() await wrapper.find('[data-testid="tile-1"]').trigger('click') await flushPromises() - expect(mockPatch).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/me/current-site', + { site: '/api/sites/1' }, + expect.anything(), + ) }) it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => { diff --git a/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts b/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts index 80819c8..104aa04 100644 --- a/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts +++ b/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts @@ -24,6 +24,14 @@ vi.stubGlobal('useAuthStore', () => ({ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key, })) +// useSidebar est consomme par useCurrentSite pour rafraichir la sidebar +// apres un switch reussi. Stub minimal retournant un loadSidebar no-op. +vi.stubGlobal('useSidebar', () => ({ + loadSidebar: vi.fn(), +})) +// refreshNuxtData est appele apres un switch pour invalider les donnees +// de page precedemment fetchees. Stub no-op pour les tests unitaires. +vi.stubGlobal('refreshNuxtData', vi.fn()) const SITE_A: Site = { id: 1, diff --git a/frontend/modules/sites/composables/useCurrentSite.ts b/frontend/modules/sites/composables/useCurrentSite.ts index 2e727e9..da196d2 100644 --- a/frontend/modules/sites/composables/useCurrentSite.ts +++ b/frontend/modules/sites/composables/useCurrentSite.ts @@ -23,11 +23,21 @@ */ import { ref } from 'vue' import type { Site } from '~/shared/types/sites' +import { onAuthSessionCleared } from '~/shared/stores/auth' const currentSite = ref(null) const availableSites = ref([]) const switching = ref(false) +// Enregistrement unique au niveau module (singleton) : quand clearSession() +// est appelee par l'intercepteur 401 de useApi, le state local est purgé +// de la meme facon qu'au logout explicite (logout.vue). +onAuthSessionCleared(() => { + currentSite.value = null + availableSites.value = [] + switching.value = false +}) + export function useCurrentSite() { // Resolution au setup : les 3 services doivent etre invoques dans un // contexte composant. Leur capture ici permet a switchSite() de @@ -35,6 +45,7 @@ export function useCurrentSite() { const auth = useAuthStore() const api = useApi() const { t } = useI18n() + const { loadSidebar } = useSidebar() /** * Synchronise le state singleton depuis le store auth. A appeler au @@ -75,6 +86,21 @@ export function useCurrentSite() { // N'est appele qu'apres un succes HTTP donc pas de rollback a // prevoir sur cette ligne. auth.setCurrentSite(site) + + // Apres un switch reussi : recharger la sidebar (les filtres de + // modules peuvent dependre du site courant via SiteScopedQueryExtension) + // et invalider toutes les donnees de page pour eviter que l'utilisateur + // voie les donnees de l'ancien site sous un toast "Site change". + try { + await loadSidebar() + } catch { + // No-op : la sidebar non rafraichie n'est pas bloquante. + } + try { + await refreshNuxtData() + } catch { + // No-op : certaines pages n'ont pas de useAsyncData a invalider. + } } catch (error) { currentSite.value = previousLocal throw error diff --git a/frontend/modules/sites/pages/admin/sites.vue b/frontend/modules/sites/pages/admin/sites.vue index f0cdead..205dec1 100644 --- a/frontend/modules/sites/pages/admin/sites.vue +++ b/frontend/modules/sites/pages/admin/sites.vue @@ -63,6 +63,7 @@ import type { Site } from '~/shared/types/sites' const { t } = useI18n() const api = useApi() +const auth = useAuthStore() const { can } = usePermissions() const canManage = computed(() => can('sites.manage')) @@ -149,6 +150,11 @@ async function handleDelete() { siteToDelete.value = null drawerOpen.value = false await loadSites() + // Rafraichit auth.user apres suppression d'un site : le backend + // applique ON DELETE SET NULL sur user.current_site_id, donc + // auth.user.currentSite peut etre devenu null sans que le front + // le sache. refreshUser() resynchronise depuis GET /api/me. + await auth.refreshUser() } finally { deleting.value = false } diff --git a/frontend/shared/stores/auth.ts b/frontend/shared/stores/auth.ts index 76aece3..d448947 100644 --- a/frontend/shared/stores/auth.ts +++ b/frontend/shared/stores/auth.ts @@ -3,6 +3,24 @@ import type { UserData } from '~/shared/types/user-data' import type { Site } from '~/shared/types/sites' import { getCurrentUser, login, logout } from '~/shared/services/auth' +/** + * Callbacks enregistres par les composables singletons qui doivent + * reinitialiser leur etat quand la session est invalidee (ex: expiration + * JWT, logout depuis un intercepteur 401). Utilise le pattern + * "callback registration" (Option C) pour eviter une dependance croisee + * depuis shared/ vers modules/ — chaque composable s'auto-enregistre. + */ +const onSessionClearedCallbacks: Array<() => void> = [] + +/** + * Enregistre un callback a invoquer lorsque clearSession() est appelee. + * Typiquement invoque au setup-time du composable (module-level), donc + * une seule fois par instance de composable singleton. + */ +export function onAuthSessionCleared(cb: () => void): void { + onSessionClearedCallbacks.push(cb) +} + export const useAuthStore = defineStore('auth', { state: () => ({ user: null as UserData | null, @@ -17,6 +35,10 @@ export const useAuthStore = defineStore('auth', { this.user = null this.checked = true this.isLoading = false + // Notifie les composables singletons (useCurrentSite, etc.) afin + // qu'ils reinitialisation leur etat — necessaire quand la session + // est invalidee par un intercepteur 401 sans passer par logout.vue. + onSessionClearedCallbacks.forEach((cb) => cb()) }, async ensureSession() { if (this.checked) {