feat: demotion of user accounts via crm
This commit is contained in:
@@ -218,6 +218,37 @@ class UserDataHandler
|
|||||||
$this->entityManager->flush();
|
$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.
|
* 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
|
* 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(),
|
'roles' => $user->getRoles(),
|
||||||
'super_admin' => $user->isSuperAdmin(),
|
'super_admin' => $user->isSuperAdmin(),
|
||||||
'hotel_codes' => $user->getHotelCodes(),
|
'hotel_codes' => $user->getHotelCodes(),
|
||||||
|
'disabled' => $user->isDisabled(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->redirectToRoute('app_admin_system_user_index');
|
return $this->redirectToRoute('app_admin_system_user_index');
|
||||||
|
|||||||
@@ -387,6 +387,27 @@ class User implements UserInterface, TimestampableEntityInterface
|
|||||||
return null !== $this->disabledAt;
|
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
|
public function getDisabledReason(): ?string
|
||||||
{
|
{
|
||||||
return $this->disabledReason;
|
return $this->disabledReason;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Config\HouseCatalog;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
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\FormBuilderInterface;
|
||||||
use Symfony\Component\Form\FormEvent;
|
use Symfony\Component\Form\FormEvent;
|
||||||
use Symfony\Component\Form\FormEvents;
|
use Symfony\Component\Form\FormEvents;
|
||||||
@@ -32,6 +33,22 @@ class UserType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'help' => 'Setzt die Rolle Admin voraus.',
|
'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
|
// 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
|
// Determine teamer status from CRM attributes
|
||||||
$isTeamer = $crmAttributes->isTeamer();
|
$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
|
// Check if user is already present in local database
|
||||||
$user = $this
|
$user = $this
|
||||||
->userDataHandler
|
->userDataHandler
|
||||||
->findLocalUser($profileResponse)
|
->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
|
// 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
|
// codes alone: they are imported once on creation and managed manually after
|
||||||
if (null !== $user) {
|
if (null !== $user) {
|
||||||
@@ -152,7 +171,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
->userDataHandler
|
->userDataHandler
|
||||||
->createLocalUser(
|
->createLocalUser(
|
||||||
$profileResponse,
|
$profileResponse,
|
||||||
$this->userDataHandler->collectRoles($crmAttributes),
|
$claimedRoles,
|
||||||
$isTeamer,
|
$isTeamer,
|
||||||
$crmSelections,
|
$crmSelections,
|
||||||
$crmAttributes->getHotelCodes(),
|
$crmAttributes->getHotelCodes(),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class UserChecker implements UserCheckerInterface
|
|||||||
|
|
||||||
if ([] === array_intersect($user->getRoles(), array_keys(User::ROLES))) {
|
if ([] === array_intersect($user->getRoles(), array_keys(User::ROLES))) {
|
||||||
if ([] !== $user->getPendingRoles()) {
|
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.');
|
throw new CustomUserMessageAccountStatusException('Keine gültige Rolle zugewiesen.');
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
{{ form_row(form.roles) }}
|
{{ form_row(form.roles) }}
|
||||||
{{ form_row(form.superAdmin) }}
|
{{ form_row(form.superAdmin) }}
|
||||||
{{ form_row(form.hotelCodes) }}
|
{{ form_row(form.hotelCodes) }}
|
||||||
|
{{ form_row(form.disabled) }}
|
||||||
|
{{ form_row(form.disabledReason) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<button type="submit" class="btn">
|
<button type="submit" class="btn">
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
<th>
|
<th>
|
||||||
Superadmin
|
Superadmin
|
||||||
</th>
|
</th>
|
||||||
|
<th>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
<th>
|
<th>
|
||||||
Letzter Login
|
Letzter Login
|
||||||
</th>
|
</th>
|
||||||
@@ -52,6 +55,13 @@
|
|||||||
{{ icon('check', 'w-4 h-4 inline-block') }}
|
{{ icon('check', 'w-4 h-4 inline-block') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
{{ user.lastLoginAt ? user.lastLoginAt|date('d.m.Y, H:i') : '-' }}
|
{{ user.lastLoginAt ? user.lastLoginAt|date('d.m.Y, H:i') : '-' }}
|
||||||
</td>
|
</td>
|
||||||
@@ -72,7 +82,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7">
|
<td colspan="8">
|
||||||
Keine Daten...
|
Keine Daten...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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
|
public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void
|
||||||
{
|
{
|
||||||
$user = (new User())
|
$user = (new User())
|
||||||
|
|||||||
@@ -77,6 +77,47 @@ class UserTest extends TestCase
|
|||||||
$this->assertCount(0, $this->validate($user));
|
$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
|
private function validate(User $user): ConstraintViolationListInterface
|
||||||
{
|
{
|
||||||
return Validation::createValidatorBuilder()
|
return Validation::createValidatorBuilder()
|
||||||
|
|||||||
@@ -84,6 +84,49 @@ class UserTypeTest extends KernelTestCase
|
|||||||
$this->assertCount(1, $form->get('superAdmin')->getErrors());
|
$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
|
private function createForm(User $user): \Symfony\Component\Form\FormInterface
|
||||||
{
|
{
|
||||||
self::bootKernel();
|
self::bootKernel();
|
||||||
|
|||||||
Reference in New Issue
Block a user