Module sites #8

Merged
tristan merged 9 commits from feat/module-site-backend into develop 2026-04-20 15:31:59 +00:00
7 changed files with 88 additions and 5 deletions
Showing only changes of commit a15fc83222 - Show all commits
+3 -1
View File
@@ -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()
Outdated
Review

Si auth.logout() (ligne 17) rejette, le finally exécute bien les trois resets mais l'exception se propage ensuite hors du handler onMounted — la ligne await navigateTo('/login') ne s'exécute jamais. L'utilisateur reste bloqué sur /logout avec état vidé. Déplacer navigateTo('/login') dans le finally (après les resets), ou entourer d'un try { await auth.logout() } catch {} finally { ...; await navigateTo('/login') }.

Si `auth.logout()` (ligne 17) rejette, le `finally` exécute bien les trois resets mais l'exception se propage ensuite hors du handler `onMounted` — la ligne `await navigateTo('/login')` ne s'exécute jamais. L'utilisateur reste bloqué sur `/logout` avec état vidé. Déplacer `navigateTo('/login')` dans le `finally` (après les resets), ou entourer d'un `try { await auth.logout() } catch {} finally { ...; await navigateTo('/login') }`.
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
1
@@ -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
Review

Après suppression, loadSites() rafraîchit la liste mais pas auth.user. Si l'admin supprime son site courant, la backend passe user.current_site_id à NULL (cascade ON DELETE SET NULL de la FK) mais auth.user.currentSite côté Pinia reste le site supprimé. Le SiteSelector du header continue d'afficher la tile morte jusqu'au prochain /api/me.

Fix : await auth.fetchUser() (ou équivalent) après le loadSites() dans le try block, ou invalider currentSite à la main si l'id supprimé === auth.user.currentSite?.id.

Après suppression, `loadSites()` rafraîchit la liste mais pas `auth.user`. Si l'admin supprime son site courant, la backend passe `user.current_site_id` à NULL (cascade `ON DELETE SET NULL` de la FK) mais `auth.user.currentSite` côté Pinia reste le site supprimé. Le `SiteSelector` du header continue d'afficher la tile morte jusqu'au prochain `/api/me`. Fix : `await auth.fetchUser()` (ou équivalent) après le `loadSites()` dans le `try` block, ou invalider `currentSite` à la main si l'id supprimé === `auth.user.currentSite?.id`.
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
} }
+22
View File
@@ -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) {