diff --git a/config/sidebar.php b/config/sidebar.php index 15bd7a1..79e4ee6 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -3,8 +3,12 @@ declare(strict_types=1); /* - * Définition de la sidebar (sections + items). Filtrée par SidebarFilter selon les modules actifs. - * Un item porte une clé `module` quand il appartient à un module activable ; sans clé, il est toujours visible. + * 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..). */ return [ @@ -13,17 +17,18 @@ return [ '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:checkbox-marked-circle-outline'], - ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-multiple-outline'], - ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:clock-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.hr.section', - 'icon' => 'mdi:calendar-account-outline', + 'label' => 'sidebar.admin.section', + 'icon' => 'mdi:cog-outline', + 'roles' => ['ROLE_ADMIN'], 'items' => [ - ['label' => 'sidebar.hr.absences', 'to' => '/absences', 'icon' => 'mdi:calendar-remove-outline'], - ['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group-outline'], + ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], + ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'], ], ], ]; diff --git a/src/Shared/Domain/Sidebar/SidebarFilter.php b/src/Shared/Domain/Sidebar/SidebarFilter.php index e97ca8d..e740afa 100644 --- a/src/Shared/Domain/Sidebar/SidebarFilter.php +++ b/src/Shared/Domain/Sidebar/SidebarFilter.php @@ -7,19 +7,31 @@ namespace App\Shared\Domain\Sidebar; final class SidebarFilter { /** - * @param list}> $sections - * @param list $activeModuleIds + * @param list, items: list}>}> $sections + * @param list $activeModuleIds + * @param list $activeRoles * * @return array{sections: list}>, disabledRoutes: list} */ - public static function filter(array $sections, array $activeModuleIds): array + 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']; @@ -37,4 +49,23 @@ final class SidebarFilter return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes]; } + + /** + * @param null|list $required + * @param list $activeRoles + */ + private static function rolesSatisfied(?array $required, array $activeRoles): bool + { + if (null === $required || [] === $required) { + return true; + } + + foreach ($required as $role) { + if (in_array($role, $activeRoles, true)) { + return true; + } + } + + return false; + } } diff --git a/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php b/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php index 84333d5..ddcf48c 100644 --- a/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php +++ b/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php @@ -9,6 +9,7 @@ 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 @@ -16,6 +17,7 @@ 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 @@ -23,10 +25,13 @@ final readonly class SidebarProvider implements ProviderInterface /** @var list $moduleClasses */ $moduleClasses = require $this->projectDir.'/config/modules.php'; - /** @var list}> $sidebar */ + /** @var list, items: list}>}> $sidebar */ $sidebar = require $this->projectDir.'/config/sidebar.php'; - $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses)); + $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']; diff --git a/tests/Functional/Shared/SidebarEndpointTest.php b/tests/Functional/Shared/SidebarEndpointTest.php index 55f0628..9d6058e 100644 --- a/tests/Functional/Shared/SidebarEndpointTest.php +++ b/tests/Functional/Shared/SidebarEndpointTest.php @@ -22,9 +22,8 @@ final class SidebarEndpointTest extends WebTestCase public function testSidebarReturnsSectionsForAuthenticatedUser(): void { - $client = self::createClient(); - $container = self::getContainer(); - $em = $container->get('doctrine.orm.entity_manager'); + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $client->loginUser($user); @@ -37,4 +36,34 @@ final class SidebarEndpointTest extends WebTestCase 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); + } } diff --git a/tests/Unit/Shared/Sidebar/SidebarFilterTest.php b/tests/Unit/Shared/Sidebar/SidebarFilterTest.php index 22dbf74..ac9315c 100644 --- a/tests/Unit/Shared/Sidebar/SidebarFilterTest.php +++ b/tests/Unit/Shared/Sidebar/SidebarFilterTest.php @@ -20,7 +20,7 @@ final class SidebarFilterTest extends TestCase ]], ]; - $result = SidebarFilter::filter($sections, []); + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); self::assertCount(1, $result['sections']); self::assertSame('/', $result['sections'][0]['items'][0]['to']); @@ -36,7 +36,7 @@ final class SidebarFilterTest extends TestCase ]], ]; - $result = SidebarFilter::filter($sections, []); + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); self::assertSame([], $result['sections']); self::assertSame(['/time-tracking'], $result['disabledRoutes']); @@ -50,10 +50,57 @@ final class SidebarFilterTest extends TestCase ]], ]; - $result = SidebarFilter::filter($sections, ['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]); + } }