diff --git a/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md b/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md new file mode 100644 index 0000000..9885c9a --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md @@ -0,0 +1,969 @@ +# LST-62 (0.2) — Socle front : shell + auto-détection des layers Nuxt — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Poser l'ossature frontend modulaire (shell `app/`, code partagé `shared/`, auto-détection des layers `modules/*/`, sidebar dynamique alimentée par `/api/sidebar`, redirection des routes désactivées) **sans déplacer aucune page métier** — l'app reste « plate » et la navigation ne régresse pas. + +**Architecture:** On s'aligne sur le pattern Starseed : `srcDir: '.'`, layouts/middleware sous `frontend/app/`, composables/stores transverses sous `frontend/shared/` (auto-importés via `imports.dirs`), et un scan `readdirSync('modules/')` qui ajoute chaque `modules/*/` à `extends`. Le backend `/api/modules` + `/api/sidebar` existe déjà (LST-56). On ajoute un **gate de rôle minimal** côté `SidebarProvider`/`SidebarFilter` (ROLE_ADMIN) pour préserver la visibilité de l'Administration sans attendre le RBAC fin (#1.2). Les items **contextuels** (Kanban/Groupes/Archives), **feature-flag** (Documents, Mail) et **user-flag** (Mes absences) restent rendus côté layout, hors `/api/sidebar`. + +**Tech Stack:** Nuxt 4.3, Vue 3.5, Pinia 3, @malio/layer-ui 1.7, @nuxtjs/i18n 10, @nuxt/icon — côté back PHP 8.4 / Symfony 8 / API Platform 4 / PHPUnit 13. + +## Global Constraints + +- **Aucune page métier déplacée** : `frontend/pages/` reste tel quel ; on ne crée AUCUN `frontend/modules//pages/` peuplé en 0.2 (le dossier `modules/` est créé vide pour le scan). +- **Zéro régression de navigation** : tous les liens actuels restent atteignables et correctement gardés (admin reste admin-only). +- **Auto-import Nuxt** : les composants/pages référencent les composables/stores **par nom** (`useApi()`, `useAuthStore()`), jamais par chemin → déplacer un fichier entre deux dossiers auto-scannés est transparent. Toujours le vérifier par un `typecheck` après déplacement. +- **Commits** : format `() : ` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic (message, body, trailers). +- **PHP** : `declare(strict_types=1);` en tête ; tests via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`. +- **TS** : strict, 4 espaces d'indentation, pas de `any`. +- **Pas de migration BDD** dans ce lot (aucune entité touchée). + +## Décisions de conception (actées avec le PO) + +1. **Gate de rôle minimal côté back** : les items/sections réservés (`/team-absences`, `/admin`) portent une clé `roles` dans `config/sidebar.php` ; `SidebarProvider` passe les rôles de l'utilisateur courant à `SidebarFilter` qui masque ce qui n'est pas autorisé. Ce n'est **pas** le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER. +2. **Items contextuels / feature-flag / user-flag hors `/api/sidebar`** : Kanban/Groupes/Archives (contexte `currentProjectId`), Documents (`shareEnabled`), Mail (+ badge non lus), Mes absences (`isEmployee`) restent rendus par le layout comme aujourd'hui. +3. **Délta cosmétique assumé** : la sidebar dynamique regroupe le Tableau de bord avec « Mes tâches / Projets / Suivi de temps » sous un même en-tête, et le bloc statique (contextuel/flag/Mes absences) s'insère après cette première section. Léger réordonnancement visuel, **à valider**, harmonisé en #60 (Finition Malio). Aucun lien perdu. + +## Vérification (pas de runner de tests JS dans ce projet) + +- **Back (Task 1)** : vraie TDD PHPUnit. +- **Front (Tasks 2-7)** : la verif = `typecheck` Nuxt + smoke test runtime. Commandes : + - Typecheck : `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` + - Runtime : dev server `make dev-nuxt` (port 3002, proxy `/api` → nginx) ; vérifier manuellement la navigation + `curl` des endpoints via nginx (`http://localhost:8082/api/...`). Les containers sont up. + +--- + +### Task 1: Backend — gate de rôle dans la sidebar (`roles`) + config complète + +**Files:** +- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (signature + gate `roles`) +- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (injecter `Security`, passer les rôles) +- Modify: `config/sidebar.php` (navigation globale + section Administration gated ROLE_ADMIN ; retrait de `/absences` qui reste client-side) +- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (adapter à la nouvelle signature + cas `roles`) +- Modify: `tests/Functional/Shared/SidebarEndpointTest.php` (vérifier le gate admin) + +**Interfaces:** +- Produces : `SidebarFilter::filter(array $sections, array $activeModuleIds, array $activeRoles = []): array`. Règles ajoutées : une **section** ou un **item** portant une clé `roles` (non vide) n'est conservé que si `$activeRoles` contient au moins un des rôles listés ; sinon la section/l'item est retiré (les `to` des items retirés **par rôle** ne sont PAS ajoutés à `disabledRoutes` — `disabledRoutes` reste réservé au filtrage **par module**, qui pilote la redirection front). Les clés internes `module` et `roles` sont retirées de la sortie. +- Consumes : `Symfony\Bundle\SecurityBundle\Security` (rôles via `getUser()`). + +- [ ] **Step 1: Adapter le test unitaire existant + ajouter les cas `roles`** + +Remplace INTÉGRALEMENT `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` par : + +```php + 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [ + ['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertSame('/', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]); + } + + public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void + { + $sections = [ + ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [ + ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertSame([], $result['sections']); + self::assertSame(['/time-tracking'], $result['disabledRoutes']); + } + + public function testItemWithActiveModuleIsVisible(): void + { + $sections = [ + ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [ + ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'], + ]], + ]; + + $result = SidebarFilter::filter($sections, ['time_tracking'], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + } + + public function testSectionWithRolesIsHiddenWhenRoleMissing(): void + { + $sections = [ + ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [ + ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertSame([], $result['sections']); + // Filtrage par rôle => PAS de disabledRoutes (réservé au filtrage par module). + self::assertSame([], $result['disabledRoutes']); + } + + public function testSectionWithRolesIsVisibleWhenRolePresent(): void + { + $sections = [ + ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [ + ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER', 'ROLE_ADMIN']); + + self::assertCount(1, $result['sections']); + self::assertSame('/admin', $result['sections'][0]['items'][0]['to']); + self::assertArrayNotHasKey('roles', $result['sections'][0]); + } + + public function testItemWithRolesIsHiddenWhenRoleMissing(): void + { + $sections = [ + ['label' => 'sidebar.hr.section', 'icon' => 'mdi:calendar', 'items' => [ + ['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group', 'roles' => ['ROLE_ADMIN']], + ['label' => 'sidebar.hr.x', 'to' => '/x', 'icon' => 'mdi:x'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertCount(1, $result['sections'][0]['items']); + self::assertSame('/x', $result['sections'][0]['items'][0]['to']); + self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]); + } +} +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: FAIL — `filter()` actuel n'accepte que 2 args / ne gère pas `roles` (erreur d'arité ou assertions rouges). + +- [ ] **Step 3: Étendre `SidebarFilter`** + +Remplace INTÉGRALEMENT `src/Shared/Domain/Sidebar/SidebarFilter.php` par : + +```php +, items: list}>}> $sections + * @param list $activeModuleIds + * @param list $activeRoles + * + * @return array{sections: list}>, disabledRoutes: list} + */ + public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array + { + $outSections = []; + $disabledRoutes = []; + + foreach ($sections as $section) { + // Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module). + if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) { + continue; + } + + $items = []; + foreach ($section['items'] as $item) { + // Gate de rôle au niveau item. + if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) { + continue; + } + + // Filtrage par module actif (pilote la redirection front via disabledRoutes). + $module = $item['module'] ?? null; + if (null !== $module && !in_array($module, $activeModuleIds, true)) { + $disabledRoutes[] = $item['to']; + + continue; + } + + $items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']]; + } + + if ([] !== $items) { + $outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items]; + } + } + + return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes]; + } + + /** + * @param list|null $required + * @param list $activeRoles + */ + private static function rolesSatisfied(?array $required, array $activeRoles): bool + { + if (null === $required || [] === $required) { + return true; + } + + foreach ($required as $role) { + if (in_array($role, $activeRoles, true)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 4: Lancer le test unitaire, vérifier le vert** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: PASS (6 tests). + +- [ ] **Step 5: Injecter les rôles dans `SidebarProvider`** + +Remplace INTÉGRALEMENT `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` par : + +```php + $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + /** @var list, items: list}>}> $sidebar */ + $sidebar = require $this->projectDir.'/config/sidebar.php'; + + $user = $this->security->getUser(); + $roles = null !== $user ? $user->getRoles() : []; + + $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles)); + + $dto = new SidebarResource(); + $dto->sections = $filtered['sections']; + $dto->disabledRoutes = $filtered['disabledRoutes']; + + return $dto; + } +} +``` + +- [ ] **Step 6: Compléter `config/sidebar.php`** + +Remplace INTÉGRALEMENT `config/sidebar.php` par (icônes alignées sur le layout actuel ; `/absences` retiré car gardé client-side via `isEmployee`) : + +```php +.). + */ +return [ + [ + 'label' => 'sidebar.general.section', + 'icon' => 'mdi:view-dashboard-outline', + 'items' => [ + ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], + ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'], + ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'], + ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'], + ], + ], + [ + 'label' => 'sidebar.admin.section', + 'icon' => 'mdi:cog-outline', + 'roles' => ['ROLE_ADMIN'], + 'items' => [ + ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], + ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'], + ], + ], +]; +``` + +- [ ] **Step 7: Renforcer le test fonctionnel sidebar (gate admin)** + +Remplace INTÉGRALEMENT `tests/Functional/Shared/SidebarEndpointTest.php` par : + +```php +request('GET', '/api/sidebar'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSidebarReturnsSectionsForAuthenticatedUser(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('sections', $data); + self::assertArrayHasKey('disabledRoutes', $data); + self::assertNotEmpty($data['sections']); + } + + public function testAdminSectionHiddenForNonAdmin(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // ROLE_USER + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + $data = json_decode($client->getResponse()->getContent(), true); + $labels = array_column($data['sections'], 'label'); + + self::assertNotContains('sidebar.admin.section', $labels); + } + + public function testAdminSectionVisibleForAdmin(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); // ROLE_ADMIN + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + $data = json_decode($client->getResponse()->getContent(), true); + $labels = array_column($data['sections'], 'label'); + + self::assertContains('sidebar.admin.section', $labels); + } +} +``` + +- [ ] **Step 8: Lancer la suite complète, vérifier le vert** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` +Expected: PASS (les 110 tests précédents adaptés + nouveaux cas). Si `admin`/`alice` n'existent pas en base de test, vérifier les fixtures (`admin`/`admin`, `alice`/`alice` d'après CLAUDE.md). + +- [ ] **Step 9: php-cs-fixer + commit** + +Run: `make php-cs-fixer-allow-risky` +```bash +git add src/Shared/Domain/Sidebar/SidebarFilter.php src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php config/sidebar.php tests/Unit/Shared/Sidebar/SidebarFilterTest.php tests/Functional/Shared/SidebarEndpointTest.php +git commit -m "feat(sidebar) : add role gate to sidebar provider and global nav config" +``` + +--- + +### Task 2: Frontend — types + composables partagés (`useModules`, `useSidebar`) + +**Files:** +- Create: `frontend/shared/types/sidebar.ts` +- Create: `frontend/shared/composables/useModules.ts` +- Create: `frontend/shared/composables/useSidebar.ts` + +> Note : à cette étape `shared/` n'est pas encore dans `imports.dirs` (fait en Task 4). Ces fichiers sont créés ici mais référencés/auto-importés seulement après Task 4 ; le typecheck final de validation se fait donc en fin de Task 4. Cette task se termine sans verif runtime (pur ajout de fichiers). + +**Interfaces:** +- Produces : + - `useModules(): { activeModuleIds: Ref, loaded: Ref, loadModules(): Promise, isModuleActive(id: string): boolean, resetModules(): void }` + - `useSidebar(): { sections: Ref, disabledRoutes: Ref, loaded: Ref, loadSidebar(): Promise, isRouteDisabled(path: string): boolean, resetSidebar(): void }` + - `SidebarSection`, `SidebarItem` (types). +- Consumes : `useApi()` (auto-importé, déplacé en Task 3 — toujours appelé par nom). + +- [ ] **Step 1: Créer les types** + +`frontend/shared/types/sidebar.ts` : + +```ts +export type SidebarItem = { + label: string + to: string + icon: string +} + +export type SidebarSection = { + label: string + icon: string + items: SidebarItem[] +} +``` + +- [ ] **Step 2: Créer `useModules`** + +`frontend/shared/composables/useModules.ts` (état singleton au niveau module) : + +```ts +const activeModuleIds = ref([]) +const loaded = ref(false) + +export function useModules() { + async function loadModules(): Promise { + const api = useApi() + const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false }) + activeModuleIds.value = data.modules ?? [] + loaded.value = true + } + + function isModuleActive(id: string): boolean { + return activeModuleIds.value.includes(id) + } + + function resetModules(): void { + activeModuleIds.value = [] + loaded.value = false + } + + return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules } +} +``` + +> Vérifier la signature réelle de `useApi().get` (Task 3 / source actuelle) : `get(url, query?, options?)`. L'option `{ toast: false }` doit exister dans `ApiFetchOptions` ; si la clé diffère (ex. `toastSuccessKey`/`toast`), aligner sur la signature réelle de `useApi.ts`. Si aucune option « silencieux » n'existe, passer `{}`. + +- [ ] **Step 3: Créer `useSidebar`** + +`frontend/shared/composables/useSidebar.ts` : + +```ts +import type { SidebarSection } from '~/shared/types/sidebar' + +const sections = ref([]) +const disabledRoutes = ref([]) +const loaded = ref(false) + +export function useSidebar() { + async function loadSidebar(): Promise { + const api = useApi() + const data = await api.get<{ sections: SidebarSection[]; disabledRoutes: string[] }>( + '/sidebar', {}, { toast: false }, + ) + sections.value = data.sections ?? [] + disabledRoutes.value = data.disabledRoutes ?? [] + loaded.value = true + } + + function isRouteDisabled(path: string): boolean { + return disabledRoutes.value.some( + (disabled) => path === disabled || path.startsWith(disabled + '/'), + ) + } + + function resetSidebar(): void { + sections.value = [] + disabledRoutes.value = [] + loaded.value = false + } + + return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/shared/types/sidebar.ts frontend/shared/composables/useModules.ts frontend/shared/composables/useSidebar.ts +git commit -m "feat(front) : add shared useModules/useSidebar composables and sidebar types" +``` + +--- + +### Task 3: Frontend — déplacer `useApi` et les stores transverses vers `shared/` + +**Files:** +- Move: `frontend/composables/useApi.ts` → `frontend/shared/composables/useApi.ts` +- Move: `frontend/stores/auth.ts` → `frontend/shared/stores/auth.ts` +- Move: `frontend/stores/ui.ts` → `frontend/shared/stores/ui.ts` + +> `timer.ts` et `mail.ts` **restent** dans `frontend/stores/` (domaines métier non encore migrés en module). On ne déplace que les deux stores transverses (auth, ui) + `useApi`. La résolution effective (auto-import depuis `shared/`) est activée en Task 4 ; cette task fait les `git mv` et termine par un commit. Le typecheck de validation est en Task 4 (après config). + +- [ ] **Step 1: Déplacer les fichiers (git mv pour préserver l'historique)** + +```bash +cd /home/matthieu/dev_malio/Lesstime/frontend +mkdir -p shared/stores +git mv composables/useApi.ts shared/composables/useApi.ts +git mv stores/auth.ts shared/stores/auth.ts +git mv stores/ui.ts shared/stores/ui.ts +``` + +- [ ] **Step 2: Vérifier qu'aucun import par CHEMIN ne pointe vers les anciens emplacements** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "composables/useApi\|stores/auth\|stores/ui" --include=*.ts --include=*.vue . | grep -v node_modules | grep -v "shared/"` +Expected: aucun résultat (tout passe par auto-import). Si un import explicite existe (ex. `from '~/composables/useApi'`), le corriger en `from '~/shared/composables/useApi'` ou retirer l'import (auto-import). Noter chaque correction. + +> `layouts/default.vue` importe actuellement `useAppVersion` depuis `~/composables/useAppVersion` (NON déplacé) — ne pas y toucher ici. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(front) : move useApi and shared stores (auth, ui) to shared/" +``` + +--- + +### Task 4: Frontend — `nuxt.config.ts` (srcDir, dossiers `app/`, scan des layers, auto-imports) + +**Files:** +- Modify: `frontend/nuxt.config.ts` +- Create: `frontend/modules/.gitkeep` (dossier vide prêt pour le scan) +- Move: `frontend/layouts/` → `frontend/app/layouts/` (default.vue, auth.vue) +- Move: `frontend/middleware/` → `frontend/app/middleware/` (auth.global.ts, admin.ts, employee.ts) + +**Interfaces:** +- Produces : structure `app/{layouts,middleware}`, `modules/` scannable, `shared/*` auto-importé. + +- [ ] **Step 1: Déplacer layouts et middleware sous `app/`** + +```bash +cd /home/matthieu/dev_malio/Lesstime/frontend +mkdir -p app modules +git mv layouts app/layouts +git mv middleware app/middleware +touch modules/.gitkeep +git add modules/.gitkeep +``` + +- [ ] **Step 2: Réécrire `nuxt.config.ts`** + +Remplace INTÉGRALEMENT `frontend/nuxt.config.ts` par (conserve `vite`/`toast` existants — repris depuis la version actuelle) : + +```ts +import { existsSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const modulesDir = resolve(__dirname, 'modules') +const moduleDirs = existsSync(modulesDir) + ? readdirSync(modulesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + : [] +const moduleLayers = moduleDirs.map((name) => `./modules/${name}`) +const moduleComposableDirs = moduleDirs + .map((name) => `modules/${name}/composables`) + .filter((path) => existsSync(resolve(__dirname, path))) +const moduleStoreDirs = moduleDirs + .map((name) => `modules/${name}/stores`) + .filter((path) => existsSync(resolve(__dirname, path))) + +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + devtools: { enabled: false }, + ssr: false, + srcDir: '.', + css: ['~/assets/css/app.css', '~/assets/css/dark.css'], + app: { + baseURL: process.env.NODE_ENV === 'production' + ? (process.env.NUXT_PUBLIC_APP_BASE || '/') + : '/', + }, + extends: ['@malio/layer-ui', ...moduleLayers], + modules: [ + '@nuxtjs/tailwindcss', + '@pinia/nuxt', + 'nuxt-toast', + '@nuxtjs/i18n', + '@nuxt/icon', + ], + dir: { + layouts: 'app/layouts', + middleware: 'app/middleware', + }, + imports: { + dirs: [ + 'shared/composables', + 'shared/stores', + 'shared/utils', + 'composables', + 'stores', + 'utils', + ...moduleComposableDirs, + ...moduleStoreDirs, + ], + }, + pinia: { + storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'], + }, + runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE, + }, + }, + devServer: { + port: 3002, + }, + components: [ + { path: '~/components', pathPrefix: false }, + ], + // ⬇️ Reprendre VERBATIM les blocs `vite: {...}`, `toast: {...}`, `i18n: {...}`, + // `typescript: {...}`, `build: {...}` de l'ancien nuxt.config.ts (inchangés). + typescript: { strict: true }, + build: { transpile: ['@vuepic/vue-datepicker'] }, +}) +``` + +> ⚠️ Les blocs `vite`, `toast`, `i18n` de l'ancienne config ne sont pas réécrits ici : **les recopier à l'identique** depuis la version d'origine (récupérable via `git show HEAD~1:frontend/nuxt.config.ts` après les déplacements). Le `i18n.langDir: 'locales'` reste résolu depuis `i18n/`. + +- [ ] **Step 3: Typecheck complet (valide Tasks 2, 3 et 4)** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` +Expected: 0 erreur. Pièges probables : +- Store non trouvé → vérifier `pinia.storesDirs` inclut bien `shared/stores/**`. +- Composable non auto-importé → vérifier `imports.dirs` inclut `shared/composables`. +- `~/composables/useApi` cassé → un import explicite a survécu (corriger comme Task 3 Step 2). + +- [ ] **Step 4: Smoke test runtime — l'app boote et la nav existante fonctionne** + +Run: `cd /home/matthieu/dev_malio/Lesstime && make dev-nuxt` (ou rebuild SPA selon le workflow). Ouvrir l'app, se connecter (`alice`/`alice`), vérifier que la sidebar **statique actuelle** s'affiche encore et que la navigation marche (le layout n'est pas encore dynamisé — c'est normal). Aucun écran blanc / erreur console bloquante. +Expected: app fonctionnelle, identique à avant (les déplacements sont transparents). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection" +``` + +--- + +### Task 5: Frontend — middlewares (`auth.global.ts` étendu + `modules.global.ts`) + +**Files:** +- Modify: `frontend/app/middleware/auth.global.ts` (charge sidebar + modules après login ; reset au logout) +- Create: `frontend/app/middleware/modules.global.ts` (redirige les routes désactivées) + +**Interfaces:** +- Consumes : `useAuthStore()`, `useSidebar()`, `useModules()` (auto-importés). + +- [ ] **Step 1: Étendre `auth.global.ts`** + +Remplace INTÉGRALEMENT `frontend/app/middleware/auth.global.ts` par : + +```ts +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + return navigateTo('/') + } + + const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar() + const { loaded: modulesLoaded, loadModules, resetModules } = useModules() + + if (auth.isAuthenticated) { + await Promise.all([ + sidebarLoaded.value ? Promise.resolve() : loadSidebar(), + modulesLoaded.value ? Promise.resolve() : loadModules(), + ]) + } else { + // Logout / session expirée : purge l'état partagé pour le prochain login. + resetSidebar() + resetModules() + } +}) +``` + +- [ ] **Step 2: Créer `modules.global.ts`** + +`frontend/app/middleware/modules.global.ts` : + +```ts +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + if (!auth.isAuthenticated) { + return + } + + const { loaded, loadSidebar, isRouteDisabled } = useSidebar() + if (!loaded.value) { + await loadSidebar() + } + + if (isRouteDisabled(to.path)) { + return navigateTo('/') + } +}) +``` + +> Ordre des middlewares globaux : Nuxt les exécute par ordre alphabétique de nom de fichier → `auth.global.ts` puis `modules.global.ts`. C'est l'ordre voulu (auth charge la sidebar avant que modules teste les routes désactivées). + +- [ ] **Step 3: Typecheck** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` +Expected: 0 erreur. + +- [ ] **Step 4: Smoke test — chargement sidebar/modules + redirection** + +Avec le dev server : se connecter (`alice`), ouvrir l'onglet Réseau → confirmer un `GET /api/sidebar` et `GET /api/modules` après login. Vérifier la redirection : ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'` (module inactif) et un `'to' => '/demo-disabled'`, recharger, confirmer que `/demo-disabled` apparaît dans `disabledRoutes` (réponse `/api/sidebar`) et qu'y naviguer redirige vers `/`. **Puis retirer l'item de démo** (ne pas committer ce stub). +Expected: appels présents, redirection effective. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/middleware/auth.global.ts frontend/app/middleware/modules.global.ts +git commit -m "feat(front) : load sidebar/modules after login and redirect disabled routes" +``` + +--- + +### Task 6: Frontend — layout `default.vue` : sidebar dynamique + items conservés + +**Files:** +- Modify: `frontend/app/layouts/default.vue` + +**Interfaces:** +- Consumes : `useSidebar()` (sections dynamiques traduites), `useUiStore()`, `useAuthStore()`, `useI18n()`, + le reste de la logique existante (timer, mail, refData) conservée VERBATIM. + +> Stratégie : on remplace le bloc statique des items **globaux** (Tableau de bord, Mes tâches, Projets, Suivi de temps, Absences équipe, Administration) par un rendu **dynamique** issu de `useSidebar()`. On **conserve** les `SidebarLink` des items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail + badge) et user-flag (Mes absences) tels quels. Tout le `