feat: bpn as single source of truth for role and hotel code assignments

This commit is contained in:
Björn Fromme
2026-08-18 11:33:42 +02:00
parent 1fd0fbc21e
commit f0e850978b
17 changed files with 818 additions and 198 deletions
@@ -0,0 +1,55 @@
<?php
namespace App\Controller\Admin\System\User;
use App\BusProNet\UserDataHandler;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Security\Voter\UserVoter;
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;
/**
* Granting a privilege is its own act, deliberately not a checkbox on the user edit form:
* it is confirmed on its own, logged on its own, and cannot happen as a side effect of
* saving an unrelated setting. It is also the only way a role is ever granted at all -
* everything else about roles is synced from BusPro (see docs/user-roles.md).
*/
class ApproveRoleController extends AbstractController
{
public function __construct(private readonly UserDataHandler $userDataHandler)
{
}
#[Route('/admin/system/user/approve-role/{id}/{role}', name: 'app_admin_system_user_approve_role')]
#[IsGranted(UserVoter::EDIT, subject: 'user')]
public function index(User $user, string $role, Request $request): Response
{
// nothing but a role this user is actually nominated for, so a hand-crafted URL
// cannot grant one BusPro never claimed
$pendingRole = User::PENDING_ROLES[$role] ?? null;
if (null === $pendingRole || false === in_array($pendingRole, $user->getPendingRoles(), true)) {
throw $this->createNotFoundException('Für diese Rolle liegt keine Freischaltung vor');
}
if (true === $request->isMethod(Request::METHOD_POST)) {
// checked again by the handler, which is what catches a sync revoking the claim
// between opening the dialog and confirming it
if (false === $this->userDataHandler->approveRole($user, $role)) {
throw $this->createNotFoundException('Für diese Rolle liegt keine Freischaltung vor');
}
$this->addFlash('success', sprintf('Die Rolle %s wurde freigeschaltet', User::ROLES[$role]));
return new HxRedirectResponse($this->generateUrl('app_admin_system_user_edit', ['id' => $user->getId()]));
}
return $this->render('admin/system/user/modal_approve_role.html.twig', [
'user' => $user,
'roleLabel' => User::ROLES[$role],
]);
}
}
@@ -2,6 +2,7 @@
namespace App\Controller\Admin\System\User;
use App\Config\HouseCatalog;
use App\Entity\User;
use App\Form\UserType;
use App\Security\Voter\UserVoter;
@@ -18,6 +19,7 @@ class EditController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly HouseCatalog $houseCatalog,
) {
}
@@ -44,9 +46,21 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_admin_system_user_index');
}
// a code with no house behind it stays visible under its own name rather than
// vanishing from the page - it is what the user is actually restricted to
$houses = [];
foreach ($user->getHotelCodes() as $code) {
$houses[$code] = $this->houseCatalog->getName($code) ?? $code;
}
return $this->render('admin/system/user/edit.html.twig', [
'form' => $form,
'user' => $user,
'grantedRoles' => array_map(
static fn (string $role): string => User::ROLES[$role],
$user->getAssignedRoles(),
),
'houses' => $houses,
]);
}
}