feat: admin-managed roles and hotel codes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user