44 KiB
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 AUCUNfrontend/modules/<x>/pages/peuplé en 0.2 (le dossiermodules/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 untypecheckaprè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 viadocker 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)
- Gate de rôle minimal côté back : les items/sections réservés (
/team-absences,/admin) portent une clérolesdansconfig/sidebar.php;SidebarProviderpasse les rôles de l'utilisateur courant àSidebarFilterqui masque ce qui n'est pas autorisé. Ce n'est pas le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER. - Items contextuels / feature-flag / user-flag hors
/api/sidebar: Kanban/Groupes/Archives (contextecurrentProjectId), Documents (shareEnabled), Mail (+ badge non lus), Mes absences (isEmployee) restent rendus par le layout comme aujourd'hui. - 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 =
typecheckNuxt (en LECTURE différentielle, cf. ci-dessous) + 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 +curldes endpoints via nginx (http://localhost:8082/api/...). Les containers sont up.
- Typecheck :
⚠️
nuxt typecheckn'est PAS un gate vert sur ce projet (constat 2026-06-19). Le baseline Lesstime est déjà rouge (~230 ligneserror TS), et le projet de référence Starseed (même Nuxt 4.3.1, même layoutshared/+srcDir: '.') ship en prod avec 325 erreurserror TS. Ces erreurs sont des classes structurelles attendues, pas des régressions :
- dans
shared/composables/*etshared/stores/*:Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'/'useToast'/'useNuxtApp'…— Nuxt 4 type le dossiershared/sous untsconfig.shared.jsonisolé sans les globals d'auto-import, alors queimports.dirsles rend bien disponibles au RUNTIME (vérifié dans.nuxt/imports.d.ts). Starseed a exactement ces 15 erreurs et fonctionne.- dans
nuxt.config.ts:node:fs/node:path/__dirname/process(pas de@types/node— comme Starseed) ; ce fichier est compilé par Nuxt au runtime, pas partsc.- dans
useApi.ts:Property 'url' does not exist…(préexistant, code forké de Starseed).Le vrai gate front = (1) ZÉRO erreur
Cannot find module '~/shared/…'/ chemin cassé (sinon un import a vraiment été cassé par un déplacement) ; (2) les auto-imports attendus présents dans.nuxt/imports.d.ts; (3) smoke runtime sur le dev server. Ne JAMAIS s'arrêter sur les classes d'erreurs structurelles ci-dessus — elles sont identiques à la référence Starseed.
Task 1: Backend — gate de rôle dans la sidebar (roles) + config complète
Files:
- Modify:
src/Shared/Domain/Sidebar/SidebarFilter.php(signature + gateroles) - Modify:
src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php(injecterSecurity, passer les rôles) - Modify:
config/sidebar.php(navigation globale + section Administration gated ROLE_ADMIN ; retrait de/absencesqui reste client-side) - Modify:
tests/Unit/Shared/Sidebar/SidebarFilterTest.php(adapter à la nouvelle signature + casroles) - 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$activeRolescontient au moins un des rôles listés ; sinon la section/l'item est retiré (lestodes items retirés par rôle ne sont PAS ajoutés àdisabledRoutes—disabledRoutesreste réservé au filtrage par module, qui pilote la redirection front). Les clés internesmoduleetrolessont retirées de la sortie. -
Consumes :
Symfony\Bundle\SecurityBundle\Security(rôles viagetUser()). -
Step 1: Adapter le test unitaire existant + ajouter les cas
roles
Remplace INTÉGRALEMENT tests/Unit/Shared/Sidebar/SidebarFilterTest.php par :
<?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
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
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
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
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
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 dansimports.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 :
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) :
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 dansApiFetchOptions; si la clé diffère (ex.toastSuccessKey/toast), aligner sur la signature réelle deuseApi.ts. Si aucune option « silencieux » n'existe, passer{}.
- Step 3: Créer
useSidebar
frontend/shared/composables/useSidebar.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
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.tsetmail.tsrestent dansfrontend/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 depuisshared/) est activée en Task 4 ; cette task fait lesgit mvet 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)
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.vueimporte actuellementuseAppVersiondepuis~/composables/useAppVersion(NON déplacé) — ne pas y toucher ici.
- Step 3: Commit
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/
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) :
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,i18nde l'ancienne config ne sont pas réécrits ici : les recopier à l'identique depuis la version d'origine (récupérable viagit show HEAD~1:frontend/nuxt.config.tsaprès les déplacements). Lei18n.langDir: 'locales'reste résolu depuisi18n/.
- 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.storesDirsinclut bienshared/stores/**. -
Composable non auto-importé → vérifier
imports.dirsinclutshared/composables. -
~/composables/useApicassé → 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
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 :
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 :
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.tspuismodules.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
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 lesSidebarLinkdes 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>defrontend/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 :
<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) :
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
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 namespacesidebar)
Interfaces:
-
Consumes : les labels renvoyés par
/api/sidebar(sidebar.general.*,sidebar.admin.*) traduits part()danstranslatedSections. -
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) :
"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) :
- Ajouter TEMPORAIREMENT dans
config/sidebar.phpun item avec'module' => 'demo','to' => '/projects'(route existante) dans une section visible. config/modules.phpreste vide (moduledemoinactif) →GET /api/sidebardoit lister/projectsdansdisabledRouteset masquer l'item ; naviguer vers/projectsdoit rediriger vers/.- Ajouter une classe
DemoModule implements ModuleInterface { id()='demo' … }+config/modules.php=[DemoModule::class]→ l'item réapparaît,/projectsn'est plus dansdisabledRoutes, la navigation fonctionne. - 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.
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.tsscannemodules/*/.- Sidebar dynamique alimentée par
/api/sidebarpour la nav globale ; gate ROLE_ADMIN effectif (admin-only invisible pouralice). - 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).