feat(sidebar) : add role gate to sidebar provider and global nav config
This commit is contained in:
+14
-9
@@ -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.<domaine>.<item>).
|
||||
*/
|
||||
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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -7,19 +7,31 @@ namespace App\Shared\Domain\Sidebar;
|
||||
final class SidebarFilter
|
||||
{
|
||||
/**
|
||||
* @param list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string, module?:string}>}> $sections
|
||||
* @param list<string> $activeModuleIds
|
||||
* @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
|
||||
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<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\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<class-string> $moduleClasses */
|
||||
$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';
|
||||
|
||||
$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'];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user