feat: demotion of user accounts via crm
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user