feat: admin-managed roles and hotel codes

This commit is contained in:
Björn Fromme
2026-08-10 10:07:24 +02:00
parent 124c0af0f5
commit 6c1073e41c
19 changed files with 718 additions and 34 deletions
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\User;
use App\Form\Admin\UserType;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
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 EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/user/{id}/edit', name: 'app_admin_user_edit')]
public function index(User $user, Request $request): Response
{
$previousRoles = $user->getRoles();
$previousHotelCodes = $user->getHotelCodes();
$form = $this->createForm(UserType::class, $user, ['hx_post' => $request->getRequestUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($this->locksOutSelf($user)) {
$form->get('roles')->addError(new FormError('Du kannst dir die Rolle Administration nicht selbst entziehen.'));
return $this->render('admin/user/modal_edit.html.twig', [
'user' => $user,
'form' => $form,
]);
}
$this->entityManager->flush();
$this->addFlash('success', 'Der Benutzeraccount wurde aktualisiert');
$this->logger->info('Updated user permissions', [
'email' => $user->getEmail(),
'previousRoles' => $previousRoles,
'roles' => $user->getRoles(),
'previousHotelCodes' => $previousHotelCodes,
'hotelCodes' => $user->getHotelCodes(),
]);
return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user'));
}
return $this->render('admin/user/modal_edit.html.twig', [
'user' => $user,
'form' => $form,
]);
}
private function locksOutSelf(User $user): bool
{
return $user === $this->getUser() && false === \in_array(Role::ADMIN, $user->getRoles(), true);
}
}
@@ -26,7 +26,6 @@ class FilterController extends AbstractController
UserFilterType::class,
new UserFilterDto(),
'app_admin_user',
['roles' => UserFilterType::ROLES],
);
return $this->render('admin/_modal_filter.html.twig', [
@@ -36,7 +36,6 @@ class IndexController extends AbstractController
UserFilterType::class,
$filter,
'app_admin_user',
['roles' => UserFilterType::ROLES],
);
$pagination = $this->paginator->paginate(
+2 -19
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Form\Model\Filter\UserFilterDto;
use App\Security\Role;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -13,28 +14,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class UserFilterType extends AbstractListFilterType
{
/**
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
* account has it implicitly and filtering by it would match everything.
*
* @var string[]
*/
public const ROLES = [
'ROLE_ADMIN',
'ROLE_MANAGER',
'ROLE_TEAMER',
'ROLE_CUSTOMER',
'ROLE_HOUSE_MANAGER',
'ROLE_GROUPS_ADMIN',
'ROLE_GROUPS_MANAGER',
];
protected function filterFields(array $options): array
{
return [
'role' => [ChoiceType::class, [
'label' => 'Rolle',
'choices' => array_combine($options['roles'], $options['roles']),
'choices' => array_flip(Role::labels()),
'placeholder' => 'alle',
'required' => false,
]],
@@ -47,9 +32,7 @@ class UserFilterType extends AbstractListFilterType
$resolver->setDefaults([
'data_class' => UserFilterDto::class,
'roles' => [],
]);
$resolver->setAllowedTypes('roles', 'string[]');
}
protected function searchPlaceholder(): string
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Security\Role;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Lets an administrator assign roles and hotel codes to an existing account.
*
* This is the only way those two get changed after the account was created — BpnAuthenticator
* imports them once and never touches them again.
*
* @extends AbstractType<User>
*/
class UserType extends AbstractType
{
/**
* @param array<string, string> $hotelCodes code => label
*/
public function __construct(private readonly array $hotelCodes = [])
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$user = $builder->getData();
$builder
->add('roles', ChoiceType::class, [
'label' => 'Rollen',
'choices' => array_flip(Role::labels()),
'multiple' => true,
'expanded' => true,
'required' => false,
// User::getRoles() prepends the implicit ROLE_USER, which must not be written back.
'getter' => static fn (User $user): array => array_values(array_diff($user->getRoles(), ['ROLE_USER'])),
'setter' => static function (User $user, array $roles): void {
$user->setRoles(array_values(array_unique($roles)));
},
])
->add('hotelCodes', ChoiceType::class, [
'label' => 'Häuser',
'choices' => $this->hotelCodeChoices($user),
'multiple' => true,
'expanded' => true,
'required' => false,
'setter' => static function (User $user, array $hotelCodes): void {
$user->setHotelCodes(array_values(array_unique($hotelCodes)));
},
])
;
}
/**
* Codes already stored on the account are always offered, even when they are missing from
* the configured catalog — otherwise saving the form would silently drop them.
*
* @return array<string, string> label => code
*/
private function hotelCodeChoices(?User $user): array
{
$codes = $this->hotelCodes;
foreach ($user?->getHotelCodes() ?? [] as $code) {
$codes[$code] ??= $code;
}
ksort($codes);
return array_flip($codes);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\Model\Filter;
use App\Model\ListFilterChip;
use App\Security\Role;
class UserFilterDto extends AbstractListFilterDto
{
@@ -15,7 +16,7 @@ class UserFilterDto extends AbstractListFilterDto
$chips = parent::activeFilters();
if (null !== $this->role && '' !== $this->role) {
$chips[] = new ListFilterChip('Rolle', $this->role, ['role']);
$chips[] = new ListFilterChip('Rolle', Role::labels()[$this->role] ?? $this->role, ['role']);
}
return $chips;
+34 -8
View File
@@ -31,9 +31,13 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
/**
* Authenticates users against the BPN API.
*
* Validates credentials via BPN's getPersonalData endpoint, creates or updates
* local User entities, and retrieves CRM attributes (roles, hotel codes) for
* authorization. Passwords are stored encrypted with RSA for subsequent API calls.
* Validates credentials via BPN's getPersonalData endpoint and creates or updates
* local User entities. Passwords are stored encrypted with RSA for subsequent API calls.
*
* CRM attributes (roles, hotel codes) seed a *new* account only: BusPro backend users can
* edit their own CRM selections, so taking roles over on every login would let anybody grant
* themselves Role::PRIVILEGED here. Privileged roles are never imported at all, and from the
* second login on both roles and hotel codes are managed by an administrator in /admin/user.
*/
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
{
@@ -96,15 +100,17 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
throw new CustomUserMessageAuthenticationException($message);
}
$roles = $crmAttributes->roles;
$hotelCodes = $crmAttributes->hotelCodes;
$encryptedPassword = $this->crypt->encrypt($password);
$userRepository = $this->entityManager->getRepository(User::class);
if (null === $user = $userRepository->findOneBy(['email' => $email])) {
$user = new User($email);
$user
->setRoles($this->importableRoles($email, $crmAttributes->roles))
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
;
$this->entityManager->persist($user);
}
@@ -112,8 +118,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->setPassword($encryptedPassword)
->setPersonId($personalData->personId)
->setAddressId($personalData->addressId)
->setRoles($roles)
->setHotelCodes($hotelCodes)
->setLastLoginAt(new \DateTimeImmutable())
->setProfileComplete($this->completenessChecker->isComplete($personalData))
;
@@ -123,6 +127,28 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return $user;
}
/**
* @param string[] $crmRoles
*
* @return string[]
*/
private function importableRoles(string $email, array $crmRoles): array
{
$roles = Role::filterImportable($crmRoles);
$dropped = array_values(array_intersect($crmRoles, Role::PRIVILEGED));
if ([] !== $dropped) {
// Somebody holds a privileged CRM selection in BusPro. We do not honour it, but it
// should stay visible: it either needs to be revoked there or granted in /admin/user.
$this->authLogger->warning('Ignored privileged roles from BPN CRM attributes', [
'email' => $email,
'roles' => $dropped,
]);
}
return $roles;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
$this->authLogger->info('Login', [
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* The roles this application knows about.
*
* Roles are stored as plain strings in the User::$roles JSON column and consumed as strings
* by #[IsGranted], the voters and security.yaml's role_hierarchy, so they are constants
* rather than an enum.
*/
final class Role
{
public const ADMIN = 'ROLE_ADMIN';
public const MANAGER = 'ROLE_MANAGER';
public const TEAMER = 'ROLE_TEAMER';
public const CUSTOMER = 'ROLE_CUSTOMER';
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
/**
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
* account has it implicitly (see User::getRoles()) and it is never stored.
*
* @var string[]
*/
public const ALL = [
self::ADMIN,
self::MANAGER,
self::TEAMER,
self::CUSTOMER,
self::HOUSE_MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
];
/**
* Roles that are never taken over from BusProNet. Many people can edit CRM selections in
* the BusPro backend, so these are granted by an administrator in /admin/user only.
*
* @var string[]
*/
public const PRIVILEGED = [
self::ADMIN,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
];
/**
* Reduces the roles derived from BPN CRM attributes to the ones we accept from there.
*
* Falls back to ROLE_CUSTOMER the way CrmAttributesResponseParser does, so an account
* whose only selection was a privileged one does not end up without any role.
*
* @param string[] $roles
*
* @return string[]
*/
public static function filterImportable(array $roles): array
{
$importable = array_values(array_unique(array_diff($roles, self::PRIVILEGED)));
return [] === $importable ? [self::CUSTOMER] : $importable;
}
/**
* @return array<string, string> role => label
*/
public static function labels(): array
{
return [
self::ADMIN => 'Administration',
self::MANAGER => 'Manager:in',
self::TEAMER => 'Teamer:in',
self::CUSTOMER => 'Kund:in',
self::HOUSE_MANAGER => 'Hausleitung',
self::GROUPS_ADMIN => 'Preisrechner Admin',
self::GROUPS_MANAGER => 'Preisrechner',
];
}
}
+1
View File
@@ -27,6 +27,7 @@ class AppExtension extends AbstractExtension
new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']),
new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']),
new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']),
new TwigFilter('map_roles', [AppRuntime::class, 'mapRoles']),
];
}
+28
View File
@@ -12,6 +12,7 @@ use App\Form\Service\Condition\TravelStartCutoffReachedCondition;
use App\Form\Service\CreateFieldStateProvider;
use App\Form\Service\EditFieldStateProvider;
use App\Model\DomainConfig;
use App\Security\Role;
use App\Service\ParticipantEligibilityChecker;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -124,6 +125,33 @@ class AppRuntime implements RuntimeExtensionInterface
return $this->countryDataProvider->get($nationality)?->nationality;
}
/**
* Turns the roles stored on a User into the labels the edit form uses.
*
* ROLE_USER is dropped because every account holds it implicitly; a role without a label
* is passed through unchanged so it stays visible instead of silently disappearing.
*
* @param string[] $roles
*
* @return string[]
*/
public function mapRoles(array $roles): array
{
$labels = Role::labels();
$mapped = [];
foreach ($roles as $role) {
if ('ROLE_USER' === $role) {
continue;
}
$mapped[] = $labels[$role] ?? $role;
}
return $mapped;
}
public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool
{
return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex);