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);
|
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'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user