fix: properly gate menus by role

This commit is contained in:
Björn Fromme
2026-08-11 14:20:15 +02:00
parent d654ce77fb
commit 102e1c1c3c
17 changed files with 298 additions and 58 deletions
+102
View File
@@ -13,6 +13,30 @@ use Symfony\Contracts\Translation\TranslatorInterface;
abstract class AbstractMenuBuilder
{
/**
* Routes of the areas that have pages of their own, mapped to the role owning the area.
*
* Note that "app_admin_" does not match the shared "app_administrative_" routes.
*/
protected const AREA_ROUTE_PREFIXES = [
'app_admin_' => 'ROLE_ADMIN',
'app_manager_' => 'ROLE_MANAGER',
'app_house_manager_' => 'ROLE_HOUSE_MANAGER',
'app_teamer_' => 'ROLE_TEAMER',
];
/**
* Priority order of the area roles, must stay in sync with User::getDefaultRoute().
*/
protected const ROLE_PRIORITY = [
'ROLE_ADMIN',
'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER',
'ROLE_TEAMER',
];
protected const AREA_SESSION_KEY = 'menu_area';
public function __construct(
protected FactoryInterface $factory,
protected Security $security,
@@ -26,6 +50,49 @@ abstract class AbstractMenuBuilder
return $this->factory->createItem('root');
}
/**
* Returns the role owning the area the current request belongs to.
*
* Entering an area that has pages of its own selects that area and keeps it
* for the rest of the session. The routes shared by all administrative roles
* (app_administrative_*) carry no area of their own and therefore stay in the
* area the user entered last, so that switching areas sticks. Without a
* remembered area the highest role wins.
*/
protected function resolveArea(): ?string
{
$request = $this->requestStack->getMainRequest();
$session = null !== $this->security->getUser() && true === $request?->hasSession()
? $request->getSession()
: null;
$route = $request?->attributes->get('_route');
if (is_string($route)) {
foreach (self::AREA_ROUTE_PREFIXES as $prefix => $role) {
if (str_starts_with($route, $prefix) && $this->security->isGranted($role)) {
$session?->set(self::AREA_SESSION_KEY, $role);
return $role;
}
}
}
$rememberedArea = $session?->get(self::AREA_SESSION_KEY);
// The remembered area is re-checked, roles can be revoked mid-session.
if (is_string($rememberedArea) && $this->security->isGranted($rememberedArea)) {
return $rememberedArea;
}
foreach (self::ROLE_PRIORITY as $role) {
if ($this->security->isGranted($role)) {
return $role;
}
}
return null;
}
protected function getDefaultRouteParameters(string $parameter = 'id', string $default = '0'): array
{
$request = $this->requestStack->getMainRequest();
@@ -36,6 +103,41 @@ abstract class AbstractMenuBuilder
];
}
/**
* Adds the link leading back out of a detail view.
*
* The return url is taken from the menu options first, then from the request,
* and falls back to the overview itself. Without that fallback the item ends up
* without an uri and is rendered as a dead label instead of a link.
*
* @param array<string, mixed> $options
*/
protected function addBackItem(
ItemInterface $menu,
array $options,
string $defaultRoute = 'app_administrative_teamer_index',
): void {
$this->addDivider($menu);
// Already decoded by ReturnUrlTrait::getReturnUrl(), must not be decoded again.
$returnUrl = $options['return_url'] ?? null;
if (false === is_string($returnUrl) || '' === $returnUrl) {
$requestReturnUrl = $this->requestStack->getMainRequest()?->get('r');
$returnUrl = is_string($requestReturnUrl) && '' !== $requestReturnUrl
? rawurldecode($requestReturnUrl)
: null;
}
$menu->addChild('zurück zur Übersicht', [
...(null !== $returnUrl ? ['uri' => $returnUrl] : ['route' => $defaultRoute]),
'extras' => [
'icon' => 'arrow-left',
],
]);
}
protected function addAdminItem(ItemInterface $menu): void
{
if ($this->security->isGranted('ROLE_ADMIN')) {
+1 -10
View File
@@ -337,16 +337,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
]);
$this->addDivider($menu);
$request = $this->requestStack->getMainRequest();
$menu->addChild('zurück zur Übersicht', [
'uri' => rawurldecode($request->get('r')),
'extras' => [
'icon' => 'arrow-left',
],
]);
$this->addBackItem($menu, $options);
return $menu;
}
+1 -10
View File
@@ -204,16 +204,7 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
],
]);
$this->addDivider($menu);
$request = $this->requestStack->getMainRequest();
$menu->addChild('zurück zur Übersicht', [
'uri' => rawurldecode($request->get('r')),
'extras' => [
'icon' => 'arrow-left',
],
]);
$this->addBackItem($menu, $options);
return $menu;
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Menu;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Serves the menu of the area the current user has the highest privileges for.
*
* Used by templates that are reachable by more than one role, so that the area
* selection follows a single priority order instead of being decided in Twig.
*/
class MenuBuilder extends AbstractMenuBuilder
{
public function __construct(
FactoryInterface $factory,
Security $security,
RequestStack $requestStack,
TranslatorInterface $translator,
private readonly AdminMenuBuilder $adminMenuBuilder,
private readonly ManagerMenuBuilder $managerMenuBuilder,
private readonly HouseManagerMenuBuilder $houseManagerMenuBuilder,
private readonly TeamerMenuBuilder $teamerMenuBuilder,
) {
parent::__construct($factory, $security, $requestStack, $translator);
}
public function createMainMenu(array $options): ItemInterface
{
return match ($this->resolveArea()) {
'ROLE_ADMIN' => $this->adminMenuBuilder->createMainMenu($options),
'ROLE_MANAGER' => $this->managerMenuBuilder->createMainMenu($options),
'ROLE_HOUSE_MANAGER' => $this->houseManagerMenuBuilder->createMainMenu($options),
'ROLE_TEAMER' => $this->teamerMenuBuilder->createMainMenu($options),
default => $this->createRootElement(),
};
}
public function createTeamerMenu(array $options): ItemInterface
{
return match ($this->resolveArea()) {
'ROLE_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options),
'ROLE_MANAGER' => $this->managerMenuBuilder->createTeamerMenu($options),
default => $this->createRootElement(),
};
}
}