fix(sites-front) : refresh state apres switch/delete/401 + redirect logout
- logout.vue : navigateTo('/login') dans le finally, garanti meme si
auth.logout() rejette.
- auth.ts : systeme de callbacks onAuthSessionCleared appeles par
clearSession() (intercepteur 401 de useApi). Les composables modules
s'abonnent pour reset leur state sans que Shared n'importe depuis
modules/ (Option C validee par CLAUDE.md, module -> shared autorise).
- useCurrentSite.ts : enregistre un reset callback + apres un switch
reussi, rafraichit useSidebar().loadSidebar() + refreshNuxtData()
(sinon donnees de page obsoletes cote ancien site sous toast success).
- SiteSelector.vue : le court-circuit "tile deja active" est retire
pour permettre un PATCH de resync quand un autre onglet a bascule le
site entre temps. TODO cross-tab : ecouter un storage event dedie.
- sites.vue admin : auth.refreshUser() apres delete pour refleter le
ON DELETE SET NULL cote user.current_site_id.
- Specs vitest : stub useSidebar/refreshNuxtData, test "tile active"
retourne sur le nouveau contrat PATCH-toujours.
This commit is contained in:
@@ -20,10 +20,12 @@ onMounted(async () => {
|
|||||||
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||||
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
||||||
// peuvent pas throw (juste des assignations reactives).
|
// 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()
|
resetSidebar()
|
||||||
resetModules()
|
resetModules()
|
||||||
resetCurrentSite()
|
resetCurrentSite()
|
||||||
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
await navigateTo('/login')
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -72,8 +72,14 @@ async function onChange(site: { id: string; name: string; color: string }): Prom
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore les clics sur le site deja actif (pas de PATCH superflu).
|
// TODO(cross-tab) : si l'utilisateur a change de site dans un autre
|
||||||
if (currentSite.value && currentSite.value.id === target.id) return
|
// 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 {
|
try {
|
||||||
await switchSite(target)
|
await switchSite(target)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
|||||||
vi.stubGlobal('watchEffect', watchEffect)
|
vi.stubGlobal('watchEffect', watchEffect)
|
||||||
vi.stubGlobal('computed', computed)
|
vi.stubGlobal('computed', computed)
|
||||||
vi.stubGlobal('ref', ref)
|
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
|
// Stub de MalioSiteSelector : on se contente de tracker les props recues
|
||||||
// et de re-emettre `change` quand on le simule via `trigger`. Evite de
|
// 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()
|
const wrapper = mountSelector()
|
||||||
|
|
||||||
await wrapper.find('[data-testid="tile-1"]').trigger('click')
|
await wrapper.find('[data-testid="tile-1"]').trigger('click')
|
||||||
await flushPromises()
|
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 () => {
|
it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ vi.stubGlobal('useAuthStore', () => ({
|
|||||||
vi.stubGlobal('useI18n', () => ({
|
vi.stubGlobal('useI18n', () => ({
|
||||||
t: (key: string) => key,
|
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 = {
|
const SITE_A: Site = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -23,11 +23,21 @@
|
|||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
const currentSite = ref<Site | null>(null)
|
const currentSite = ref<Site | null>(null)
|
||||||
const availableSites = ref<Site[]>([])
|
const availableSites = ref<Site[]>([])
|
||||||
const switching = ref(false)
|
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() {
|
export function useCurrentSite() {
|
||||||
// Resolution au setup : les 3 services doivent etre invoques dans un
|
// Resolution au setup : les 3 services doivent etre invoques dans un
|
||||||
// contexte composant. Leur capture ici permet a switchSite() de
|
// contexte composant. Leur capture ici permet a switchSite() de
|
||||||
@@ -35,6 +45,7 @@ export function useCurrentSite() {
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { loadSidebar } = useSidebar()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronise le state singleton depuis le store auth. A appeler au
|
* 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
|
// N'est appele qu'apres un succes HTTP donc pas de rollback a
|
||||||
// prevoir sur cette ligne.
|
// prevoir sur cette ligne.
|
||||||
auth.setCurrentSite(site)
|
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) {
|
} catch (error) {
|
||||||
currentSite.value = previousLocal
|
currentSite.value = previousLocal
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import type { Site } from '~/shared/types/sites'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
const auth = useAuthStore()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const canManage = computed(() => can('sites.manage'))
|
const canManage = computed(() => can('sites.manage'))
|
||||||
|
|
||||||
@@ -149,6 +150,11 @@ async function handleDelete() {
|
|||||||
siteToDelete.value = null
|
siteToDelete.value = null
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
await loadSites()
|
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 {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,24 @@ import type { UserData } from '~/shared/types/user-data'
|
|||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
import { getCurrentUser, login, logout } from '~/shared/services/auth'
|
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', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
user: null as UserData | null,
|
user: null as UserData | null,
|
||||||
@@ -17,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.user = null
|
this.user = null
|
||||||
this.checked = true
|
this.checked = true
|
||||||
this.isLoading = false
|
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() {
|
async ensureSession() {
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
|
|||||||
Reference in New Issue
Block a user