feat: pending roles from crm to be confirmed by superadmins
This commit is contained in:
@@ -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
|
||||
$roles = [];
|
||||
|
||||
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';
|
||||
}
|
||||
$roles = $this->collectPendingRoles($crmAttributes);
|
||||
|
||||
if ($crmAttributes->isTeamer()) {
|
||||
$roles[] = 'ROLE_TEAMER';
|
||||
@@ -39,6 +40,26 @@ class UserDataHandler
|
||||
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
|
||||
{
|
||||
// Check if user is already present in local database
|
||||
@@ -141,22 +162,31 @@ class UserDataHandler
|
||||
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(
|
||||
User $user,
|
||||
ProfileResponse $profileResponse,
|
||||
array $roles,
|
||||
bool $isTeamer = false,
|
||||
array $crmSelections = [],
|
||||
array $hotelCodes = [],
|
||||
array $claimedRoles = [],
|
||||
): void {
|
||||
$user
|
||||
->setFirstName($profileResponse->getFirstName())
|
||||
->setLastName($profileResponse->getName())
|
||||
->setEmail($profileResponse->getCommunication()->getEmail())
|
||||
->setRoles($roles)
|
||||
->setHotelCodes($hotelCodes)
|
||||
;
|
||||
|
||||
$this->refreshPendingRoles($user, $claimedRoles);
|
||||
|
||||
if (true === $isTeamer) {
|
||||
$address = Address::fromApiResponse($profileResponse);
|
||||
$communication = Communication::fromApiResponse($profileResponse);
|
||||
@@ -187,4 +217,39 @@ class UserDataHandler
|
||||
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Controller\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
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\Authentication\AuthenticationUtils;
|
||||
@@ -12,7 +11,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
class LoginController extends AbstractController
|
||||
{
|
||||
#[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
|
||||
if (null !== $user = $this->getUser()) {
|
||||
@@ -26,12 +25,9 @@ class LoginController extends AbstractController
|
||||
// last username entered by the user
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
$role = $request->query->get('role');
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'last_username' => $lastUsername,
|
||||
'error' => $error,
|
||||
'role' => $role,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+71
-12
@@ -8,12 +8,36 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
class User implements UserInterface, TimestampableEntityInterface
|
||||
{
|
||||
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\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -176,19 +200,16 @@ class User implements UserInterface, TimestampableEntityInterface
|
||||
|
||||
public function getRolesLabels(): array
|
||||
{
|
||||
$labels = [];
|
||||
$labels = self::ROLES;
|
||||
|
||||
foreach ($this->roles as $role) {
|
||||
$labels[] = match ($role) {
|
||||
'ROLE_ADMIN' => 'Admin',
|
||||
'ROLE_MANAGER' => 'Reisemanager',
|
||||
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
|
||||
'ROLE_TEAMER' => 'Teamer',
|
||||
default => $role,
|
||||
};
|
||||
foreach (self::PENDING_ROLES as $role => $pendingRole) {
|
||||
$labels[$pendingRole] = self::ROLES[$role].' (nicht freigeschaltet)';
|
||||
}
|
||||
|
||||
return $labels;
|
||||
return array_map(
|
||||
static fn (string $role): string => $labels[$role] ?? $role,
|
||||
$this->roles,
|
||||
);
|
||||
}
|
||||
|
||||
public function setRoles(array $roles): static
|
||||
@@ -198,6 +219,29 @@ class User implements UserInterface, TimestampableEntityInterface
|
||||
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
|
||||
{
|
||||
return in_array($role, $this->getRoles());
|
||||
@@ -215,6 +259,21 @@ class User implements UserInterface, TimestampableEntityInterface
|
||||
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
|
||||
{
|
||||
return $this->lastLoginAt;
|
||||
@@ -235,9 +294,9 @@ class User implements UserInterface, TimestampableEntityInterface
|
||||
return 'app_manager_index';
|
||||
} elseif ($this->hasRole('ROLE_HOUSE_MANAGER')) {
|
||||
return 'app_house_manager_index';
|
||||
} else {
|
||||
return 'app_teamer_index';
|
||||
}
|
||||
|
||||
return 'app_teamer_index';
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Model\ApplicationFilterDto;
|
||||
@@ -17,15 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ApplicationFilterType extends AbstractType
|
||||
{
|
||||
private array $hotelChoices = [];
|
||||
|
||||
public function __construct(private readonly Security $security, private readonly array $destinations)
|
||||
public function __construct(private readonly Security $security, private readonly HouseCatalog $houseCatalog)
|
||||
{
|
||||
$destinations = $this->destinations;
|
||||
sort($destinations);
|
||||
foreach ($destinations as $item) {
|
||||
$this->hotelChoices[$item] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
@@ -126,7 +120,7 @@ class ApplicationFilterType extends AbstractType
|
||||
'label' => 'Haus/Destination',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => $this->hotelChoices,
|
||||
'choices' => $this->houseCatalog->getNameChoices(),
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Model\AssignmentFilterDto;
|
||||
@@ -17,15 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class AssignmentFilterType extends AbstractType
|
||||
{
|
||||
private array $hotelChoices = [];
|
||||
|
||||
public function __construct(private readonly Security $security, private readonly array $destinations)
|
||||
public function __construct(private readonly Security $security, private readonly HouseCatalog $houseCatalog)
|
||||
{
|
||||
$destinations = $this->destinations;
|
||||
sort($destinations);
|
||||
foreach ($destinations as $item) {
|
||||
$this->hotelChoices[$item] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
@@ -130,7 +124,7 @@ class AssignmentFilterType extends AbstractType
|
||||
'label' => 'Haus/Destination',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => $this->hotelChoices,
|
||||
'choices' => $this->houseCatalog->getNameChoices(),
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Model\DestinationFilterDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
@@ -12,15 +13,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class DestinationFilterType extends AbstractType
|
||||
{
|
||||
private array $hotelChoices = [];
|
||||
|
||||
public function __construct(private readonly array $destinations)
|
||||
public function __construct(private readonly HouseCatalog $houseCatalog)
|
||||
{
|
||||
$destinations = $this->destinations;
|
||||
sort($destinations);
|
||||
foreach ($destinations as $item) {
|
||||
$this->hotelChoices[$item] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
@@ -30,7 +24,7 @@ class DestinationFilterType extends AbstractType
|
||||
'label' => 'Haus/Destination',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => $this->hotelChoices,
|
||||
'choices' => $this->houseCatalog->getNameChoices(),
|
||||
])
|
||||
->add('apply', SubmitType::class, [
|
||||
'label' => 'filtern',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Entity\JobProfile;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
@@ -14,15 +15,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class DocumentFilterType extends AbstractType
|
||||
{
|
||||
private array $hotelChoices = [];
|
||||
|
||||
public function __construct(private readonly array $destinations)
|
||||
public function __construct(private readonly HouseCatalog $houseCatalog)
|
||||
{
|
||||
$destinations = $this->destinations;
|
||||
sort($destinations);
|
||||
foreach ($destinations as $item) {
|
||||
$this->hotelChoices[$item] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
@@ -56,7 +50,7 @@ class DocumentFilterType extends AbstractType
|
||||
'label' => 'Haus/Destination',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => $this->hotelChoices,
|
||||
'choices' => $this->houseCatalog->getNameChoices(),
|
||||
])
|
||||
->add('dateFrom', DatepickerType::class, [
|
||||
'label' => 'Einsatzzeitraum von',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Config\HouseCatalog;
|
||||
use App\Model\TimelineFilterDto;
|
||||
use App\Service\Assignment\TimelineService;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -15,7 +16,7 @@ class TimelineFilterType extends AbstractType
|
||||
public function __construct(
|
||||
private readonly TimelineService $timelineService,
|
||||
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')) {
|
||||
$destinations = $this->destinations;
|
||||
$hotelChoices = [];
|
||||
sort($destinations);
|
||||
foreach ($destinations as $item) {
|
||||
$hotelChoices[$item] = $item;
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('hotels', MultiselectType::class, [
|
||||
'label' => 'Haus/Destination',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => $hotelChoices,
|
||||
'choices' => $this->houseCatalog->getNameChoices(),
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,11 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
'linkAttributes' => [
|
||||
'title' => 'Administrative Benutzer:innen',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_admin_system_user_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Logs', [
|
||||
'route' => 'app_admin_log_index',
|
||||
|
||||
@@ -31,16 +31,18 @@ class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
$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
|
||||
->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')
|
||||
->setParameters([
|
||||
'role_admin' => json_encode('ROLE_ADMIN'),
|
||||
'role_manager' => json_encode('ROLE_MANAGER'),
|
||||
'role_house_manager' => json_encode('ROLE_HOUSE_MANAGER'),
|
||||
])
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
@@ -64,8 +64,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
}
|
||||
|
||||
// Final checks and local user loading/creation
|
||||
$preferredRole = $request->request->get('_role');
|
||||
$user = $this->getOrCreateLocalUser($response, $email, $password, $preferredRole);
|
||||
$user = $this->getOrCreateLocalUser($response, $email, $password);
|
||||
|
||||
if (null === $user) {
|
||||
return null;
|
||||
@@ -110,7 +109,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
ProfileResponse $profileResponse,
|
||||
string $email,
|
||||
string $password,
|
||||
?string $preferredRole,
|
||||
): ?User {
|
||||
// Fetch CRM attributes, early return in case of an API error
|
||||
try {
|
||||
@@ -120,24 +118,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
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
|
||||
$crmSelections = $crmAttributes->toArray();
|
||||
|
||||
@@ -150,19 +130,33 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
->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) {
|
||||
$this
|
||||
->userDataHandler
|
||||
->updateLocalUser($user, $profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes())
|
||||
->updateLocalUser(
|
||||
$user,
|
||||
$profileResponse,
|
||||
$isTeamer,
|
||||
$crmSelections,
|
||||
$this->userDataHandler->collectPendingRoles($crmAttributes),
|
||||
)
|
||||
;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Initial import of roles and hotel codes on user creation
|
||||
return $this
|
||||
->userDataHandler
|
||||
->createLocalUser($profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes())
|
||||
->createLocalUser(
|
||||
$profileResponse,
|
||||
$this->userDataHandler->collectRoles($crmAttributes),
|
||||
$isTeamer,
|
||||
$crmSelections,
|
||||
$crmAttributes->getHotelCodes(),
|
||||
)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class UserChecker implements UserCheckerInterface
|
||||
{
|
||||
private const APPLICATION_ROLES = [
|
||||
'ROLE_ADMIN',
|
||||
'ROLE_MANAGER',
|
||||
'ROLE_HOUSE_MANAGER',
|
||||
'ROLE_TEAMER',
|
||||
];
|
||||
|
||||
public function checkPreAuth(UserInterface $user): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
@@ -26,7 +19,11 @@ class UserChecker implements UserCheckerInterface
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user