wip: administrative roles

This commit is contained in:
Björn Fromme committed 2026-07-17 14:27:13 +02:00
1 parent d0358e584c
commit 8f2653c706
38 files changed
+307 -3

No files matched your search

-2
View File
@@ -18,7 +18,6 @@ security:
role_hierarchy:
ROLE_ADMIN: [ ROLE_GROUPS_ADMIN ]
ROLE_GROUPS_ADMIN: [ ROLE_GROUPS_MANAGER ]
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -53,7 +52,6 @@ security:
access_control:
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
when@test:
+8
View File
@@ -121,6 +121,14 @@ services:
method: createMainMenu
alias: admin_main
App\Menu\GroupsMenuBuilder:
arguments:
$factory: '@knp_menu.factory'
tags:
- name: knp_menu.menu_builder
method: createMainMenu
alias: groups_main
App\Service\BpnXmlSyncManager:
arguments:
$xmlSource: '@xml_source.storage'
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260717103431 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE accommodation_booking ADD managed_by_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE accommodation_booking ADD CONSTRAINT FK_AC389924873649CA FOREIGN KEY (managed_by_id) REFERENCES user (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_AC389924873649CA ON accommodation_booking (managed_by_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE accommodation_booking DROP FOREIGN KEY FK_AC389924873649CA');
$this->addSql('DROP INDEX IDX_AC389924873649CA ON accommodation_booking');
$this->addSql('ALTER TABLE accommodation_booking DROP managed_by_id');
}
}
@@ -16,6 +16,8 @@ class CrmAttributesResponseParser
private const BPN_CRM_ID_ADMIN = 1292;
private const BPN_CRM_ID_MANAGER = 1293;
private const BPN_CRM_ID_TEAMER = 1070;
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
private const BPN_DEFAULT_HOTEL_CODE = 'SSL';
public function parse(Crawler $result): CrmAttributes
@@ -57,6 +59,12 @@ class CrmAttributesResponseParser
if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_TEAMER';
}
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_GROUPS_MANAGER';
}
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_GROUPS_ADMIN';
}
$attributes[] = $attribute;
})
@@ -9,7 +9,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CalendarController extends AbstractController
{
#[Route('/admin/accommodation/{id}/calendar', name: 'app_admin_accommodation_calendar', methods: ['GET'])]
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
@@ -8,7 +8,9 @@ use App\Repository\Groups\AccommodationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class IndexController extends AbstractController
{
public function __construct(
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class CreateController extends AbstractController
{
public function __construct(
@@ -17,7 +17,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class EditController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class GenerateAccessLinkController extends AbstractController
{
public function __construct(
@@ -10,7 +10,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class IndexController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class SendAccessLinkController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class ShowController extends AbstractController
{
use ReturnUrlTrait;
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
@@ -15,7 +15,9 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
@@ -12,7 +12,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
@@ -13,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
use ReturnUrlTrait;
@@ -10,7 +10,9 @@ use App\Service\BookingExporter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class ExportController extends AbstractController
{
use ReturnUrlTrait;
@@ -11,7 +11,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class IndexController extends AbstractController
{
use ReturnUrlTrait;
@@ -10,7 +10,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class ShowController extends AbstractController
{
use ReturnUrlTrait;
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Controller\Admin;
use App\Security\Voter\AdministrativeAccessVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class DashboardController extends AbstractController
{
#[Route('/admin/dashboard', name: 'app_admin_dashboard')]
@@ -10,7 +10,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class DownloadController extends AbstractController
{
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
@@ -10,7 +10,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
public function __construct(
@@ -10,7 +10,9 @@ use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class XmlDumpController extends AbstractController
{
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
@@ -10,7 +10,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
public function __construct(
@@ -8,6 +8,7 @@ use App\Entity\BlameableEntity;
use App\Entity\BlameableEntityInterface;
use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface;
use App\Entity\User;
use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types;
@@ -125,6 +126,10 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[Assert\Range(min: 1, max: 100)]
private ?int $additionalServicesDiscount = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?User $managedBy = null;
public function __construct()
{
$this->uuid = Uuid::v4()->toRfc4122();
@@ -516,4 +521,16 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this;
}
public function getManagedBy(): ?User
{
return $this->managedBy;
}
public function setManagedBy(?User $managedBy): self
{
$this->managedBy = $managedBy;
return $this;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Menu;
use Knp\Menu\ItemInterface;
class GroupsMenuBuilder extends AbstractMenuBuilder
{
/**
* @param array<string, mixed> $options
*/
public function createMainMenu(array $options): ItemInterface
{
$menu = $this->createRootElement();
$menu->addChild('Dashboard', [
'route' => 'app_admin_dashboard',
'linkAttributes' => [
'title' => 'Dashboard',
],
'extras' => [
'icon' => 'chart',
],
]);
$menu->addChild('Gruppenbuchungen', [
'route' => 'app_admin_accommodationbooking',
'linkAttributes' => [
'title' => 'Buchungen',
],
'extras' => [
'icon' => 'list',
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
],
]);
$menu->addChild('Gruppenhäuser', [
'route' => 'app_admin_accommodation',
'linkAttributes' => [
'title' => 'Gruppenhäuser',
],
'extras' => [
'icon' => 'house',
'routes' => [['pattern' => '/^app_admin_accommodation\//']],
],
]);
$this->addLogoutItem($menu);
return $menu;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<string, mixed>
*/
class AdministrativeAccessVoter extends Voter
{
public const ADMINISTRATIVE_ACCESS = 'ADMINISTRATIVE_ACCESS';
public function __construct(
private readonly AuthorizationCheckerInterface $authorizationChecker,
) {
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::ADMINISTRATIVE_ACCESS === $attribute;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
return $this->authorizationChecker->isGranted('ROLE_ADMIN')
|| $this->authorizationChecker->isGranted('ROLE_GROUPS_MANAGER');
}
}
+5 -1
View File
@@ -37,7 +37,11 @@
</button>
</div>
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
{{ knp_menu_render(knp_menu_get('admin_main')) }}
{% if is_granted('ROLE_ADMIN') %}
{{ knp_menu_render(knp_menu_get('admin_main')) }}
{% elseif is_granted('ROLE_GROUPS_MANAGER') %}
{{ knp_menu_render(knp_menu_get('groups_main')) }}
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\XmlParser\CrmAttributesResponseParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;
class CrmAttributesResponseParserTest extends TestCase
{
private CrmAttributesResponseParser $parser;
protected function setUp(): void
{
$this->parser = new CrmAttributesResponseParser();
}
public function testParseAssignsGroupsManagerRoleWhenSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1477, true));
self::assertContains('ROLE_GROUPS_MANAGER', $roles);
self::assertNotContains('ROLE_GROUPS_ADMIN', $roles);
}
public function testParseAssignsGroupsAdminRoleWhenSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1478, true));
self::assertContains('ROLE_GROUPS_ADMIN', $roles);
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
}
public function testParseAssignsNoGroupsRolesWhenNotSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1477, false));
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
self::assertNotContains('ROLE_GROUPS_ADMIN', $roles);
self::assertSame(['ROLE_CUSTOMER'], $roles);
}
public function testParseStillAssignsExistingAdminManagerTeamerRoles(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENDATEN" />
<selektionsmerkmale>
<selektionsgruppe id="1" bezeichnung="Rollen">
<selektion id="1292" bezeichnung="Admin" aenderbar="0" auswahl="True" />
<selektion id="1293" bezeichnung="Manager" aenderbar="0" auswahl="True" />
<selektion id="1070" bezeichnung="Teamer" aenderbar="0" auswahl="True" />
</selektionsgruppe>
</selektionsmerkmale>
<crmaktionen></crmaktionen>
</ergebnis>';
$roles = $this->parseRoles($xmlContent);
self::assertContains('ROLE_ADMIN', $roles);
self::assertContains('ROLE_MANAGER', $roles);
self::assertContains('ROLE_TEAMER', $roles);
self::assertContains('ROLE_HOUSE_MANAGER', $roles);
}
/**
* @return string[]
*/
private function parseRoles(string $xmlContent): array
{
$crawler = new Crawler($xmlContent);
$resultNode = $crawler->filterXPath('//ergebnis');
return $this->parser->parse($resultNode)->roles;
}
private function selectionXml(int $id, bool $selected): string
{
return sprintf('<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENDATEN" />
<selektionsmerkmale>
<selektionsgruppe id="1" bezeichnung="Rollen">
<selektion id="%d" bezeichnung="Gruppenreisen" aenderbar="0" auswahl="%s" />
</selektionsgruppe>
</selektionsmerkmale>
<crmaktionen></crmaktionen>
</ergebnis>', $id, $selected ? 'True' : 'False');
}
}