From d6a40e48436c2f677a8ca59993980814d1194a76 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 10:24:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(core)=20:=20footer=20sidebar=20=E2=80=94?= =?UTF-8?q?=20compte=20connect=C3=A9=20+=20d=C3=A9connexion=20inline=20+?= =?UTF-8?q?=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déconnexion déplacée du menu vers le footer (compte connecté au survol + version). useLogout() appelle clearSession() (reset des stores singletons via onAuthSessionCleared) puis redirige vers /login, sans page /logout intermédiaire. --- config/sidebar.php | 9 ++-- frontend/app/layouts/default.vue | 51 +++++++++++++++++++ .../catalog/composables/useCategoriesAdmin.ts | 18 +++---- frontend/modules/core/pages/logout.vue | 35 ------------- .../sites/composables/useCurrentSite.ts | 8 +-- frontend/shared/composables/useLogout.ts | 21 ++++++++ frontend/shared/stores/auth.ts | 8 +-- frontend/tests/e2e/auth/login.spec.ts | 9 +++- .../e2e/helpers/pages/SidebarComponent.ts | 18 ++++++- .../permissions/sidebar-visibility.spec.ts | 5 +- 10 files changed, 120 insertions(+), 62 deletions(-) delete mode 100644 frontend/modules/core/pages/logout.vue create mode 100644 frontend/shared/composables/useLogout.ts 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 @@ + + + + + +
@@ -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 @@ - - - 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', () => {