feat: demotion of user accounts via crm

This commit is contained in:
Björn Fromme
2026-08-10 13:01:01 +02:00
parent b01c2e84c7
commit b39a78da82
11 changed files with 240 additions and 3 deletions
+31
View File
@@ -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
@@ -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');
+21
View File
@@ -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;
+17
View File
@@ -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
+20 -1
View File
@@ -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(),
+1 -1
View File
@@ -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.');
@@ -3,6 +3,8 @@
{{ form_row(form.roles) }}
{{ form_row(form.superAdmin) }}
{{ form_row(form.hotelCodes) }}
{{ form_row(form.disabled) }}
{{ form_row(form.disabledReason) }}
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn">
+11 -1
View File
@@ -26,6 +26,9 @@
<th>
Superadmin
</th>
<th>
Status
</th>
<th>
Letzter Login
</th>
@@ -52,6 +55,13 @@
{{ icon('check', 'w-4 h-4 inline-block') }}
{% endif %}
</td>
<td>
{% if user.disabled %}
<span class="inline-block py-1 px-2 text-xs bg-red-500 text-white">
gesp. am {{ user.disabledAt|date('d.m.Y') }}
</span>
{% endif %}
</td>
<td>
{{ user.lastLoginAt ? user.lastLoginAt|date('d.m.Y, H:i') : '-' }}
</td>
@@ -72,7 +82,7 @@
</tr>
{% else %}
<tr>
<td colspan="7">
<td colspan="8">
Keine Daten...
</td>
</tr>
+52
View File
@@ -213,6 +213,58 @@ class UserDataHandlerTest extends TestCase
];
}
public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', User::PENDING_ROLES['ROLE_MANAGER']])
;
$this->entityManager
->expects($this->once())
->method('flush');
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->disableForRevokedCrmRoles($user);
$this->assertTrue($user->isDisabled());
$this->assertNotNull($user->getDisabledAt());
$this->assertSame('Für deinen Account liegt in BusPro keine Berechtigung mehr vor.', $user->getDisabledReason());
$this->assertSame('Automatisch gesperrt: keine Rollen in BusPro.', $user->getDisabledReasonInternal());
// the granted role is kept so the user stays reviewable, the marker is not
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
}
public function testDisableForRevokedCrmRolesLeavesAnExistingBlockUntouched(): void
{
$disabledAt = new \DateTimeImmutable('2026-01-01 08:00:00');
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN'])
->setDisabledAt($disabledAt)
->setDisabledReason('Wegen Fehlverhaltens gesperrt.')
->setDisabledReasonInternal('Siehe Vorgang 4711.')
;
$this->entityManager
->expects($this->never())
->method('flush');
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->disableForRevokedCrmRoles($user);
$this->assertSame($disabledAt, $user->getDisabledAt());
$this->assertSame('Wegen Fehlverhaltens gesperrt.', $user->getDisabledReason());
$this->assertSame('Siehe Vorgang 4711.', $user->getDisabledReasonInternal());
}
public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void
{
$user = (new User())
+41
View File
@@ -77,6 +77,47 @@ class UserTest extends TestCase
$this->assertCount(0, $this->validate($user));
}
public function testSetDisabledBlocksAndUnblocksTheAccount(): void
{
$user = new User();
$user->setDisabled(true);
$this->assertTrue($user->isDisabled());
$this->assertNotNull($user->getDisabledAt());
$user
->setDisabledReason('Gesperrt.')
->setDisabledReasonInternal('Siehe Vorgang 4711.')
->setDisabled(false)
;
$this->assertFalse($user->isDisabled());
$this->assertNull($user->getDisabledAt());
$this->assertNull($user->getDisabledReason());
$this->assertNull($user->getDisabledReasonInternal());
}
public function testSetDisabledKeepsTheOriginalTimestampWhenTheStateIsUnchanged(): void
{
$disabledAt = new \DateTimeImmutable('2026-01-01 08:00:00');
$user = (new User())
->setDisabledAt($disabledAt)
->setDisabledReason('Gesperrt.')
;
$user->setDisabled(true);
$this->assertSame($disabledAt, $user->getDisabledAt());
$this->assertSame('Gesperrt.', $user->getDisabledReason());
$enabled = new User();
$enabled->setDisabled(false);
$this->assertNull($enabled->getDisabledAt());
}
private function validate(User $user): ConstraintViolationListInterface
{
return Validation::createValidatorBuilder()
+43
View File
@@ -84,6 +84,49 @@ class UserTypeTest extends KernelTestCase
$this->assertCount(1, $form->get('superAdmin')->getErrors());
}
public function testSubmitBlocksTheAccountWithAReason(): void
{
$user = (new User())->setRoles(['ROLE_ADMIN']);
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
'disabled' => '1',
'disabledReason' => 'Wegen Fehlverhaltens gesperrt.',
]);
$this->assertTrue($form->isValid());
$this->assertTrue($user->isDisabled());
$this->assertSame('Wegen Fehlverhaltens gesperrt.', $user->getDisabledReason());
}
public function testSubmitUnblocksTheAccountAndClearsTheReasons(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setDisabledAt(new \DateTimeImmutable('2026-01-01 08:00:00'))
->setDisabledReason('Für deinen Account liegt in BusPro keine Berechtigung mehr vor.')
->setDisabledReasonInternal('Automatisch gesperrt: keine Rollen in BusPro.')
;
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
'disabled' => null,
'disabledReason' => null,
]);
$this->assertTrue($form->isValid());
$this->assertFalse($user->isDisabled());
$this->assertNull($user->getDisabledReason());
$this->assertNull($user->getDisabledReasonInternal());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
}
private function createForm(User $user): \Symfony\Component\Form\FormInterface
{
self::bootKernel();