diff --git a/config/sidebar.php b/config/sidebar.php
index fd45eda..7fd5bea 100644
--- a/config/sidebar.php
+++ b/config/sidebar.php
@@ -184,6 +184,9 @@ return [
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
// (aucune permission RBAC requise, tous les items restent dans `core` pour
// rester toujours presents meme quand les modules metier sont desactives).
+ // La deconnexion a quitte cette section : elle vit desormais dans le footer
+ // de la sidebar (compte connecte + lien deconnexion + version, cf.
+ // frontend/app/layouts/default.vue + useLogout).
[
'label' => 'sidebar.account.section',
'icon' => 'mdi:account-circle-outline',
@@ -194,12 +197,6 @@ return [
'icon' => 'mdi:view-dashboard-outline',
'module' => 'core',
],
- [
- 'label' => 'sidebar.account.logout',
- 'to' => '/logout',
- 'icon' => 'mdi:logout',
- 'module' => 'core',
- ],
],
],
];
diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue
index ee46cf8..e8978b6 100644
--- a/frontend/app/layouts/default.vue
+++ b/frontend/app/layouts/default.vue
@@ -21,6 +21,45 @@
+
+
+
+
+
+
+
+
+ {{ initials }}
+ {{ username }}
+
+
+
+
v {{ version }}
+
+
+
+
+
+
+
@@ -42,6 +81,18 @@ const {isModuleActive} = useModules()
const auth = useAuthStore()
const route = useRoute()
+// Footer de la sidebar : compte connecte + deconnexion inline + version.
+const {logout: onLogout} = useLogout()
+const {version, load: loadAppVersion} = useAppVersion()
+
+const username = computed(() => auth.user?.username ?? '')
+// Pastille avatar : 1re lettre du compte (meme convention que la maquette Malio).
+const initials = computed(() => username.value.charAt(0).toUpperCase() || '?')
+
+onMounted(() => {
+ void loadAppVersion()
+})
+
// Le SiteSelector est rendu si :
// - le module Sites est actif dans config/modules.php (sinon la feature
// n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ;
diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
index ff85c49..d1fef82 100644
--- a/frontend/modules/catalog/composables/useCategoriesAdmin.ts
+++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
@@ -11,8 +11,9 @@
* la recharger a chaque ouverture du drawer.
*
* State singleton au niveau module : reset automatique au logout via
- * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
- * explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
+ * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), declenche par
+ * `clearSession()` (logout volontaire `useLogout` ou intercepteur 401).
+ * `resetCategoriesAdmin()` reste expose pour un reset manuel/tests.
*/
import { ref } from 'vue'
import type { CategoryType } from '~/modules/catalog/types/category'
@@ -38,10 +39,9 @@ function resetCategoriesAdminState(): void {
error.value = null
}
-// Auto-enregistrement singleton : purge le state sur 401/clearSession
-// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
-// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
-// appelle directement `resetCategoriesAdmin()` ci-dessous.
+// Auto-enregistrement singleton : purge le state sur clearSession() (logout
+// volontaire via useLogout, ou intercepteur 401) pour eviter qu'un user suivant
+// (connecte sur le meme onglet) voie le referentiel de l'ancien tenant.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
@@ -73,9 +73,9 @@ export function useCategoriesAdmin() {
}
/**
- * Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
- * pour garantir que la prochaine session reparte sur un state propre
- * meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
+ * Reset explicite expose pour un reset manuel (tests, ou appel cible).
+ * Au logout, le reset est deja garanti par `onAuthSessionCleared`
+ * (declenche par `clearSession()` dans `auth.logout()`).
*/
function resetCategoriesAdmin(): void {
resetCategoriesAdminState()
diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue
deleted file mode 100644
index 21ff0a0..0000000
--- a/frontend/modules/core/pages/logout.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
{{ $t('auth.logout') }}...
-
-
-
-
diff --git a/frontend/modules/sites/composables/useCurrentSite.ts b/frontend/modules/sites/composables/useCurrentSite.ts
index da196d2..a2035fb 100644
--- a/frontend/modules/sites/composables/useCurrentSite.ts
+++ b/frontend/modules/sites/composables/useCurrentSite.ts
@@ -6,8 +6,8 @@
* 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`).
+ * concurrents. Le state est purge au logout via `onAuthSessionCleared`
+ * (declenche par `clearSession()`, cf. `useLogout` et l'intercepteur 401).
*
* Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
* garantit deja l'invariant "user avec sites non vide => currentSite non null"
@@ -30,8 +30,8 @@ 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).
+// est appelee (logout volontaire via useLogout, ou intercepteur 401 de useApi),
+// le state local est purgé.
onAuthSessionCleared(() => {
currentSite.value = null
availableSites.value = []
diff --git a/frontend/shared/composables/useLogout.ts b/frontend/shared/composables/useLogout.ts
new file mode 100644
index 0000000..4ff444a
--- /dev/null
+++ b/frontend/shared/composables/useLogout.ts
@@ -0,0 +1,21 @@
+/**
+ * Déconnexion centralisée — déclenchée directement par un handler (ex: lien du
+ * footer de la sidebar), sans passer par une page de redirection dédiée.
+ *
+ * `authStore.logout()` invalide la session serveur (POST /api/logout), vide
+ * l'état auth, et appelle `clearSession()` qui notifie tous les composables
+ * singletons (sidebar, modules, currentSite, auditLog, categoriesAdmin) via
+ * `onAuthSessionCleared` — leurs états sont donc réinitialisés ici sans aucun
+ * reset manuel. La redirection vers `/login` (inévitable : un utilisateur
+ * déconnecté ne peut pas rester sur une page protégée) est la seule navigation.
+ */
+export function useLogout() {
+ const auth = useAuthStore()
+
+ async function logout(): Promise {
+ await auth.logout()
+ await navigateTo('/login')
+ }
+
+ return { logout }
+}
diff --git a/frontend/shared/stores/auth.ts b/frontend/shared/stores/auth.ts
index d448947..ed6da1a 100644
--- a/frontend/shared/stores/auth.ts
+++ b/frontend/shared/stores/auth.ts
@@ -77,9 +77,11 @@ export const useAuthStore = defineStore('auth', {
} catch {
// Ignore logout errors so we can still clear local auth state.
} finally {
- this.user = null
- this.checked = true
- this.isLoading = false
+ // clearSession() vide l'etat auth ET notifie les composables
+ // singletons (sidebar, modules, currentSite, auditLog,
+ // categoriesAdmin) via onAuthSessionCleared : plus besoin de
+ // resets manuels au logout — meme chemin que l'intercepteur 401.
+ this.clearSession()
}
},
async refreshUser() {
diff --git a/frontend/tests/e2e/auth/login.spec.ts b/frontend/tests/e2e/auth/login.spec.ts
index 0cfca7f..9bab667 100644
--- a/frontend/tests/e2e/auth/login.spec.ts
+++ b/frontend/tests/e2e/auth/login.spec.ts
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test'
import { LoginPage } from '../helpers/pages/LoginPage'
+import { SidebarComponent } from '../helpers/pages/SidebarComponent'
import { getPersona } from '../_fixtures/personas'
/**
@@ -53,8 +54,12 @@ test.describe('Login', () => {
await loginPage.fillAndSubmit(superAdmin.username, superAdmin.password)
await page.waitForURL('/')
- // 2. Navigation vers /logout (il y a un lien "Deconnexion" dans la sidebar)
- await page.goto('/logout')
+ // 2. Deconnexion via le footer de la sidebar : survol du bloc compte
+ // (revele le bouton) puis clic. Le handler appelle useLogout() qui POST
+ // /api/logout, reset les stores, et redirige vers /login (sans page /logout).
+ const sidebar = new SidebarComponent(page)
+ await sidebar.accountBlock().hover()
+ await sidebar.logoutButton().click()
await page.waitForURL(/\/login$/)
// 3. Le cookie BEARER doit avoir ete supprime par le firewall de logout
diff --git a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts
index 9e7a130..6c64fab 100644
--- a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts
+++ b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts
@@ -27,7 +27,21 @@ export class SidebarComponent {
return this.page.locator('a[href="/"]').first()
}
- logoutLink(): Locator {
- return this.page.locator('a[href="/logout"]')
+ /**
+ * Bloc « compte connecte » du footer de la sidebar. Cible de survol qui
+ * revele le bouton de deconnexion (la deconnexion n'est plus un item de nav
+ * `/logout` mais un lien du footer, cf. default.vue + useLogout).
+ */
+ accountBlock(): Locator {
+ return this.page.locator('[data-test="sidebar-account"]')
+ }
+
+ /**
+ * Bouton de deconnexion du footer (revele au survol du bloc compte en mode
+ * deplie, ou directement la pastille en mode replie). Selecteur par
+ * `data-test` : stable au renommage/retraduction du label.
+ */
+ logoutButton(): Locator {
+ return this.page.locator('[data-test="sidebar-logout"]')
}
}
diff --git a/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts b/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts
index 01323df..17187c0 100644
--- a/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts
+++ b/frontend/tests/e2e/permissions/sidebar-visibility.spec.ts
@@ -72,7 +72,10 @@ test.describe('Sidebar visibility', () => {
// Meme strategie que ci-dessus : ancrage semantique plutot que
// `networkidle` pour eviter les faux timeouts en CI.
await expect(sidebar.accountDashboardLink()).toBeVisible({ timeout: 10000 })
- await expect(sidebar.logoutLink()).toBeVisible()
+ // La deconnexion vit dans le footer (rendu sans condition de permission).
+ // Le bouton est revele au survol du bloc compte.
+ await sidebar.accountBlock().hover()
+ await expect(sidebar.logoutButton()).toBeVisible()
})
test('la liste des personas dans personas.ts couvre toutes les combinaisons admin attendues', () => {