Files
Lesstime/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md
T

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 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 (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 + curl des endpoints via nginx (http://localhost:8082/api/...). Les containers sont up.

⚠️ nuxt typecheck n'est PAS un gate vert sur ce projet (constat 2026-06-19). Le baseline Lesstime est déjà rouge (~230 lignes error TS), et le projet de référence Starseed (même Nuxt 4.3.1, même layout shared/ + srcDir: '.') ship en prod avec 325 erreurs error TS. Ces erreurs sont des classes structurelles attendues, pas des régressions :

  • dans shared/composables/* et shared/stores/* : Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'/'useToast'/'useNuxtApp'… — Nuxt 4 type le dossier shared/ sous un tsconfig.shared.json isolé sans les globals d'auto-import, alors que imports.dirs les 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 par tsc.
  • 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 + 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 à disabledRoutesdisabledRoutes 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

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 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 :

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 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 :

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.tsfrontend/shared/composables/useApi.ts
  • Move: frontend/stores/auth.tsfrontend/shared/stores/auth.ts
  • Move: frontend/stores/ui.tsfrontend/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)
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
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, 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
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.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
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 :

                <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 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) :

  "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.

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).