feat: pending roles from crm to be confirmed by superadmins

This commit is contained in:
Björn Fromme
2026-08-10 12:26:21 +02:00
parent 681734533f
commit b01c2e84c7
26 changed files with 907 additions and 150 deletions
+23 -21
View File
@@ -9,26 +9,28 @@ parameters:
teamer_inactive_period: '-2 years' teamer_inactive_period: '-2 years'
destinations: # Houses, hotel code: name. The name is used both as a label and to match
- Schwendi # destination.hotel, the code to match destination.hotelCode.
- Waldschlössli houses:
- Spinabad AGR: 'Rotbach'
- Schweizerhaus ASC: 'Christiler'
- Weißfluh DGS: 'Spinabad'
- Victoria DKI: 'Schwendi'
- Klein Tirol DKS: 'Schweizerhaus'
- Jenatsch DPW: 'Waldschlössli'
- Lederer DWW: 'Weißfluh'
- Steinachhof KHH: 'Heuberghaus'
- Val Thorens L2A: 'Les Deux Alpes'
- Les Deux Alpes LPJ: 'Jenatsch'
- Ranalt MVK: 'Klein Tirol'
- Jolimont PCJ: 'Jolimont'
- Christiler PMV: 'Victoria'
- Heuberghaus SBW: 'Val Thorens'
- Silvana SHM: 'Mitterlengau'
- Rotbach SRR: 'Ranalt'
- Mitterlengau SSL: 'Lederer'
SST: 'Steinachhof'
SVS: 'Silvana'
services: services:
_defaults: _defaults:
@@ -37,7 +39,7 @@ services:
bind: bind:
$tempDir: '%kernel.project_dir%/temp' $tempDir: '%kernel.project_dir%/temp'
$logger: '@monolog.logger.myep' $logger: '@monolog.logger.myep'
$destinations: '%destinations%' $houses: '%houses%'
$xmlExport: '@xml_export.storage' $xmlExport: '@xml_export.storage'
$xmlDump: '@xml_dump.storage' $xmlDump: '@xml_dump.storage'
$teamerInactivePeriod: '%teamer_inactive_period%' $teamerInactivePeriod: '%teamer_inactive_period%'
+79 -14
View File
@@ -19,18 +19,19 @@ class UserDataHandler
) { ) {
} }
public function collectRoles(CrmAttributesResponse $crmAttributes, ?string $preferredRole): array /**
* Collects the roles to grant on initial user creation.
*
* Administrative roles are deliberately not importable: they may only be granted
* manually by a super admin, so that nobody can escalate their own privileges via
* the BusPro CRM. They are imported as pending markers instead, which grant nothing
* but mark the user for approval. ROLE_TEAMER carries no privileges of its own and
* is granted directly.
*/
public function collectRoles(CrmAttributesResponse $crmAttributes): array
{ {
// Collect user's roles from CRM attributes // Collect user's roles from CRM attributes
$roles = []; $roles = $this->collectPendingRoles($crmAttributes);
if ($crmAttributes->isAdmin() && (null === $preferredRole || 'admin' === $preferredRole)) {
$roles[] = 'ROLE_ADMIN';
} elseif ($crmAttributes->isManager() && (null === $preferredRole || 'manager' === $preferredRole)) {
$roles[] = 'ROLE_MANAGER';
} elseif ($crmAttributes->isHouseManager() && (null === $preferredRole || 'house_manager' === $preferredRole)) {
$roles[] = 'ROLE_HOUSE_MANAGER';
}
if ($crmAttributes->isTeamer()) { if ($crmAttributes->isTeamer()) {
$roles[] = 'ROLE_TEAMER'; $roles[] = 'ROLE_TEAMER';
@@ -39,6 +40,26 @@ class UserDataHandler
return $roles; return $roles;
} }
/**
* Collects the pending markers for the administrative roles claimed in the CRM.
*/
public function collectPendingRoles(CrmAttributesResponse $crmAttributes): array
{
$roles = [];
if ($crmAttributes->isAdmin()) {
$roles[] = User::PENDING_ROLES['ROLE_ADMIN'];
}
if ($crmAttributes->isManager()) {
$roles[] = User::PENDING_ROLES['ROLE_MANAGER'];
} elseif ($crmAttributes->isHouseManager()) {
$roles[] = User::PENDING_ROLES['ROLE_HOUSE_MANAGER'];
}
return $roles;
}
public function findLocalUser(ProfileResponse $profileResponse): ?User public function findLocalUser(ProfileResponse $profileResponse): ?User
{ {
// Check if user is already present in local database // Check if user is already present in local database
@@ -141,22 +162,31 @@ class UserDataHandler
return $user; return $user;
} }
/**
* Updates an existing user from BusPro data.
*
* Roles and hotel codes are imported once on user creation only and are managed
* manually afterwards, so they are intentionally left untouched here. The only
* exception are the privilege-free pending markers, which keep tracking the
* administrative roles claimed in the CRM.
*
* @param string[] $claimedRoles pending markers as returned by collectPendingRoles()
*/
public function updateLocalUser( public function updateLocalUser(
User $user, User $user,
ProfileResponse $profileResponse, ProfileResponse $profileResponse,
array $roles,
bool $isTeamer = false, bool $isTeamer = false,
array $crmSelections = [], array $crmSelections = [],
array $hotelCodes = [], array $claimedRoles = [],
): void { ): void {
$user $user
->setFirstName($profileResponse->getFirstName()) ->setFirstName($profileResponse->getFirstName())
->setLastName($profileResponse->getName()) ->setLastName($profileResponse->getName())
->setEmail($profileResponse->getCommunication()->getEmail()) ->setEmail($profileResponse->getCommunication()->getEmail())
->setRoles($roles)
->setHotelCodes($hotelCodes)
; ;
$this->refreshPendingRoles($user, $claimedRoles);
if (true === $isTeamer) { if (true === $isTeamer) {
$address = Address::fromApiResponse($profileResponse); $address = Address::fromApiResponse($profileResponse);
$communication = Communication::fromApiResponse($profileResponse); $communication = Communication::fromApiResponse($profileResponse);
@@ -187,4 +217,39 @@ class UserDataHandler
$this->entityManager->flush(); $this->entityManager->flush();
} }
/**
* Keeps the pending markers in sync with the administrative roles claimed in the CRM.
* The markers grant no privileges, so tracking them on every login is safe: only a
* super admin can turn one into an actual role, and an already granted role is never
* marked as pending again.
*
* @param string[] $claimedRoles
*/
private function refreshPendingRoles(User $user, array $claimedRoles): void
{
// an approved role needs no marker anymore
$grantedRoles = $user->getAssignedRoles();
$pendingRoles = array_values(array_filter(
$claimedRoles,
static fn (string $pendingRole): bool => false === in_array(
array_search($pendingRole, User::PENDING_ROLES, true),
$grantedRoles,
true,
),
));
if ($pendingRoles === $user->getPendingRoles()) {
return;
}
// getAssignedRoles() returns the manually assignable roles without the markers
$user->setRoles([...$grantedRoles, ...$pendingRoles]);
$this->logger->info('Refresh pending roles', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'pending_roles' => $pendingRoles,
]);
}
} }
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Config;
/**
* The houses the application works with, as configured by the "houses" parameter.
*
* Every house is identified by its hotel code and its name. Both are used to match
* destinations, but against different columns: the name matches destination.hotel
* (substring), the code matches destination.hotelCode (prefix or suffix, see
* App\Entity\User::hasHotelCodeMatch()). They therefore do not necessarily select the
* same destinations, which is why both are kept.
*/
class HouseCatalog
{
/**
* @param array<string, string> $houses hotel code => name
*/
public function __construct(private readonly array $houses)
{
}
/**
* Choices for filtering destinations by house name: label => name.
*
* @return array<string, string>
*/
public function getNameChoices(): array
{
$names = array_values($this->houses);
sort($names);
return array_combine($names, $names);
}
/**
* Choices for assigning hotel codes to a user: label => code.
*
* @return array<string, string>
*/
public function getCodeChoices(): array
{
$codes = $this->houses;
asort($codes);
return array_flip($codes);
}
public function getName(string $code): ?string
{
return $this->houses[$code] ?? null;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Controller\Admin\System\User;
use App\Entity\User;
use App\Form\UserType;
use App\Security\Voter\UserVoter;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/user/edit/{id}', name: 'app_admin_system_user_edit')]
#[IsGranted(UserVoter::EDIT, subject: 'user')]
public function index(User $user, Request $request): Response
{
$form = $this->createForm(UserType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Die Benutzer:in wurde aktualisiert');
$this->logger->info('Edit user', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'roles' => $user->getRoles(),
'super_admin' => $user->isSuperAdmin(),
'hotel_codes' => $user->getHotelCodes(),
]);
return $this->redirectToRoute('app_admin_system_user_index');
}
return $this->render('admin/system/user/edit.html.twig', [
'form' => $form,
'user' => $user,
]);
}
}
+1 -5
View File
@@ -4,7 +4,6 @@ namespace App\Controller\Security;
use App\Entity\User; use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
@@ -12,7 +11,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController class LoginController extends AbstractController
{ {
#[Route('/login', name: 'app_security_login')] #[Route('/login', name: 'app_security_login')]
public function login(AuthenticationUtils $authenticationUtils, Request $request): Response public function login(AuthenticationUtils $authenticationUtils): Response
{ {
// redirect to default route in case of active session // redirect to default route in case of active session
if (null !== $user = $this->getUser()) { if (null !== $user = $this->getUser()) {
@@ -26,12 +25,9 @@ class LoginController extends AbstractController
// last username entered by the user // last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername(); $lastUsername = $authenticationUtils->getLastUsername();
$role = $request->query->get('role');
return $this->render('security/login.html.twig', [ return $this->render('security/login.html.twig', [
'last_username' => $lastUsername, 'last_username' => $lastUsername,
'error' => $error, 'error' => $error,
'role' => $role,
]); ]);
} }
+71 -12
View File
@@ -8,12 +8,36 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, TimestampableEntityInterface class User implements UserInterface, TimestampableEntityInterface
{ {
use TimestampableEntity; use TimestampableEntity;
/**
* Assignable roles and their labels.
*/
public const ROLES = [
'ROLE_ADMIN' => 'Admin',
'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer',
];
/**
* Markers for administrative roles a user holds in the BusPro CRM, keyed by the role
* they stand for. They grant no privileges whatsoever and merely make the user show
* up for approval, because administrative roles may only ever be granted manually by
* a super admin.
*/
public const PENDING_ROLES = [
'ROLE_ADMIN' => 'ROLE_ADMIN_PENDING',
'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING',
'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING',
];
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -176,19 +200,16 @@ class User implements UserInterface, TimestampableEntityInterface
public function getRolesLabels(): array public function getRolesLabels(): array
{ {
$labels = []; $labels = self::ROLES;
foreach ($this->roles as $role) { foreach (self::PENDING_ROLES as $role => $pendingRole) {
$labels[] = match ($role) { $labels[$pendingRole] = self::ROLES[$role].' (nicht freigeschaltet)';
'ROLE_ADMIN' => 'Admin',
'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer',
default => $role,
};
} }
return $labels; return array_map(
static fn (string $role): string => $labels[$role] ?? $role,
$this->roles,
);
} }
public function setRoles(array $roles): static public function setRoles(array $roles): static
@@ -198,6 +219,29 @@ class User implements UserInterface, TimestampableEntityInterface
return $this; return $this;
} }
/**
* The manually assignable roles held by the user, i.e. without the implicit ROLE_USER
* and ROLE_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* edit role assignments: saving them resolves the pending approvals.
*/
public function getAssignedRoles(): array
{
return array_values(array_intersect($this->roles, array_keys(self::ROLES)));
}
/**
* The pending markers currently held by the user.
*/
public function getPendingRoles(): array
{
return array_values(array_intersect($this->roles, array_values(self::PENDING_ROLES)));
}
public function setAssignedRoles(array $roles): static
{
return $this->setRoles($roles);
}
public function hasRole(string $role): bool public function hasRole(string $role): bool
{ {
return in_array($role, $this->getRoles()); return in_array($role, $this->getRoles());
@@ -215,6 +259,21 @@ class User implements UserInterface, TimestampableEntityInterface
return $this; return $this;
} }
/**
* Super admin is an elevation of ROLE_ADMIN, never a standalone grant.
*/
#[Assert\Callback]
public function validateSuperAdmin(ExecutionContextInterface $context): void
{
if (true === $this->superAdmin && false === in_array('ROLE_ADMIN', $this->roles, true)) {
$context
->buildViolation('Nur Admins können zu Superadmins ernannt werden.')
->atPath('superAdmin')
->addViolation()
;
}
}
public function getLastLoginAt(): ?\DateTimeImmutable public function getLastLoginAt(): ?\DateTimeImmutable
{ {
return $this->lastLoginAt; return $this->lastLoginAt;
@@ -235,9 +294,9 @@ class User implements UserInterface, TimestampableEntityInterface
return 'app_manager_index'; return 'app_manager_index';
} elseif ($this->hasRole('ROLE_HOUSE_MANAGER')) { } elseif ($this->hasRole('ROLE_HOUSE_MANAGER')) {
return 'app_house_manager_index'; return 'app_house_manager_index';
} else {
return 'app_teamer_index';
} }
return 'app_teamer_index';
} }
public function eraseCredentials(): void public function eraseCredentials(): void
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form; namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\Assignment; use App\Entity\Assignment;
use App\Entity\JobProfile; use App\Entity\JobProfile;
use App\Model\ApplicationFilterDto; use App\Model\ApplicationFilterDto;
@@ -17,15 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class ApplicationFilterType extends AbstractType class ApplicationFilterType extends AbstractType
{ {
private array $hotelChoices = []; public function __construct(private readonly Security $security, private readonly HouseCatalog $houseCatalog)
public function __construct(private readonly Security $security, private readonly array $destinations)
{ {
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -126,7 +120,7 @@ class ApplicationFilterType extends AbstractType
'label' => 'Haus/Destination', 'label' => 'Haus/Destination',
'required' => false, 'required' => false,
'empty_label' => 'nicht filtern', 'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices, 'choices' => $this->houseCatalog->getNameChoices(),
]) ])
; ;
} }
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form; namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\Assignment; use App\Entity\Assignment;
use App\Entity\JobProfile; use App\Entity\JobProfile;
use App\Model\AssignmentFilterDto; use App\Model\AssignmentFilterDto;
@@ -17,15 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AssignmentFilterType extends AbstractType class AssignmentFilterType extends AbstractType
{ {
private array $hotelChoices = []; public function __construct(private readonly Security $security, private readonly HouseCatalog $houseCatalog)
public function __construct(private readonly Security $security, private readonly array $destinations)
{ {
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -130,7 +124,7 @@ class AssignmentFilterType extends AbstractType
'label' => 'Haus/Destination', 'label' => 'Haus/Destination',
'required' => false, 'required' => false,
'empty_label' => 'nicht filtern', 'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices, 'choices' => $this->houseCatalog->getNameChoices(),
]) ])
; ;
} }
+3 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form; namespace App\Form;
use App\Config\HouseCatalog;
use App\Model\DestinationFilterDto; use App\Model\DestinationFilterDto;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -12,15 +13,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class DestinationFilterType extends AbstractType class DestinationFilterType extends AbstractType
{ {
private array $hotelChoices = []; public function __construct(private readonly HouseCatalog $houseCatalog)
public function __construct(private readonly array $destinations)
{ {
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -30,7 +24,7 @@ class DestinationFilterType extends AbstractType
'label' => 'Haus/Destination', 'label' => 'Haus/Destination',
'required' => false, 'required' => false,
'empty_label' => 'nicht filtern', 'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices, 'choices' => $this->houseCatalog->getNameChoices(),
]) ])
->add('apply', SubmitType::class, [ ->add('apply', SubmitType::class, [
'label' => 'filtern', 'label' => 'filtern',
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form; namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\JobProfile; use App\Entity\JobProfile;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Entity\Upload; use App\Entity\Upload;
@@ -14,15 +15,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class DocumentFilterType extends AbstractType class DocumentFilterType extends AbstractType
{ {
private array $hotelChoices = []; public function __construct(private readonly HouseCatalog $houseCatalog)
public function __construct(private readonly array $destinations)
{ {
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -56,7 +50,7 @@ class DocumentFilterType extends AbstractType
'label' => 'Haus/Destination', 'label' => 'Haus/Destination',
'required' => false, 'required' => false,
'empty_label' => 'nicht filtern', 'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices, 'choices' => $this->houseCatalog->getNameChoices(),
]) ])
->add('dateFrom', DatepickerType::class, [ ->add('dateFrom', DatepickerType::class, [
'label' => 'Einsatzzeitraum von', 'label' => 'Einsatzzeitraum von',
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form; namespace App\Form;
use App\Config\HouseCatalog;
use App\Model\TimelineFilterDto; use App\Model\TimelineFilterDto;
use App\Service\Assignment\TimelineService; use App\Service\Assignment\TimelineService;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -15,7 +16,7 @@ class TimelineFilterType extends AbstractType
public function __construct( public function __construct(
private readonly TimelineService $timelineService, private readonly TimelineService $timelineService,
private readonly Security $security, private readonly Security $security,
private readonly array $destinations, private readonly HouseCatalog $houseCatalog,
) { ) {
} }
@@ -53,19 +54,12 @@ class TimelineFilterType extends AbstractType
; ;
if (false === $this->security->isGranted('ROLE_HOUSE_MANAGER')) { if (false === $this->security->isGranted('ROLE_HOUSE_MANAGER')) {
$destinations = $this->destinations;
$hotelChoices = [];
sort($destinations);
foreach ($destinations as $item) {
$hotelChoices[$item] = $item;
}
$builder $builder
->add('hotels', MultiselectType::class, [ ->add('hotels', MultiselectType::class, [
'label' => 'Haus/Destination', 'label' => 'Haus/Destination',
'required' => false, 'required' => false,
'empty_label' => 'nicht filtern', 'empty_label' => 'nicht filtern',
'choices' => $hotelChoices, 'choices' => $this->houseCatalog->getNameChoices(),
]) ])
; ;
} }
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserType extends AbstractType
{
public function __construct(private readonly HouseCatalog $houseCatalog)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('roles', MultiselectType::class, [
'label' => 'Rollen',
'required' => false,
'property_path' => 'assignedRoles',
'choices' => array_flip(User::ROLES),
'empty_label' => 'keine Rolle',
])
->add('superAdmin', CheckboxType::class, [
'label' => 'Superadmin',
'required' => false,
'help' => 'Setzt die Rolle Admin voraus.',
])
;
// hotel codes already assigned to the user may predate the catalog, so they are
// added as choices to keep them selectable instead of failing
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$user = $event->getData();
$choices = $this->houseCatalog->getCodeChoices();
foreach ($user instanceof User ? $user->getHotelCodes() : [] as $code) {
if (false === in_array($code, $choices, true)) {
$choices[$code] = $code;
}
}
$event->getForm()->add('hotelCodes', MultiselectType::class, [
'label' => 'Häuser',
'required' => false,
'choices' => $choices,
'empty_label' => 'keine Häuser',
]);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
+5
View File
@@ -259,6 +259,11 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'linkAttributes' => [ 'linkAttributes' => [
'title' => 'Administrative Benutzer:innen', 'title' => 'Administrative Benutzer:innen',
], ],
'extras' => [
'routes' => [
['pattern' => '/^app_admin_system_user_/'],
],
],
]); ]);
$menu->addChild('Logs', [ $menu->addChild('Logs', [
'route' => 'app_admin_log_index', 'route' => 'app_admin_log_index',
+10 -8
View File
@@ -31,16 +31,18 @@ class UserRepository extends ServiceEntityRepository
{ {
$qb = $this->createQueryBuilder('u'); $qb = $this->createQueryBuilder('u');
// administrative roles, plus the pending markers standing in for them
$roles = [...array_keys(User::PENDING_ROLES), ...array_values(User::PENDING_ROLES)];
foreach ($roles as $index => $role) {
$qb
->orWhere('JSON_CONTAINS(u.roles, :role'.$index.') = 1')
->setParameter('role'.$index, json_encode($role))
;
}
return $qb return $qb
->where('JSON_CONTAINS(u.roles, :role_admin) = 1')
->orWhere('JSON_CONTAINS(u.roles, :role_manager) = 1')
->orWhere('JSON_CONTAINS(u.roles, :role_house_manager) = 1')
->orderBy('u.lastName', 'ASC') ->orderBy('u.lastName', 'ASC')
->setParameters([
'role_admin' => json_encode('ROLE_ADMIN'),
'role_manager' => json_encode('ROLE_MANAGER'),
'role_house_manager' => json_encode('ROLE_HOUSE_MANAGER'),
])
->getQuery() ->getQuery()
->getResult() ->getResult()
; ;
+18 -24
View File
@@ -64,8 +64,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
} }
// Final checks and local user loading/creation // Final checks and local user loading/creation
$preferredRole = $request->request->get('_role'); $user = $this->getOrCreateLocalUser($response, $email, $password);
$user = $this->getOrCreateLocalUser($response, $email, $password, $preferredRole);
if (null === $user) { if (null === $user) {
return null; return null;
@@ -110,7 +109,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
ProfileResponse $profileResponse, ProfileResponse $profileResponse,
string $email, string $email,
string $password, string $password,
?string $preferredRole,
): ?User { ): ?User {
// Fetch CRM attributes, early return in case of an API error // Fetch CRM attributes, early return in case of an API error
try { try {
@@ -120,24 +118,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return null; return null;
} }
// Collect user's roles from CRM attributes
$roles = $this
->userDataHandler
->collectRoles($crmAttributes, $preferredRole)
;
// User is expected to have at least one role
if (0 === count($roles)) {
// Revoke roles on existing local user to invalidate any active session
$existingUser = $this->userDataHandler->findLocalUser($profileResponse);
if (null !== $existingUser) {
$existingUser->setRoles([]);
$this->entityManager->flush();
}
return null;
}
// Flatten selected CRM attributes // Flatten selected CRM attributes
$crmSelections = $crmAttributes->toArray(); $crmSelections = $crmAttributes->toArray();
@@ -150,19 +130,33 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->findLocalUser($profileResponse) ->findLocalUser($profileResponse)
; ;
// Update existing user's roles and teamer data and return it // Update existing user's teamer data and return it, leaving roles and hotel
// codes alone: they are imported once on creation and managed manually after
if (null !== $user) { if (null !== $user) {
$this $this
->userDataHandler ->userDataHandler
->updateLocalUser($user, $profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes()) ->updateLocalUser(
$user,
$profileResponse,
$isTeamer,
$crmSelections,
$this->userDataHandler->collectPendingRoles($crmAttributes),
)
; ;
return $user; return $user;
} }
// Initial import of roles and hotel codes on user creation
return $this return $this
->userDataHandler ->userDataHandler
->createLocalUser($profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes()) ->createLocalUser(
$profileResponse,
$this->userDataHandler->collectRoles($crmAttributes),
$isTeamer,
$crmSelections,
$crmAttributes->getHotelCodes(),
)
; ;
} }
} }
+5 -8
View File
@@ -9,13 +9,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
class UserChecker implements UserCheckerInterface class UserChecker implements UserCheckerInterface
{ {
private const APPLICATION_ROLES = [
'ROLE_ADMIN',
'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER',
'ROLE_TEAMER',
];
public function checkPreAuth(UserInterface $user): void public function checkPreAuth(UserInterface $user): void
{ {
if (!$user instanceof User) { if (!$user instanceof User) {
@@ -26,7 +19,11 @@ class UserChecker implements UserCheckerInterface
throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason()); throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason());
} }
if ([] === array_intersect($user->getRoles(), self::APPLICATION_ROLES)) { if ([] === array_intersect($user->getRoles(), array_keys(User::ROLES))) {
if ([] !== $user->getPendingRoles()) {
throw new CustomUserMessageAccountStatusException('Deine Rolle wurde noch nicht freigeschaltet.');
}
throw new CustomUserMessageAccountStatusException('Keine gültige Rolle zugewiesen.'); throw new CustomUserMessageAccountStatusException('Keine gültige Rolle zugewiesen.');
} }
} }
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Security\Voter;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class UserVoter extends Voter
{
public const EDIT = 'CAN_EDIT_USER';
public function __construct(private readonly Security $security)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof User;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$currentUser = $token->getUser();
$targetUser = $subject;
if (!$currentUser instanceof User || !$targetUser instanceof User) {
return false;
}
// editing your own roles or super admin flag is not allowed
if ($currentUser === $targetUser) {
return false;
}
// if the current user is impersonating, do not grant access
if ($this->security->isGranted('IS_IMPERSONATOR')) {
return false;
}
return $currentUser->isSuperAdmin();
}
}
@@ -0,0 +1,16 @@
{{ form_start(form) }}
<div class="flex flex-col space-y-4 pb-8">
{{ form_row(form.roles) }}
{{ form_row(form.superAdmin) }}
{{ form_row(form.hotelCodes) }}
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn">
Speichern
</button>
<a href="{{ path('app_admin_system_user_index') }}" class="btn btn--secondary">
Abbrechen
</a>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
@@ -0,0 +1,12 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Einstellungen - Benutzer:in bearbeiten{% endblock %}
{% block content %}
<div class="max-w-screen-sm">
<h1 class="text-2xl font-bold pb-8">
{{ user.fullName }} bearbeiten
</h1>
{% include 'admin/system/user/_form.html.twig' %}
</div>
{% endblock %}
+8 -1
View File
@@ -56,16 +56,23 @@
{{ user.lastLoginAt ? user.lastLoginAt|date('d.m.Y, H:i') : '-' }} {{ user.lastLoginAt ? user.lastLoginAt|date('d.m.Y, H:i') : '-' }}
</td> </td>
<td> <td>
<div class="flex items-center space-x-2">
{% if is_granted('CAN_IMPERSONATE', user) %} {% if is_granted('CAN_IMPERSONATE', user) %}
<a href="{{ path('app_index', { '_switch_user': user.email }) }}"> <a href="{{ path('app_index', { '_switch_user': user.email }) }}">
{{ icon('mask', 'w-4 h-4') }} {{ icon('mask', 'w-4 h-4') }}
</a> </a>
{% endif %} {% endif %}
{% if is_granted('CAN_EDIT_USER', user) %}
<a href="{{ path('app_admin_system_user_edit', { 'id': user.id }) }}">
{{ icon('edit') }}
</a>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="6"> <td colspan="7">
Keine Daten... Keine Daten...
</td> </td>
</tr> </tr>
-3
View File
@@ -44,9 +44,6 @@
Login Login
</button> </button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
{% if role is not null %}
<input type="hidden" name="_role" value="{{ role }}">
{% endif %}
</form> </form>
<div class="pt-4"> <div class="pt-4">
<a href="{{ path('app_security_password_reset') }}" class="text-sm underline"> <a href="{{ path('app_security_password_reset') }}" class="text-sm underline">
+128 -4
View File
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
use App\BusProNet\Model\Address as BusProAddress; use App\BusProNet\Model\Address as BusProAddress;
use App\BusProNet\Model\Communication as BusProCommunication; use App\BusProNet\Model\Communication as BusProCommunication;
use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\UserDataHandler; use App\BusProNet\UserDataHandler;
use App\Entity\Embeddable\Address; use App\Entity\Embeddable\Address;
@@ -29,6 +30,59 @@ class UserDataHandlerTest extends TestCase
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
} }
/**
* @dataProvider collectRolesProvider
*/
public function testCollectRolesImportsAdministrativeRolesAsPendingOnly(CrmAttributesResponse $crmAttributes, array $expectedRoles): void
{
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertSame($expectedRoles, $handler->collectRoles($crmAttributes));
}
public static function collectRolesProvider(): iterable
{
yield 'admin only yields the pending marker' => [
(new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'manager only yields the pending marker' => [
(new CrmAttributesResponse())->setManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager only yields the pending marker' => [
(new CrmAttributesResponse())->setHouseManager(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'teamer is granted directly' => [
(new CrmAttributesResponse())->setTeamer(true),
['ROLE_TEAMER'],
];
yield 'admin and teamer' => [
(new CrmAttributesResponse())->setAdmin(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_ADMIN'], 'ROLE_TEAMER'],
];
yield 'admin and manager yield both markers' => [
(new CrmAttributesResponse())->setAdmin(true)->setManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'manager takes precedence over house manager' => [
(new CrmAttributesResponse())->setManager(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager and teamer' => [
(new CrmAttributesResponse())->setHouseManager(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'],
];
}
public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void
{ {
$user = (new User()) $user = (new User())
@@ -37,6 +91,8 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]') ->setEmail('[email protected]')
->setBusProAddressId(1) ->setBusProAddressId(1)
->setBusProPersonId(2) ->setBusProPersonId(2)
->setRoles(['ROLE_ADMIN'])
->setHotelCodes(['XYZ'])
; ;
$teamer = (new Teamer()) $teamer = (new Teamer())
@@ -61,17 +117,17 @@ class UserDataHandlerTest extends TestCase
$handler->updateLocalUser( $handler->updateLocalUser(
$user, $user,
$profileResponse, $profileResponse,
['ROLE_TEAMER'],
true, true,
['team' => ['selected' => true]], ['team' => ['selected' => true]],
['ABC'],
); );
$this->assertSame('New', $user->getFirstName()); $this->assertSame('New', $user->getFirstName());
$this->assertSame('Lastname', $user->getLastName()); $this->assertSame('Lastname', $user->getLastName());
$this->assertSame('[email protected]', $user->getEmail()); $this->assertSame('[email protected]', $user->getEmail());
$this->assertSame(['ABC'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_TEAMER')); // roles and hotel codes are imported on creation only and stay under manual control
$this->assertSame(['XYZ'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_ADMIN'));
$this->assertSame('New', $teamer->getFirstName()); $this->assertSame('New', $teamer->getFirstName());
$this->assertSame('Lastname', $teamer->getLastName()); $this->assertSame('Lastname', $teamer->getLastName());
@@ -89,6 +145,74 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections()); $this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections());
} }
/**
* @dataProvider pendingRolesProvider
*/
public function testUpdateLocalUserRefreshesThePendingRoles(
array $roles,
array $claimedRoles,
array $expectedAssignedRoles,
array $expectedPendingRoles,
): void {
$user = (new User())
->setFirstName('Old')
->setLastName('Name')
->setEmail('[email protected]')
->setRoles($roles)
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], $claimedRoles);
$this->assertSame($expectedAssignedRoles, $user->getAssignedRoles());
$this->assertSame($expectedPendingRoles, $user->getPendingRoles());
}
public static function pendingRolesProvider(): iterable
{
yield 'marker is added when the CRM claims a manager' => [
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'marker is dropped when the CRM attribute is gone' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'],
[],
['ROLE_TEAMER'],
[],
];
yield 'an approved role is never marked again' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER'],
[],
];
yield 'a claim beyond the approved role stays pending' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'the claimed role changes' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
[User::PENDING_ROLES['ROLE_MANAGER']],
[],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'granted roles are untouched without any claim' => [
['ROLE_ADMIN'],
[],
['ROLE_ADMIN'],
[],
];
}
public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void
{ {
$user = (new User()) $user = (new User())
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\HouseCatalog;
use PHPUnit\Framework\TestCase;
class HouseCatalogTest extends TestCase
{
private const HOUSES = [
'SVS' => 'Silvana',
'AGR' => 'Rotbach',
'DPW' => 'Waldschlössli',
];
public function testNameChoicesAreSortedAndMapLabelToName(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame([
'Rotbach' => 'Rotbach',
'Silvana' => 'Silvana',
'Waldschlössli' => 'Waldschlössli',
], $catalog->getNameChoices());
}
public function testCodeChoicesAreSortedByLabelAndMapLabelToCode(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame([
'Rotbach' => 'AGR',
'Silvana' => 'SVS',
'Waldschlössli' => 'DPW',
], $catalog->getCodeChoices());
}
public function testGetName(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame('Silvana', $catalog->getName('SVS'));
$this->assertNull($catalog->getName('XYZ'));
}
public function testEmptyCatalog(): void
{
$catalog = new HouseCatalog([]);
$this->assertSame([], $catalog->getNameChoices());
$this->assertSame([], $catalog->getCodeChoices());
}
}
+62
View File
@@ -6,6 +6,8 @@ namespace App\Tests\Entity;
use App\Entity\User; use App\Entity\User;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validation;
class UserTest extends TestCase class UserTest extends TestCase
{ {
@@ -23,4 +25,64 @@ class UserTest extends TestCase
$isMatch = $user->hasHotelCodeMatch('XXDEFXX'); $isMatch = $user->hasHotelCodeMatch('XXDEFXX');
$this->assertFalse($isMatch); $this->assertFalse($isMatch);
} }
public function testPendingMarkersAreLabelledButNotAssignable(): void
{
$user = (new User())->setRoles([
User::PENDING_ROLES['ROLE_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
'ROLE_TEAMER',
]);
$this->assertSame(
['Admin (nicht freigeschaltet)', 'Hausleitung (nicht freigeschaltet)', 'Teamer'],
$user->getRolesLabels(),
);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame(
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
$user->getPendingRoles(),
);
}
public function testSuperAdminRequiresRoleAdmin(): void
{
$user = (new User())
->setRoles(['ROLE_MANAGER'])
->setSuperAdmin(true)
;
$violations = $this->validate($user);
$this->assertCount(1, $violations);
$this->assertSame('superAdmin', $violations[0]->getPropertyPath());
}
public function testSuperAdminWithRoleAdminIsValid(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin(true)
;
$this->assertCount(0, $this->validate($user));
}
public function testNonSuperAdminWithoutRoleAdminIsValid(): void
{
$user = (new User())
->setRoles(['ROLE_TEAMER'])
;
$this->assertCount(0, $this->validate($user));
}
private function validate(User $user): ConstraintViolationListInterface
{
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
->validate($user)
;
}
} }
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\User;
use App\Form\UserType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
class UserTypeTest extends KernelTestCase
{
public function testRendersRolesWithoutSynthesizedRoles(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin(true)
;
$view = $this->createForm($user)->createView();
// ROLE_USER and ROLE_SUPER_ADMIN are synthesized by getRoles() and must not leak in
$this->assertSame(['ROLE_ADMIN'], $view->children['roles']->vars['data']);
}
public function testApprovingAPendingRoleClearsTheMarker(): void
{
$user = (new User())->setRoles([User::PENDING_ROLES['ROLE_ADMIN']]);
$form = $this->createForm($user);
// the marker is not an assignable choice and must not reach the field
$this->assertSame([], $form->createView()->children['roles']->vars['data']);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
}
public function testRendersHotelCodesNotCoveredByTheConfiguredMap(): void
{
$user = (new User())->setHotelCodes(['XYZ']);
$view = $this->createForm($user)->createView();
$this->assertSame(['XYZ'], $view->children['hotelCodes']->vars['data']);
}
public function testSubmitStoresAssignedRolesOnly(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => '1',
'hotelCodes' => [],
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertTrue($user->isSuperAdmin());
}
public function testSubmitRejectsSuperAdminWithoutRoleAdmin(): void
{
$user = new User();
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_MANAGER'],
'superAdmin' => '1',
'hotelCodes' => [],
]);
$this->assertFalse($form->isValid());
$this->assertCount(1, $form->get('superAdmin')->getErrors());
}
private function createForm(User $user): \Symfony\Component\Form\FormInterface
{
self::bootKernel();
/** @var FormFactoryInterface $formFactory */
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
return $formFactory->create(UserType::class, $user, [
'csrf_protection' => false,
]);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\User;
use App\Security\Voter\UserVoter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class UserVoterTest extends TestCase
{
private Security&MockObject $security;
protected function setUp(): void
{
$this->security = $this->createMock(Security::class);
}
public function testSuperAdminMayEditOtherUsers(): void
{
$this->assertSame(
VoterInterface::ACCESS_GRANTED,
$this->vote($this->createUser(true), $this->createUser(false)),
);
}
public function testPlainAdminMayNotEdit(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($this->createUser(false), $this->createUser(false)),
);
}
public function testSuperAdminMayNotEditThemselves(): void
{
$currentUser = $this->createUser(true);
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($currentUser, $currentUser),
);
}
public function testImpersonatorMayNotEdit(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($this->createUser(true), $this->createUser(false), true),
);
}
private function vote(User $currentUser, User $targetUser, bool $isImpersonator = false): int
{
$this->security
->method('isGranted')
->with('IS_IMPERSONATOR')
->willReturn($isImpersonator);
$token = $this->createMock(TokenInterface::class);
$token
->method('getUser')
->willReturn($currentUser);
$voter = new UserVoter($this->security);
return $voter->vote($token, $targetUser, [UserVoter::EDIT]);
}
private function createUser(bool $superAdmin): User
{
return (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin($superAdmin)
;
}
}