feat(sidebar) : add role gate to sidebar provider and global nav config

This commit is contained in:
Matthieu
2026-06-19 15:03:45 +02:00
parent 111f37a0c9
commit 0ee82c8b62
5 changed files with 137 additions and 20 deletions
+14 -9
View File
@@ -3,8 +3,12 @@
declare(strict_types=1); declare(strict_types=1);
/* /*
* Définition de la sidebar (sections + items). Filtrée par SidebarFilter selon les modules actifs. * Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Un item porte une clé `module` quand il appartient à un module activable ; sans clé, il est toujours visible. * 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>). * Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/ */
return [ return [
@@ -13,17 +17,18 @@ return [
'icon' => 'mdi:view-dashboard-outline', 'icon' => 'mdi:view-dashboard-outline',
'items' => [ 'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], ['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.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-multiple-outline'], ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:clock-outline'], ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
], ],
], ],
[ [
'label' => 'sidebar.hr.section', 'label' => 'sidebar.admin.section',
'icon' => 'mdi:calendar-account-outline', 'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'],
'items' => [ 'items' => [
['label' => 'sidebar.hr.absences', 'to' => '/absences', 'icon' => 'mdi:calendar-remove-outline'], ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group-outline'], ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
], ],
], ],
]; ];
+34 -3
View File
@@ -7,19 +7,31 @@ namespace App\Shared\Domain\Sidebar;
final class SidebarFilter final class SidebarFilter
{ {
/** /**
* @param list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sections * @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> $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>} * @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 public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array
{ {
$outSections = []; $outSections = [];
$disabledRoutes = []; $disabledRoutes = [];
foreach ($sections as $section) { 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 = []; $items = [];
foreach ($section['items'] as $item) { 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; $module = $item['module'] ?? null;
if (null !== $module && !in_array($module, $activeModuleIds, true)) { if (null !== $module && !in_array($module, $activeModuleIds, true)) {
$disabledRoutes[] = $item['to']; $disabledRoutes[] = $item['to'];
@@ -37,4 +49,23 @@ final class SidebarFilter
return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes]; return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
} }
/**
* @param null|list<string> $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;
}
} }
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry; use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter; use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource; use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class SidebarProvider implements ProviderInterface final readonly class SidebarProvider implements ProviderInterface
@@ -16,6 +17,7 @@ final readonly class SidebarProvider implements ProviderInterface
public function __construct( public function __construct(
#[Autowire('%kernel.project_dir%')] #[Autowire('%kernel.project_dir%')]
private string $projectDir, private string $projectDir,
private Security $security,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
@@ -23,10 +25,13 @@ final readonly class SidebarProvider implements ProviderInterface
/** @var list<class-string> $moduleClasses */ /** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php'; $moduleClasses = require $this->projectDir.'/config/modules.php';
/** @var list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sidebar */ /** @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'; $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 = new SidebarResource();
$dto->sections = $filtered['sections']; $dto->sections = $filtered['sections'];
@@ -22,9 +22,8 @@ final class SidebarEndpointTest extends WebTestCase
public function testSidebarReturnsSectionsForAuthenticatedUser(): void public function testSidebarReturnsSectionsForAuthenticatedUser(): void
{ {
$client = self::createClient(); $client = self::createClient();
$container = self::getContainer(); $em = self::getContainer()->get('doctrine.orm.entity_manager');
$em = $container->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user); $client->loginUser($user);
@@ -37,4 +36,34 @@ final class SidebarEndpointTest extends WebTestCase
self::assertArrayHasKey('disabledRoutes', $data); self::assertArrayHasKey('disabledRoutes', $data);
self::assertNotEmpty($data['sections']); 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);
}
} }
@@ -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::assertCount(1, $result['sections']);
self::assertSame('/', $result['sections'][0]['items'][0]['to']); 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([], $result['sections']);
self::assertSame(['/time-tracking'], $result['disabledRoutes']); 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::assertCount(1, $result['sections']);
self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']); self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']);
self::assertSame([], $result['disabledRoutes']); 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]);
}
} }