+ isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0,
+)
+
const translatedSections = computed(() =>
sections.value.map(section => ({
label: t(section.label),
diff --git a/frontend/app/middleware/auth.global.ts b/frontend/app/middleware/auth.global.ts
index 9699045..7c6a01f 100644
--- a/frontend/app/middleware/auth.global.ts
+++ b/frontend/app/middleware/auth.global.ts
@@ -15,9 +15,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
if (auth.isAuthenticated) {
- const { loaded, loadSidebar } = useSidebar()
- if (!loaded.value) {
- await loadSidebar()
- }
+ const { loaded: sidebarLoaded, loadSidebar } = useSidebar()
+ const { loaded: modulesLoaded, loadModules } = useModules()
+
+ // Chargement parallele sidebar + modules actifs : les deux sont
+ // consommes par layouts/default.vue (sidebar pour la nav, modules
+ // pour conditionner le SiteSelector). Charger en parallele evite
+ // le flash au premier paint de la barre.
+ await Promise.all([
+ sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
+ modulesLoaded.value ? Promise.resolve() : loadModules(),
+ ])
}
})
diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index 2dd1243..5170bfe 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -60,6 +60,12 @@
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
}
},
+ "sites": {
+ "selector": {
+ "ariaGroupLabel": "Sélecteur de site actif",
+ "switchSuccess": "Site courant changé"
+ }
+ },
"success": {
"auth": {
"logout": "Deconnexion reussie"
diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue
index 3902549..917bdfe 100644
--- a/frontend/modules/core/pages/logout.vue
+++ b/frontend/modules/core/pages/logout.vue
@@ -9,10 +9,21 @@ definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
+const { resetModules } = useModules()
+const { resetCurrentSite } = useCurrentSite()
onMounted(async () => {
- await auth.logout()
- resetSidebar()
+ try {
+ await auth.logout()
+ } finally {
+ // Les resets sont garantis meme si auth.logout() rejette : eviter
+ // 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).
+ resetSidebar()
+ resetModules()
+ resetCurrentSite()
+ }
await navigateTo('/login')
})
diff --git a/frontend/modules/sites/components/SiteDrawer.vue b/frontend/modules/sites/components/SiteDrawer.vue
index 8d929c6..86fd26e 100644
--- a/frontend/modules/sites/components/SiteDrawer.vue
+++ b/frontend/modules/sites/components/SiteDrawer.vue
@@ -99,7 +99,7 @@
diff --git a/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts b/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
new file mode 100644
index 0000000..eddb486
--- /dev/null
+++ b/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
@@ -0,0 +1,176 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { computed, defineComponent, h, ref, watchEffect } from 'vue'
+import type { Site } from '~/shared/types/sites'
+import { useCurrentSite } from '~/modules/sites/composables/useCurrentSite'
+import SiteSelector from '../SiteSelector.vue'
+
+const mockPatch = vi.hoisted(() => vi.fn())
+const mockAuthUser = vi.hoisted(() => ({
+ value: null as { sites: Site[]; currentSite: Site | null } | null,
+}))
+
+// Stubs des auto-imports Nuxt. SiteSelector.vue utilise useCurrentSite,
+// useAuthStore, useI18n, watchEffect, computed sans import explicite
+// (pattern Nuxt). En Vitest on les expose comme globals.
+vi.stubGlobal('useCurrentSite', useCurrentSite)
+vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
+vi.stubGlobal('useAuthStore', () => ({
+ get user() {
+ return mockAuthUser.value
+ },
+ setCurrentSite(site: Site | null) {
+ if (mockAuthUser.value) {
+ mockAuthUser.value.currentSite = site
+ }
+ },
+}))
+vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
+vi.stubGlobal('watchEffect', watchEffect)
+vi.stubGlobal('computed', computed)
+vi.stubGlobal('ref', ref)
+
+// Stub de MalioSiteSelector : on se contente de tracker les props recues
+// et de re-emettre `change` quand on le simule via `trigger`. Evite de
+// monter la vraie lib Malio (qui aurait besoin de tout Tailwind + twMerge).
+const MalioSiteSelectorStub = defineComponent({
+ name: 'MalioSiteSelector',
+ props: {
+ sites: { type: Array, required: true },
+ modelValue: { type: String, default: undefined },
+ groupClass: { type: String, default: '' },
+ tileClass: { type: String, default: '' },
+ labelClass: { type: String, default: '' },
+ },
+ emits: ['update:modelValue', 'change'],
+ setup(props, { emit }) {
+ return () => h('div', {
+ 'data-testid': 'malio-site-selector',
+ 'data-sites-count': String((props.sites as unknown[]).length),
+ 'data-active-id': String(props.modelValue ?? ''),
+ 'data-label-class': props.labelClass,
+ }, [
+ ...(props.sites as Array<{ id: string; name: string; color: string }>).map(site =>
+ h('button', {
+ 'data-testid': `tile-${site.id}`,
+ // Emet les deux events comme le vrai MalioSiteSelector
+ // (update:modelValue + change). Le wrapper n'ecoute que
+ // change aujourd'hui, mais tracker les deux grave la
+ // signature et prepare un eventuel v-model futur.
+ onClick: () => {
+ emit('update:modelValue', site.id)
+ emit('change', site)
+ },
+ }, site.name),
+ ),
+ ])
+ },
+})
+
+const SITE_A: Site = {
+ id: 1,
+ name: 'Chatellerault',
+ street: '14 All.',
+ complement: null,
+ postalCode: '86100',
+ city: 'Châtellerault',
+ color: '#056CF2',
+ fullAddress: '14 All.\n86100 Châtellerault',
+}
+const SITE_B: Site = {
+ id: 2,
+ name: 'Saint-Jean',
+ street: 'Z i',
+ complement: null,
+ postalCode: '17400',
+ city: 'Fontenet',
+ color: '#F3CB00',
+ fullAddress: 'Z i\n17400 Fontenet',
+}
+
+function mountSelector() {
+ return mount(SiteSelector, {
+ global: {
+ stubs: { MalioSiteSelector: MalioSiteSelectorStub },
+ },
+ })
+}
+
+describe('SiteSelector', () => {
+ beforeEach(() => {
+ mockPatch.mockReset()
+ mockAuthUser.value = {
+ sites: [SITE_A, SITE_B],
+ currentSite: SITE_A,
+ }
+ })
+
+ it('rend un tile par site autorise', () => {
+ const wrapper = mountSelector()
+ const stub = wrapper.find('[data-testid="malio-site-selector"]')
+
+ expect(stub.attributes('data-sites-count')).toBe('2')
+ })
+
+ it('marque le site courant via modelValue (string)', () => {
+ const wrapper = mountSelector()
+ const stub = wrapper.find('[data-testid="malio-site-selector"]')
+
+ // Chatellerault id=1 => '1'
+ expect(stub.attributes('data-active-id')).toBe('1')
+ })
+
+ it('passe labelClass="text-2xl" pour forcer 24px conforme Figma', () => {
+ // Decision design : texte blanc par defaut Malio mais taille 24px
+ // imposee par la maquette. Le reste des attributs text (white, bold,
+ // uppercase, tracking-wide) provient du default Malio via twMerge.
+ const wrapper = mountSelector()
+ const stub = wrapper.find('[data-testid="malio-site-selector"]')
+
+ expect(stub.attributes('data-label-class')).toBe('text-2xl')
+ })
+
+ it('clic sur un tile inactif declenche switchSite via PATCH /me/current-site', async () => {
+ mockPatch.mockResolvedValueOnce({})
+ const wrapper = mountSelector()
+
+ await wrapper.find('[data-testid="tile-2"]').trigger('click')
+ await flushPromises()
+
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/me/current-site',
+ { site: '/api/sites/2' },
+ expect.anything(),
+ )
+ })
+
+ it('clic sur le tile deja actif ne declenche aucun PATCH', async () => {
+ const wrapper = mountSelector()
+
+ await wrapper.find('[data-testid="tile-1"]').trigger('click')
+ await flushPromises()
+
+ expect(mockPatch).not.toHaveBeenCalled()
+ })
+
+ it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {
+ // Scenario : admin clique sur Saint-Jean alors que Chatellerault est
+ // actif, mais le serveur rejette (ex : 500). Apres rollback dans
+ // useCurrentSite, le composant doit re-afficher Chatellerault actif.
+ mockPatch.mockRejectedValueOnce(new Error('server down'))
+ const wrapper = mountSelector()
+
+ // Avant : Chatellerault (id=1) actif.
+ expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
+ .toBe('1')
+
+ await wrapper.find('[data-testid="tile-2"]').trigger('click')
+ await flushPromises()
+
+ // Apres rollback : Chatellerault (id=1) de nouveau actif.
+ expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
+ .toBe('1')
+ // Le store auth ne doit PAS avoir ete laisse avec SITE_B.
+ expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
+ })
+})
diff --git a/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts b/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts
new file mode 100644
index 0000000..80819c8
--- /dev/null
+++ b/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { Site } from '~/shared/types/sites'
+import { useCurrentSite } from '../useCurrentSite'
+
+const mockPatch = vi.hoisted(() => vi.fn())
+const mockAuthUser = vi.hoisted(() => ({
+ value: null as { sites: Site[]; currentSite: Site | null } | null,
+}))
+
+// Stub des auto-imports Nuxt consommes par le composable.
+vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
+vi.stubGlobal('useAuthStore', () => ({
+ get user() {
+ return mockAuthUser.value
+ },
+ // Mime l'action Pinia ajoutee au ticket 3 review (S6) : mute
+ // user.currentSite si user present, no-op sinon.
+ setCurrentSite(site: Site | null) {
+ if (mockAuthUser.value) {
+ mockAuthUser.value.currentSite = site
+ }
+ },
+}))
+vi.stubGlobal('useI18n', () => ({
+ t: (key: string) => key,
+}))
+
+const SITE_A: Site = {
+ id: 1,
+ name: 'Chatellerault',
+ street: '14 All. d\'Argenson',
+ complement: null,
+ postalCode: '86100',
+ city: 'Châtellerault',
+ color: '#056CF2',
+ fullAddress: '14 All. d\'Argenson\n86100 Châtellerault',
+}
+const SITE_B: Site = {
+ id: 2,
+ name: 'Saint-Jean',
+ street: 'Z i',
+ complement: null,
+ postalCode: '17400',
+ city: 'Fontenet',
+ color: '#F3CB00',
+ fullAddress: 'Z i\n17400 Fontenet',
+}
+
+describe('useCurrentSite', () => {
+ beforeEach(() => {
+ mockPatch.mockReset()
+ mockAuthUser.value = {
+ sites: [SITE_A, SITE_B],
+ currentSite: SITE_A,
+ }
+ const { resetCurrentSite } = useCurrentSite()
+ resetCurrentSite()
+ })
+
+ it('syncFromAuth hydrate le state depuis le store auth', () => {
+ const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
+
+ syncFromAuth()
+
+ expect(currentSite.value).toEqual(SITE_A)
+ expect(availableSites.value).toEqual([SITE_A, SITE_B])
+ })
+
+ it('syncFromAuth gere le cas user null (deconnecte)', () => {
+ mockAuthUser.value = null
+ const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
+
+ syncFromAuth()
+
+ expect(currentSite.value).toBeNull()
+ expect(availableSites.value).toEqual([])
+ })
+
+ it('switchSite met a jour currentSite localement AVANT la requete (optimistic)', async () => {
+ mockPatch.mockImplementation(async () => {
+ // Au moment du resolve, currentSite est deja basculé.
+ const state = useCurrentSite()
+ expect(state.currentSite.value).toEqual(SITE_B)
+ return {}
+ })
+
+ const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
+ syncFromAuth()
+ await switchSite(SITE_B)
+
+ expect(currentSite.value).toEqual(SITE_B)
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/me/current-site',
+ { site: '/api/sites/2' },
+ expect.objectContaining({ toastSuccessMessage: expect.any(String) }),
+ )
+ })
+
+ it('switchSite propage le nouveau currentSite au store auth en cas de succes', async () => {
+ mockPatch.mockResolvedValueOnce({})
+ const { syncFromAuth, switchSite } = useCurrentSite()
+ syncFromAuth()
+
+ await switchSite(SITE_B)
+
+ expect(mockAuthUser.value?.currentSite).toEqual(SITE_B)
+ })
+
+ it('switchSite rollback le currentSite local si la requete echoue', async () => {
+ mockPatch.mockRejectedValueOnce(new Error('network'))
+ const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
+ syncFromAuth()
+
+ await expect(switchSite(SITE_B)).rejects.toThrow('network')
+
+ expect(currentSite.value).toEqual(SITE_A)
+ })
+
+ it('switchSite ne propage pas au store auth en cas d\'echec', async () => {
+ mockPatch.mockRejectedValueOnce(new Error('network'))
+ const { syncFromAuth, switchSite } = useCurrentSite()
+ syncFromAuth()
+
+ await expect(switchSite(SITE_B)).rejects.toThrow()
+
+ expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
+ })
+
+ it('switching est vrai pendant la requete et faux apres', async () => {
+ let resolveRequest: (value: unknown) => void = () => {}
+ mockPatch.mockImplementation(
+ () => new Promise((resolve) => { resolveRequest = resolve }),
+ )
+
+ const { syncFromAuth, switchSite, switching } = useCurrentSite()
+ syncFromAuth()
+
+ const pending = switchSite(SITE_B)
+ expect(switching.value).toBe(true)
+
+ resolveRequest({})
+ await pending
+
+ expect(switching.value).toBe(false)
+ })
+
+ it('double switchSite concurrent : le second appel est un no-op silencieux', async () => {
+ let resolveRequest: (value: unknown) => void = () => {}
+ mockPatch.mockImplementation(
+ () => new Promise((resolve) => { resolveRequest = resolve }),
+ )
+
+ const { syncFromAuth, switchSite } = useCurrentSite()
+ syncFromAuth()
+
+ const first = switchSite(SITE_B)
+ await switchSite(SITE_A) // doit etre no-op (switching=true)
+
+ // Le second appel ne declenche pas de PATCH additionnel.
+ expect(mockPatch).toHaveBeenCalledTimes(1)
+
+ resolveRequest({})
+ await first
+ })
+
+ it('resetCurrentSite vide tout l\'etat singleton', () => {
+ const { syncFromAuth, resetCurrentSite, currentSite, availableSites, switching } = useCurrentSite()
+ syncFromAuth()
+ expect(currentSite.value).not.toBeNull()
+
+ resetCurrentSite()
+
+ expect(currentSite.value).toBeNull()
+ expect(availableSites.value).toEqual([])
+ expect(switching.value).toBe(false)
+ })
+
+ it('capture useI18n/useApi/useAuthStore UNE FOIS au setup (garde anti-regression bug runtime)', async () => {
+ // Historique : une premiere version du composable appelait useI18n()
+ // dans `switchSite` plutot qu'au top du setup. Consequence en runtime :
+ // l'appel depuis un event handler (click) hors contexte setup levait
+ // "Must be called at the top of a setup function". Ce test grave le
+ // contrat : useCurrentSite() DOIT capturer les 3 services a
+ // l'initialisation, pas paresseusement.
+ //
+ // Verification : on remplace useI18n par un mock qui throw au 2e appel.
+ // Si switchSite invoque useI18n() lui-meme, ce test cassera.
+ let i18nCallCount = 0
+ vi.stubGlobal('useI18n', () => {
+ i18nCallCount++
+ if (i18nCallCount > 1) {
+ throw new Error('useI18n called more than once — regression bug runtime')
+ }
+ return { t: (key: string) => key }
+ })
+
+ mockPatch.mockResolvedValueOnce({})
+ const { syncFromAuth, switchSite } = useCurrentSite()
+ syncFromAuth()
+
+ // Si switchSite appelait useI18n() en interne, ce call incrementerait
+ // i18nCallCount a 2 et throw. La garde du test passe uniquement si
+ // la capture a bien eu lieu au setup (i18nCallCount reste a 1).
+ await switchSite(SITE_B)
+
+ expect(i18nCallCount).toBe(1)
+
+ // Restaure le stub par defaut pour les tests suivants.
+ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
+ })
+})
diff --git a/frontend/modules/sites/composables/useCurrentSite.ts b/frontend/modules/sites/composables/useCurrentSite.ts
new file mode 100644
index 0000000..2e727e9
--- /dev/null
+++ b/frontend/modules/sites/composables/useCurrentSite.ts
@@ -0,0 +1,104 @@
+/**
+ * Composable de gestion du site courant (ticket 3 module Sites).
+ *
+ * Pattern aligne sur `useSidebar` : state singleton au niveau module,
+ * hydrate depuis `useAuthStore().user`, mute de maniere optimistic avec
+ * rollback si la requete PATCH `/api/me/current-site` echoue.
+ *
+ * Garantie d'unicite : le flag `switching` bloque les double-clicks
+ * concurrents. Le reset explicite est appele au logout
+ * (voir `modules/core/pages/logout.vue`).
+ *
+ * Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
+ * garantit deja l'invariant "user avec sites non vide => currentSite non null"
+ * apres tout PATCH /rbac. Le front consomme l'etat renvoye tel quel.
+ *
+ * Contrainte d'appel : `useCurrentSite()` doit etre invoque au top du
+ * `setup()` d'un composant (ou d'un autre composable appele au setup).
+ * Les dependances `useI18n`, `useApi` et `useAuthStore` sont resolues
+ * a l'initialisation et reutilisees par `switchSite` — ceci evite le
+ * "Must be called at the top of a setup function" qui se produirait
+ * si on les appelait paresseusement depuis une fonction async declenchee
+ * par un handler d'event (hors contexte setup).
+ */
+import { ref } from 'vue'
+import type { Site } from '~/shared/types/sites'
+
+const currentSite = ref(null)
+const availableSites = ref([])
+const switching = ref(false)
+
+export function useCurrentSite() {
+ // Resolution au setup : les 3 services doivent etre invoques dans un
+ // contexte composant. Leur capture ici permet a switchSite() de
+ // s'executer plus tard (handler de click, async) sans crash.
+ const auth = useAuthStore()
+ const api = useApi()
+ const { t } = useI18n()
+
+ /**
+ * Synchronise le state singleton depuis le store auth. A appeler au
+ * mount du SiteSelector (ou via un watcher sur `auth.user`).
+ */
+ function syncFromAuth(): void {
+ availableSites.value = auth.user?.sites ?? []
+ currentSite.value = auth.user?.currentSite ?? null
+ }
+
+ /**
+ * Bascule le site courant. Optimistic UI : la mutation locale precede
+ * la requete HTTP. En cas d'echec (`api.patch` throw), l'etat local est
+ * restaure — le store auth n'a PAS ete muté a ce stade (la propagation
+ * `auth.setCurrentSite` se fait uniquement apres un succes HTTP), donc
+ * aucun rollback cote auth n'est necessaire.
+ *
+ * Garde anti-double-submit : si un switch est deja en vol, le second
+ * appel est un no-op silencieux.
+ */
+ async function switchSite(site: Site): Promise {
+ if (switching.value) {
+ return
+ }
+
+ const previousLocal = currentSite.value
+ currentSite.value = site
+ switching.value = true
+
+ try {
+ await api.patch(
+ '/me/current-site',
+ { site: `/api/sites/${site.id}` },
+ { toastSuccessMessage: t('sites.selector.switchSuccess') },
+ )
+ // Propage au store auth via l'action dediee — plus tracable que
+ // la mutation directe et garantit la notification des watchers.
+ // N'est appele qu'apres un succes HTTP donc pas de rollback a
+ // prevoir sur cette ligne.
+ auth.setCurrentSite(site)
+ } catch (error) {
+ currentSite.value = previousLocal
+ throw error
+ } finally {
+ switching.value = false
+ }
+ }
+
+ /**
+ * Vide l'etat singleton. Appele au logout pour eviter qu'un user
+ * suivant (connecte sur le meme onglet) voie les sites de l'ancien.
+ */
+ function resetCurrentSite(): void {
+ currentSite.value = null
+ availableSites.value = []
+ switching.value = false
+ }
+
+ return {
+ currentSite,
+ availableSites,
+ switching,
+ switchSite,
+ syncFromAuth,
+ resetCurrentSite,
+ }
+}
diff --git a/frontend/modules/sites/utils/color.ts b/frontend/modules/sites/utils/color.ts
deleted file mode 100644
index 2530d30..0000000
--- a/frontend/modules/sites/utils/color.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Validation du format de couleur d'un site.
- *
- * Aligne sur la regex backend (Site entity) : seul le format #RRGGBB
- * strict est accepte, avec 6 caracteres hexadecimaux apres le #.
- * Tolere la casse (majuscules, minuscules, mixte).
- *
- * Utilise par SiteDrawer pour bloquer le submit cote front avant qu'une
- * requete invalide parte au backend.
- */
-const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
-
-export function isValidSiteColor(hex: string): boolean {
- return HEX_COLOR_REGEX.test(hex)
-}
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index 18dd366..9f17d13 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -3,11 +3,21 @@ import { resolve } from 'node:path'
// Auto-detect module layers: every directory under frontend/modules/ becomes a Nuxt layer.
const modulesDir = resolve(__dirname, 'modules')
-const moduleLayers = existsSync(modulesDir)
+const moduleDirs = existsSync(modulesDir)
? readdirSync(modulesDir, { withFileTypes: true })
.filter(d => d.isDirectory())
- .map(d => `./modules/${d.name}`)
+ .map(d => d.name)
: []
+const moduleLayers = moduleDirs.map(name => `./modules/${name}`)
+
+// Auto-detect composables dirs pour chaque layer module. Necessaire car le
+// `imports.dirs` explicite ci-dessous override le comportement par defaut
+// de Nuxt (qui scannerait composables/ de chaque layer automatiquement).
+// Sans ca, useCurrentSite / autres composables des modules ne seraient pas
+// resolus a l'execution — cf. ticket 3 bug detecte apres review.
+const moduleComposableDirs = moduleDirs
+ .map(name => `./modules/${name}/composables`)
+ .filter(path => existsSync(resolve(__dirname, path)))
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
@@ -51,6 +61,7 @@ export default defineNuxtConfig({
'shared/composables',
'shared/utils',
'shared/stores',
+ ...moduleComposableDirs,
],
},
vite: {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6695e2e..539a0bd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -7,7 +7,7 @@
"name": "coltura-frontend",
"hasInstallScript": true,
"dependencies": {
- "@malio/layer-ui": "^1.3.0",
+ "@malio/layer-ui": "^1.4.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -22,6 +22,7 @@
"@nuxt/eslint-config": "^1.9.0",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
+ "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
@@ -83,6 +84,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -580,27 +582,6 @@
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
"license": "MIT"
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
- "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1839,9 +1820,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
- "version": "1.3.0",
- "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.3.0/layer-ui-1.3.0.tgz",
- "integrity": "sha512-Gs4pnlWTWrhoF3QQKxYBu4IxN65O9B4bls7s+ONm05qvI2Y2x7N4VNFGjWvT+rNQ4BzHFCxSCzN4V3o6p0Q7uw==",
+ "version": "1.4.0",
+ "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.0/layer-ui-1.4.0.tgz",
+ "integrity": "sha512-2LBe/WqOwNw61Y+9y2SDgsB3/JCTS7VOYfQHFLMb6GXOIj1Vmjxqf8GEzQOzre4pGI+n8w2o+VVn6ttQIkBtzA==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -2186,6 +2167,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"c12": "^3.3.3",
"consola": "^3.4.2",
@@ -2288,6 +2270,7 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/shared": "^3.5.30",
"defu": "^6.1.4",
@@ -3957,9 +3940,9 @@
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.2",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
- "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
+ "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-alias": {
@@ -4628,6 +4611,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@@ -4690,6 +4674,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -5206,12 +5191,12 @@
}
},
"node_modules/@vitejs/plugin-vue": {
- "version": "6.0.5",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
- "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==",
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
+ "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
"license": "MIT",
"dependencies": {
- "@rolldown/pluginutils": "1.0.0-rc.2"
+ "@rolldown/pluginutils": "1.0.0-rc.13"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -5469,6 +5454,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.32",
@@ -5712,6 +5698,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6099,6 +6086,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
+ "peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -6296,6 +6284,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -6410,6 +6399,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -6604,7 +6594,8 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/clean-regexp": {
"version": "1.0.0",
@@ -7657,6 +7648,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8815,6 +8807,7 @@
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
@@ -11205,6 +11198,7 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@dxup/nuxt": "^0.4.0",
"@nuxt/cli": "^3.34.0",
@@ -12263,6 +12257,7 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -12314,6 +12309,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@oxc-project/types": "^0.112.0"
},
@@ -12580,6 +12576,7 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -12658,6 +12655,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13201,6 +13199,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -13820,6 +13819,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -14717,6 +14717,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -15372,6 +15373,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -15638,6 +15640,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16556,6 +16559,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
@@ -16600,6 +16604,7 @@
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
@@ -16636,6 +16641,7 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@intlify/core-base": "11.3.1",
"@intlify/devtools-types": "11.3.1",
diff --git a/frontend/package.json b/frontend/package.json
index 5d025f4..4b12611 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,7 +15,7 @@
"test:watch": "vitest"
},
"dependencies": {
- "@malio/layer-ui": "^1.3.0",
+ "@malio/layer-ui": "^1.4.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -30,6 +30,7 @@
"@nuxt/eslint-config": "^1.9.0",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
+ "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
diff --git a/frontend/shared/composables/__tests__/useModules.test.ts b/frontend/shared/composables/__tests__/useModules.test.ts
new file mode 100644
index 0000000..0854fe7
--- /dev/null
+++ b/frontend/shared/composables/__tests__/useModules.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { useModules } from '../useModules'
+
+// Mock de useApi : on peut scripter la reponse de /api/modules.
+const mockApiGet = vi.hoisted(() => vi.fn())
+
+// useApi est auto-importe par Nuxt en prod. En Vitest isole, on expose le
+// mock comme global pour que l'appel sans import dans useModules.ts
+// (pattern aligne sur useSidebar) fonctionne.
+vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
+
+describe('useModules', () => {
+ beforeEach(() => {
+ mockApiGet.mockReset()
+ // Reset l'etat singleton entre tests.
+ const { resetModules } = useModules()
+ resetModules()
+ })
+
+ it('charge la liste des modules actifs depuis /api/modules', async () => {
+ mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
+ const { loadModules, activeModuleIds, loaded } = useModules()
+
+ await loadModules()
+
+ expect(mockApiGet).toHaveBeenCalledWith('/modules', {}, { toast: false })
+ expect(activeModuleIds.value).toEqual(['core', 'sites'])
+ expect(loaded.value).toBe(true)
+ })
+
+ it('isModuleActive retourne true pour un id present', async () => {
+ mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
+ const { loadModules, isModuleActive } = useModules()
+ await loadModules()
+
+ expect(isModuleActive('sites')).toBe(true)
+ expect(isModuleActive('core')).toBe(true)
+ })
+
+ it('isModuleActive retourne false pour un id absent', async () => {
+ mockApiGet.mockResolvedValueOnce({ modules: ['core'] })
+ const { loadModules, isModuleActive } = useModules()
+ await loadModules()
+
+ expect(isModuleActive('sites')).toBe(false)
+ expect(isModuleActive('inexistant')).toBe(false)
+ })
+
+ it('swallow les erreurs reseau et laisse la liste vide', async () => {
+ mockApiGet.mockRejectedValueOnce(new Error('boom'))
+ const { loadModules, activeModuleIds, loaded, isModuleActive } = useModules()
+
+ await loadModules()
+
+ expect(activeModuleIds.value).toEqual([])
+ expect(loaded.value).toBe(true)
+ expect(isModuleActive('sites')).toBe(false)
+ })
+
+ it('resetModules vide l\'etat', async () => {
+ mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
+ const { loadModules, resetModules, activeModuleIds, loaded } = useModules()
+ await loadModules()
+ expect(activeModuleIds.value.length).toBeGreaterThan(0)
+
+ resetModules()
+
+ expect(activeModuleIds.value).toEqual([])
+ expect(loaded.value).toBe(false)
+ })
+})
diff --git a/frontend/shared/composables/__tests__/useSidebar.test.ts b/frontend/shared/composables/__tests__/useSidebar.test.ts
new file mode 100644
index 0000000..237d279
--- /dev/null
+++ b/frontend/shared/composables/__tests__/useSidebar.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { useSidebar } from '../useSidebar'
+
+const mockApiGet = vi.hoisted(() => vi.fn())
+vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
+
+/**
+ * Tests de l'invariant "loadSidebar ne reject jamais".
+ *
+ * Garantie utilisee par le middleware auth.global.ts qui fait un
+ * Promise.all([loadSidebar(), loadModules()]) — si l'un throw, le
+ * middleware echoue et toute l'app avec. Le swallow interne est donc
+ * load-bearing et ce test le verrouille.
+ */
+describe('useSidebar', () => {
+ beforeEach(() => {
+ mockApiGet.mockReset()
+ const { resetSidebar } = useSidebar()
+ resetSidebar()
+ })
+
+ it('charge sections et disabledRoutes depuis /api/sidebar', async () => {
+ mockApiGet.mockResolvedValueOnce({
+ sections: [{ label: 's', icon: 'i', items: [] }],
+ disabledRoutes: ['/foo'],
+ })
+ const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
+
+ await loadSidebar()
+
+ expect(sections.value).toHaveLength(1)
+ expect(disabledRoutes.value).toEqual(['/foo'])
+ expect(loaded.value).toBe(true)
+ })
+
+ it('swallow les erreurs reseau sans rejeter (invariant middleware)', async () => {
+ mockApiGet.mockRejectedValueOnce(new Error('boom'))
+ const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
+
+ // Assertion principale : la promise resout normalement meme sur erreur.
+ await expect(loadSidebar()).resolves.toBeUndefined()
+ expect(sections.value).toEqual([])
+ expect(disabledRoutes.value).toEqual([])
+ expect(loaded.value).toBe(true)
+ })
+
+ it('isRouteDisabled matche exactement un chemin', async () => {
+ mockApiGet.mockResolvedValueOnce({ sections: [], disabledRoutes: ['/foo'] })
+ const { loadSidebar, isRouteDisabled } = useSidebar()
+ await loadSidebar()
+
+ expect(isRouteDisabled('/foo')).toBe(true)
+ expect(isRouteDisabled('/foo/bar')).toBe(true)
+ expect(isRouteDisabled('/other')).toBe(false)
+ })
+
+ it('resetSidebar vide l\'etat', async () => {
+ mockApiGet.mockResolvedValueOnce({
+ sections: [{ label: 's', icon: 'i', items: [] }],
+ disabledRoutes: ['/foo'],
+ })
+ const { loadSidebar, resetSidebar, sections, loaded } = useSidebar()
+ await loadSidebar()
+ expect(loaded.value).toBe(true)
+
+ resetSidebar()
+
+ expect(sections.value).toEqual([])
+ expect(loaded.value).toBe(false)
+ })
+})
diff --git a/frontend/shared/composables/useModules.ts b/frontend/shared/composables/useModules.ts
new file mode 100644
index 0000000..73d7550
--- /dev/null
+++ b/frontend/shared/composables/useModules.ts
@@ -0,0 +1,49 @@
+/**
+ * Composable de lecture des modules actifs (source : `/api/modules`).
+ *
+ * State singleton au niveau module : `useSidebar` suit la meme convention.
+ * Chargement idempotent via le flag `loaded`, reset explicite au logout
+ * (voir pages/logout.vue).
+ */
+import { ref } from 'vue'
+
+const activeModuleIds = ref([])
+const loaded = ref(false)
+
+export function useModules() {
+ async function loadModules() {
+ try {
+ const api = useApi()
+ const data = await api.get<{ modules: string[] }>(
+ '/modules',
+ {},
+ { toast: false },
+ )
+ activeModuleIds.value = data.modules ?? []
+ loaded.value = true
+ } catch {
+ // Swallow volontaire aligne sur useSidebar : un echec reseau ne
+ // doit pas bloquer le rendu, l'app affichera juste sans la
+ // granularite module (selector masque par defaut).
+ activeModuleIds.value = []
+ loaded.value = true
+ }
+ }
+
+ function isModuleActive(id: string): boolean {
+ return activeModuleIds.value.includes(id)
+ }
+
+ function resetModules() {
+ activeModuleIds.value = []
+ loaded.value = false
+ }
+
+ return {
+ activeModuleIds,
+ loaded,
+ loadModules,
+ isModuleActive,
+ resetModules,
+ }
+}
diff --git a/frontend/shared/composables/useSidebar.ts b/frontend/shared/composables/useSidebar.ts
index 0e0bb0a..adbdbbf 100644
--- a/frontend/shared/composables/useSidebar.ts
+++ b/frontend/shared/composables/useSidebar.ts
@@ -1,3 +1,4 @@
+import { ref } from 'vue'
import type { SidebarSection } from '~/shared/types'
const sections = ref([])
diff --git a/frontend/shared/stores/auth.ts b/frontend/shared/stores/auth.ts
index 35b5ed1..76aece3 100644
--- a/frontend/shared/stores/auth.ts
+++ b/frontend/shared/stores/auth.ts
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import type { UserData } from '~/shared/types/user-data'
+import type { Site } from '~/shared/types/sites'
import { getCurrentUser, login, logout } from '~/shared/services/auth'
export const useAuthStore = defineStore('auth', {
@@ -66,6 +67,18 @@ export const useAuthStore = defineStore('auth', {
} catch {
// Silently fail — user session might have expired
}
+ },
+ /**
+ * Action dediee au switch du site courant (ticket 3 module Sites).
+ * Utilisee par useCurrentSite apres la confirmation serveur, et en
+ * rollback si la requete PATCH echoue apres une mutation optimistic.
+ * Passer explicitement par une action plutot que muter user.currentSite
+ * directement garantit la tracabilite Pinia (devtools).
+ */
+ setCurrentSite(site: Site | null) {
+ if (this.user) {
+ this.user.currentSite = site
+ }
}
}
})
diff --git a/frontend/modules/sites/utils/__tests__/color.test.ts b/frontend/shared/utils/__tests__/color.test.ts
similarity index 72%
rename from frontend/modules/sites/utils/__tests__/color.test.ts
rename to frontend/shared/utils/__tests__/color.test.ts
index e07cad7..4210eae 100644
--- a/frontend/modules/sites/utils/__tests__/color.test.ts
+++ b/frontend/shared/utils/__tests__/color.test.ts
@@ -16,15 +16,15 @@ describe('isValidSiteColor', () => {
it('accepte les couleurs fixtures du projet', () => {
expect(isValidSiteColor('#056CF2')).toBe(true)
- expect(isValidSiteColor('#10B981')).toBe(true)
- expect(isValidSiteColor('#F59E0B')).toBe(true)
+ expect(isValidSiteColor('#F3CB00')).toBe(true)
+ expect(isValidSiteColor('#74BF04')).toBe(true)
})
it('rejette un nom CSS', () => {
expect(isValidSiteColor('red')).toBe(false)
})
- it('rejette un hex court (3 caracteres)', () => {
+ it('rejette un hex court', () => {
expect(isValidSiteColor('#FFF')).toBe(false)
})
@@ -32,14 +32,6 @@ describe('isValidSiteColor', () => {
expect(isValidSiteColor('FFFFFF')).toBe(false)
})
- it('rejette un format rgb()', () => {
- expect(isValidSiteColor('rgb(255, 0, 0)')).toBe(false)
- })
-
- it('rejette un hex trop long', () => {
- expect(isValidSiteColor('#1234567')).toBe(false)
- })
-
it('rejette un caractere non hex', () => {
expect(isValidSiteColor('#12345G')).toBe(false)
})
diff --git a/frontend/shared/utils/color.ts b/frontend/shared/utils/color.ts
new file mode 100644
index 0000000..8396973
--- /dev/null
+++ b/frontend/shared/utils/color.ts
@@ -0,0 +1,19 @@
+/**
+ * Utilitaires de couleur partages.
+ *
+ * Aligne sur la regex backend stricte #RRGGBB (voir Site.php).
+ */
+
+const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
+
+/**
+ * Valide qu'une chaine respecte le format #RRGGBB strict (7 caracteres,
+ * 6 chiffres hexadecimaux apres le #). Tolere la casse (majuscules,
+ * minuscules, mixte).
+ *
+ * Utilise cote front par SiteDrawer pour bloquer le submit avant l'envoi
+ * backend — miroir du pattern Symfony Assert\Regex sur Site::$color.
+ */
+export function isValidSiteColor(hex: string): boolean {
+ return HEX_COLOR_REGEX.test(hex)
+}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 9ee0720..946c045 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -1,7 +1,9 @@
import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
+ plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
diff --git a/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php b/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php
index 2362271..cd316fc 100644
--- a/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php
+++ b/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php
@@ -36,7 +36,7 @@ class SitesFixtures extends Fixture
public function load(ObjectManager $manager): void
{
- // Chatellerault : couleur imposee par le ticket (bleu Coltura).
+ // Chatellerault : bleu Coltura.
$this->ensureSite(
$manager,
name: 'Chatellerault',
@@ -47,9 +47,9 @@ class SitesFixtures extends Fixture
color: '#056CF2',
);
- // Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault.
- // Note : le nom du site (identifier) ne reflete pas la ville reelle
- // (Fontenet) — c'est une nomenclature interne client.
+ // Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete
+ // pas la ville reelle (Fontenet) — c'est une nomenclature interne
+ // client.
$this->ensureSite(
$manager,
name: 'Saint-Jean',
@@ -57,10 +57,10 @@ class SitesFixtures extends Fixture
complement: null,
postalCode: '17400',
city: 'Fontenet',
- color: '#10B981',
+ color: '#F3CB00',
);
- // Pommevic : ambre pour une troisieme teinte nettement distincte.
+ // Pommevic : vert clair.
$this->ensureSite(
$manager,
name: 'Pommevic',
@@ -68,7 +68,7 @@ class SitesFixtures extends Fixture
complement: null,
postalCode: '82400',
city: 'Pommevic',
- color: '#F59E0B',
+ color: '#74BF04',
);
$manager->flush();