From b39a78da826e41b09ccdec09bb602cba9cd18aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 10 Aug 2026 13:01:01 +0200 Subject: [PATCH] feat: demotion of user accounts via crm --- src/BusProNet/UserDataHandler.php | 31 +++++++++++ .../Admin/System/User/EditController.php | 1 + src/Entity/User.php | 21 ++++++++ src/Form/UserType.php | 17 ++++++ src/Security/BpnAuthenticator.php | 21 +++++++- src/Security/UserChecker.php | 2 +- templates/admin/system/user/_form.html.twig | 2 + templates/admin/system/user/index.html.twig | 12 ++++- tests/BusProNet/UserDataHandlerTest.php | 52 +++++++++++++++++++ tests/Entity/UserTest.php | 41 +++++++++++++++ tests/Form/UserTypeTest.php | 43 +++++++++++++++ 11 files changed, 240 insertions(+), 3 deletions(-) diff --git a/src/BusProNet/UserDataHandler.php b/src/BusProNet/UserDataHandler.php index be70d13..810e0e2 100644 --- a/src/BusProNet/UserDataHandler.php +++ b/src/BusProNet/UserDataHandler.php @@ -218,6 +218,37 @@ class UserDataHandler $this->entityManager->flush(); } + /** + * Blocks a user the CRM no longer grants anything in this application. + * + * The granted roles are deliberately kept: they stay visible for review and are what + * makes the user reappear in the administrative list, where a super admin can unblock + * them. Only the privilege-free markers are dropped, as they no longer reflect the CRM. + * Regaining a CRM role does not unblock the account, that is a manual decision. + */ + public function disableForRevokedCrmRoles(User $user): void + { + // an existing block may be a disciplinary one and must never be overwritten + if (true === $user->isDisabled()) { + return; + } + + $this->refreshPendingRoles($user, []); + + $user + ->setDisabledAt(new \DateTimeImmutable()) + ->setDisabledReason('Für deinen Account liegt in BusPro keine Berechtigung mehr vor.') + ->setDisabledReasonInternal('Automatisch gesperrt: keine Rollen in BusPro.') + ; + + $this->entityManager->flush(); + + $this->logger->info('Disable user without CRM roles', [ + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail(), + ]); + } + /** * 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 diff --git a/src/Controller/Admin/System/User/EditController.php b/src/Controller/Admin/System/User/EditController.php index f7accdd..21abc73 100644 --- a/src/Controller/Admin/System/User/EditController.php +++ b/src/Controller/Admin/System/User/EditController.php @@ -38,6 +38,7 @@ class EditController extends AbstractController 'roles' => $user->getRoles(), 'super_admin' => $user->isSuperAdmin(), 'hotel_codes' => $user->getHotelCodes(), + 'disabled' => $user->isDisabled(), ]); return $this->redirectToRoute('app_admin_system_user_index'); diff --git a/src/Entity/User.php b/src/Entity/User.php index 6333b4a..1cd72c3 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -387,6 +387,27 @@ class User implements UserInterface, TimestampableEntityInterface return null !== $this->disabledAt; } + /** + * Blocks or unblocks the account, keeping the reasons in sync. Unchanged state is a + * no-op so that saving an unrelated change does not reset the timestamp. + */ + public function setDisabled(bool $disabled): static + { + if ($disabled === $this->isDisabled()) { + return $this; + } + + if (false === $disabled) { + return $this + ->setDisabledAt(null) + ->setDisabledReason(null) + ->setDisabledReasonInternal(null) + ; + } + + return $this->setDisabledAt(new \DateTimeImmutable()); + } + public function getDisabledReason(): ?string { return $this->disabledReason; diff --git a/src/Form/UserType.php b/src/Form/UserType.php index 6af4b63..c94c39e 100644 --- a/src/Form/UserType.php +++ b/src/Form/UserType.php @@ -6,6 +6,7 @@ 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\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -32,6 +33,22 @@ class UserType extends AbstractType 'required' => false, 'help' => 'Setzt die Rolle Admin voraus.', ]) + // must stay ahead of the reason: properties are written in field order and + // unblocking clears the reasons + ->add('disabled', CheckboxType::class, [ + 'label' => 'Account gesperrt', + 'required' => false, + 'help' => 'Gesperrte Benutzer:innen können sich nicht anmelden.', + ]) + ->add('disabledReason', TextareaType::class, [ + 'label' => 'Begründung', + 'required' => false, + 'help' => 'Wird bei der Anmeldung angezeigt.', + 'attr' => [ + 'data-controller' => 'textarea-autosize', + 'data-action' => 'textarea-autosize#resize', + ], + ]) ; // hotel codes already assigned to the user may predate the catalog, so they are diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index 47c39e8..c31b331 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -124,12 +124,31 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent // Determine teamer status from CRM attributes $isTeamer = $crmAttributes->isTeamer(); + // Everything the CRM grants this person here: pending markers plus ROLE_TEAMER + $claimedRoles = $this + ->userDataHandler + ->collectRoles($crmAttributes) + ; + // Check if user is already present in local database $user = $this ->userDataHandler ->findLocalUser($profileResponse) ; + // BusPro knows this person but grants them nothing in this application, so they + // are no user of it: never create an account, block an existing one. Returning + // the blocked user lets the UserChecker explain why the login was refused. + if ([] === $claimedRoles) { + if (null === $user) { + return null; + } + + $this->userDataHandler->disableForRevokedCrmRoles($user); + + return $user; + } + // 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) { @@ -152,7 +171,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent ->userDataHandler ->createLocalUser( $profileResponse, - $this->userDataHandler->collectRoles($crmAttributes), + $claimedRoles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes(), diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php index d2fd6df..35d3f74 100644 --- a/src/Security/UserChecker.php +++ b/src/Security/UserChecker.php @@ -21,7 +21,7 @@ class UserChecker implements UserCheckerInterface if ([] === array_intersect($user->getRoles(), array_keys(User::ROLES))) { if ([] !== $user->getPendingRoles()) { - throw new CustomUserMessageAccountStatusException('Deine Rolle wurde noch nicht freigeschaltet.'); + throw new CustomUserMessageAccountStatusException('Dein Account wurde noch nicht freigeschaltet.'); } throw new CustomUserMessageAccountStatusException('Keine gültige Rolle zugewiesen.'); diff --git a/templates/admin/system/user/_form.html.twig b/templates/admin/system/user/_form.html.twig index cc42ff5..f5a0af5 100644 --- a/templates/admin/system/user/_form.html.twig +++ b/templates/admin/system/user/_form.html.twig @@ -3,6 +3,8 @@ {{ form_row(form.roles) }} {{ form_row(form.superAdmin) }} {{ form_row(form.hotelCodes) }} + {{ form_row(form.disabled) }} + {{ form_row(form.disabledReason) }}