docs : add implementation plan for socle front (LST-62 / 0.2)

This commit is contained in:
Matthieu
2026-06-19 15:00:23 +02:00
parent 5fbdda1983
commit 111f37a0c9
@@ -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/<x>/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 `<type>(<scope>) : <message>` (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
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Sidebar;
use App\Shared\Domain\Sidebar\SidebarFilter;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SidebarFilterTest extends TestCase
{
public function testItemWithoutModuleIsAlwaysVisible(): void
{
$sections = [
['label' => '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
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
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<string>|null $required
* @param list<string> $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
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class SidebarProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
{
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
/** @var list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $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
<?php
declare(strict_types=1);
/*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
* le RBAC fin par permission arrive en #1.2).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
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
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Shared;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class SidebarEndpointTest extends WebTestCase
{
public function testSidebarRequiresAuthentication(): void
{
$client = self::createClient();
$client->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<string[]>, loaded: Ref<boolean>, loadModules(): Promise<void>, isModuleActive(id: string): boolean, resetModules(): void }`
- `useSidebar(): { sections: Ref<SidebarSection[]>, disabledRoutes: Ref<string[]>, loaded: Ref<boolean>, loadSidebar(): Promise<void>, 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<string[]>([])
const loaded = ref(false)
export function useModules() {
async function loadModules(): Promise<void> {
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<T>(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<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)
export function useSidebar() {
async function loadSidebar(): Promise<void> {
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 `<script setup>` non lié à la sidebar (timer, drawer, head, mail polling, refData) est conservé à l'identique.
- [ ] **Step 1: Réécrire le bloc `<nav>` et l'en-tête du `<script setup>` de `frontend/app/layouts/default.vue`**
Dans le `<template>`, remplace le contenu de `<nav class="flex-1 overflow-hidden" …>…</nav>` (lignes ~40-167 de l'original) par :
```vue
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
{{ section.label }}
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink
v-for="item in section.items"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
<template v-if="sIndex === 0">
<!-- Contextuel projet -->
<template v-if="currentProjectId">
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
</template>
<!-- Feature-flag : Documents -->
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<!-- Feature-flag : Mail + badge -->
<div v-if="isMailVisible" class="relative">
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<span
v-if="mailStore.globalUnreadCount > 0"
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
>
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
</span>
</div>
<!-- User-flag : Mes absences (isEmployee non couvert par le gate rôle) -->
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
</template>
</template>
</nav>
```
Dans le `<script setup lang="ts">`, **ajoute** en tête (après les `useXxxStore()` existants) :
```ts
const { t } = useI18n()
const { sections } = useSidebar()
const translatedSections = computed(() =>
sections.value.map((section) => ({
label: t(section.label),
icon: section.icon,
items: section.items.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
})),
)
```
**Conserve** tout le reste du `<script setup>` (`isAdmin`, `isEmployee`, `isMailVisible`, `isDocumentsVisible`, `currentProjectId`, `sidebarIsCollapsed`, timer/drawer/head/mail/refData…) et le `<style scoped>` à l'identique. `isAdmin`/`isAbsenceSectionVisible` deviennent inutilisés pour la sidebar (l'admin est gated côté serveur) — si le typecheck signale une variable inutilisée, retirer `isAbsenceSectionVisible` ; garder `isAdmin` s'il sert ailleurs, sinon le retirer aussi.
- [ ] **Step 2: Typecheck**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Expected: 0 erreur (corriger toute variable / tout import inutilisé signalé).
- [ ] **Step 3: Smoke test visuel — non-régression de navigation**
Dev server. Se connecter successivement :
- `alice` (ROLE_USER) : sidebar affiche Tableau de bord / Mes tâches / Projets / Suivi de temps (dynamiques), + Documents/Mail si visibles, + Mes absences si employé ; **PAS** de section Administration ni Absences équipe.
- `admin` (ROLE_ADMIN) : en plus, section **Administration** avec Absences équipe + Administration.
- Entrer dans un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent (contextuel conservé).
Expected: tous les liens d'avant atteignables ; gating admin respecté. Noter tout délta visuel (ordre) pour validation PO (cf. décision 3).
- [ ] **Step 4: Commit**
```bash
git add frontend/app/layouts/default.vue
git commit -m "feat(front) : render dynamic sidebar from /api/sidebar in default layout"
```
---
### Task 7: Frontend — clés i18n `sidebar.*` + vérification bout-en-bout
**Files:**
- Modify: `frontend/i18n/locales/fr.json` (ajouter le namespace `sidebar`)
**Interfaces:**
- Consumes : les labels renvoyés par `/api/sidebar` (`sidebar.general.*`, `sidebar.admin.*`) traduits par `t()` dans `translatedSections`.
- [ ] **Step 1: Repérer la structure du fichier i18n**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && head -20 i18n/locales/fr.json`
Objectif : connaître l'indentation et confirmer que c'est un objet JSON imbriqué (ajouter une clé racine `sidebar`).
- [ ] **Step 2: Ajouter le namespace `sidebar`**
Ajoute (à la racine de l'objet JSON, en respectant l'indentation existante) :
```json
"sidebar": {
"general": {
"section": "Gestion de projet",
"dashboard": "Tableau de bord",
"myTasks": "Mes tâches",
"projects": "Projets",
"timeTracking": "Suivi de temps"
},
"admin": {
"section": "Administration",
"teamAbsences": "Absences équipe",
"administration": "Administration"
}
}
```
> Les libellés reprennent ceux du layout actuel. `sidebar.general.section` = « Gestion de projet » (regroupe désormais le Tableau de bord — délta cosmétique acté, décision 3).
- [ ] **Step 3: Typecheck + smoke i18n**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
Dev server : confirmer que les en-têtes/labels de sidebar s'affichent **traduits** (pas les clés brutes `sidebar.general.*`).
Expected: libellés FR corrects.
- [ ] **Step 4: Vérification bout-en-bout de l'activation/désactivation (AC)**
Test manuel documenté (aucun module réel en 0.2) :
1. Ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'`, `'to' => '/projects'` (route existante) dans une section visible.
2. `config/modules.php` reste vide (module `demo` inactif) → `GET /api/sidebar` doit lister `/projects` dans `disabledRoutes` et masquer l'item ; naviguer vers `/projects` doit rediriger vers `/`.
3. Ajouter une classe `DemoModule implements ModuleInterface { id()='demo' … }` + `config/modules.php` = `[DemoModule::class]` → l'item réapparaît, `/projects` n'est plus dans `disabledRoutes`, la navigation fonctionne.
4. **Tout retirer** (item démo + DemoModule + entrée modules.php). Confirmer l'état initial.
Documenter le résultat dans le message de fin. **Ne rien committer de ce stub.**
- [ ] **Step 5: Suite back + cs-fixer (non-régression globale) + commit**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: vert (inchangé vs Task 1).
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` → 0 erreur.
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(front) : add sidebar i18n labels"
```
---
## Acceptance check (après toutes les tasks)
- [ ] `frontend/app/{layouts,middleware}`, `frontend/shared/{composables,stores,types}`, `frontend/modules/` (vide) en place ; `nuxt.config.ts` scanne `modules/*/`.
- [ ] Sidebar **dynamique** alimentée par `/api/sidebar` pour la nav globale ; gate ROLE_ADMIN effectif (admin-only invisible pour `alice`).
- [ ] Route d'un module désactivé → **redirigée** vers `/` (vérifié via le stub démo).
- [ ] **Aucune page métier déplacée** ; `frontend/pages/` intact ; tous les liens actuels atteignables.
- [ ] `npx nuxt typecheck` = 0 erreur ; suite PHPUnit verte ; aucune migration BDD.
- [ ] Délta cosmétique d'ordre de sidebar présenté au PO pour validation.
## Notes pour le ticket suivant (1.1 — Module Core)
Le 1.1 migrera `User`/Auth dans `src/Module/Core/`, re-pointera `resolve_target_entities` vers `Module\Core\User`, déclarera `CoreModule` (REQUIRED) dans `config/modules.php`, et créera le premier vrai layer front `frontend/modules/core/` (login, profile, admin users) — c'est là que le scan de layers et `useModules`/`useSidebar` prennent tout leur sens (premier item de sidebar avec une clé `module` réelle).