feat: add supervisor role

This commit is contained in:
fromme committed 2026-10-05 17:10:02 +02:00
1 parent dcd85bb911
commit 3af326251d
22 files changed
+270 -103

No files matched your search

@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
* Retiring is a checkbox here rather than a confirmation modal of its own: it deletes nothing and
* is undone by ticking the box again. What it does leave behind -- bookings that still point at
* the resource -- is named in the flash, so the administrator can release them from
* /admin/bookings if they should not stand.
* /manage/bookings if they should not stand.
*/
#[IsGranted(Role::ADMIN)]
final class EditController extends AbstractController
+5 -5
View File
@@ -56,7 +56,7 @@ final class ReleaseController extends AbstractController
$onBehalf = !$booking->belongsTo($this->currentUser());
// The release itself is audited from BookingManager, which is also where the row still
// exists to be described. An administrator freeing somebody else's desk needs nothing
// exists to be described. A supervisor freeing somebody else's desk needs nothing
// extra here: the record carries the holder, and the actor comes from the token.
$this->manager->release($booking);
@@ -78,13 +78,13 @@ final class ReleaseController extends AbstractController
}
/**
* Releasing is the one thing an administrator may do to a booking that is not theirs -- a
* colleague calls in sick and their desk has to go back into the pool.
* Releasing is the one thing a supervisor may do to a booking that is not theirs -- a colleague
* calls in sick and their desk has to go back into the pool. Administrators inherit it.
*/
private function denyUnlessMayManage(Booking $booking): void
{
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.');
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::SUPERVISOR)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by a supervisor.');
}
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The landing screen: one tile per bookable kind, plus tiles for the bookings list and, for an
* administrator, the admin screens.
* The landing screen: one tile per bookable kind, plus tiles for the bookings list, for a
* supervisor the overview of everybody's bookings and, for an administrator, the admin screens.
*
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
* bookings list -- which is why the tiles are generated from ResourceType rather than written
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
namespace App\Controller\Manage\Booking;
use App\Form\Admin\BookingFilter;
use App\Form\Admin\BookingFilterType;
use App\Form\Manage\BookingFilter;
use App\Form\Manage\BookingFilterType;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Security\Role;
@@ -17,18 +17,22 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource
* on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool.
* What a supervisor can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a
* resource on somebody's behalf -- a colleague calls in sick and their desk should go back into
* the pool. Administrators reach it through role_hierarchy.
*
* The admin controllers are read-only. The two actions offered here post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit an
* administrator, so there is exactly one code path that removes a booking and it still goes
* Not under /admin, which is administrator-only throughout: supervisors may free bookings but not
* configure resources or codes.
*
* The manage controllers are read-only. The two actions offered here post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit
* supervisors, so there is exactly one code path that removes a booking and it still goes
* through BookingManager.
*
* The access_control rule on ^/admin is the real gate; the attribute here is so the guard
* The access_control rule on ^/manage is the real gate; the attribute here is so the guard
* survives a change to the route path.
*/
#[IsGranted(Role::ADMIN)]
#[IsGranted(Role::SUPERVISOR)]
final class IndexController extends AbstractController
{
/**
@@ -44,7 +48,7 @@ final class IndexController extends AbstractController
) {
}
#[Route('/admin/bookings', name: 'app_admin_booking', methods: ['GET'])]
#[Route('/manage/bookings', name: 'app_manage_booking', methods: ['GET'])]
public function index(Request $request): Response
{
$filter = new BookingFilter();
@@ -62,7 +66,7 @@ final class IndexController extends AbstractController
self::PER_PAGE,
);
return $this->render('admin/bookings.html.twig', [
return $this->render('manage/bookings.html.twig', [
'form' => $form,
'filter' => $filter,
'pagination' => $pagination,
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Admin\Booking;
namespace App\Controller\Manage\Booking;
use App\Controller\Traits\BoardResponseTrait;
use App\Entity\Resource;
@@ -16,13 +16,13 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The modal behind an occupied shape on the floor plan, for an administrator.
* The modal behind an occupied shape on the floor plan, for supervisors and administrators.
*
* Not app_booking_edit: a shape can hold several bookings on one day once part-day spans exist,
* so "the booking" has no single referent -- and editing reschedules, which an administrator
* so "the booking" has no single referent -- and editing reschedules, which a supervisor
* deliberately may not do to somebody else's booking.
*/
#[IsGranted(Role::ADMIN)]
#[IsGranted(Role::SUPERVISOR)]
final class SpotController extends AbstractController
{
use BoardResponseTrait;
@@ -31,12 +31,12 @@ final class SpotController extends AbstractController
{
}
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
#[Route('/manage/spot/{id}/{date}', name: 'app_manage_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
public function index(Request $request, Resource $resource, string $date): Response
{
$selected = DateParam::parseOrToday($date);
return $this->render('admin/_modal_spot.html.twig', [
return $this->render('manage/_modal_spot.html.twig', [
'resource' => $resource,
'date' => $selected,
// The board's span, so the modal lists exactly what the shape the user clicked shows.
+2 -2
View File
@@ -59,8 +59,8 @@ final class EndController extends AbstractController
private function denyUnlessMayManageSeries(BookingSeries $series): void
{
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.');
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::SUPERVISOR)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by a supervisor.');
}
}
}
@@ -13,14 +13,14 @@ trait ConfirmedActionTrait
{
/**
* Where to navigate after a confirmed action, or null when the caller is the board and wants
* its fragments swapped instead. The opener says which -- the admin screen sends
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
* bookings.
* its fragments swapped instead. The opener says which -- the overview of everybody's
* bookings sends ?return=manage so freeing somebody else's desk does not drop a supervisor on
* their own bookings.
*/
private function returnRoute(Request $request): ?string
{
return match ($request->query->get('return')) {
'admin' => 'app_admin_booking',
'manage' => 'app_manage_booking',
'bookings' => 'app_booking',
default => null,
};
+12 -4
View File
@@ -9,10 +9,10 @@ use App\Security\Role;
/**
* Who may read an access code.
*
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy
* among several plausible ones -- a code for the reception desk or for the cleaning company is
* the obvious next case -- and a third case here costs a case in the match below, where a third
* value of a boolean costs a migration.
* Deliberately an enum rather than a boolean column: "admin only" was one policy among several
* plausible ones -- supervisors were the next, a code for the reception desk or for the cleaning
* company the obvious ones after that -- and another case here costs a case in the match below,
* where another value of a boolean costs a migration.
*
* This is also what the list shows under "Für wen": the access decision and the caption are one
* value, so there is no second, prose field describing an audience that the filter does not
@@ -23,6 +23,9 @@ enum CodeVisibility: string
/** Every signed-in user, which in this application means everyone who works here. */
case EVERYONE = 'everyone';
/** Supervisors, and administrators with them -- e.g. the office of the management. */
case SUPERVISOR = 'supervisor';
/** Administrators only -- a code the whole staff has no business reading. */
case ADMIN = 'admin';
@@ -30,6 +33,7 @@ enum CodeVisibility: string
{
return match ($this) {
self::EVERYONE => 'Alle Mitarbeitenden',
self::SUPERVISOR => 'Leitung',
self::ADMIN => 'Administration',
};
}
@@ -44,12 +48,16 @@ enum CodeVisibility: string
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
* role of its own: reaching the page at all already required one.
*
* These are the stored roles, which role_hierarchy does not expand -- so an administrator's
* claim to the supervisor codes is spelt out here rather than inherited.
*
* @param list<string> $roles
*/
public function isReadableBy(array $roles): bool
{
return match ($this) {
self::EVERYONE => true,
self::SUPERVISOR => \in_array(Role::SUPERVISOR, $roles, true) || \in_array(Role::ADMIN, $roles, true),
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
};
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\Form\Admin;
namespace App\Form\Manage;
use App\Config\BookingHorizon;
use App\Entity\User;
use App\Enum\ResourceType;
/**
* What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters
* What the overview of all bookings is narrowed by. A plain DTO bound to a GET form, so the filters
* live in the URL and a filtered view can be linked to or bookmarked.
*/
final class BookingFilter
@@ -24,7 +24,7 @@ final class BookingFilter
public function __construct()
{
// Today through the booking horizon: what an administrator can still do something about.
// Today through the booking horizon: what a supervisor can still do something about.
// Past bookings are history and are not actionable, so they are not the default view.
$this->from = new \DateTimeImmutable('today');
$this->to = BookingHorizon::latest();
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Form\Admin;
namespace App\Form\Manage;
use App\Entity\User;
use App\Enum\ResourceType;
+3 -3
View File
@@ -9,7 +9,7 @@ use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Form\Admin\BookingFilter;
use App\Form\Manage\BookingFilter;
use App\Model\TimeRange;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
@@ -210,10 +210,10 @@ class BookingRepository extends ServiceEntityRepository
}
/**
* Every booking in a window, across everybody -- what the admin list renders.
* Every booking in a window, across everybody -- what /manage/bookings renders.
*
* findForUserInRange() is the same query with a mandatory user; this one keeps it optional
* and eager-selects the holder as well, because the admin list names a different person on
* and eager-selects the holder as well, because the overview names a different person on
* every row and would otherwise fire a query per booking.
*
* Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column
+2 -2
View File
@@ -30,9 +30,9 @@ class BookingSeriesRepository extends ServiceEntityRepository
/**
* Every series still running on the given day, optionally narrowed to one person or one kind
* of resource. The admin list is the caller that needs it across everybody.
* of resource. The overview at /manage/bookings is the caller that needs it across everybody.
*
* The user is eager-selected because the admin list names the holder of every row -- without
* The user is eager-selected because the overview names the holder of every row -- without
* it the page fires a query per series.
*
* @return list<BookingSeries>
+12 -3
View File
@@ -20,13 +20,21 @@ final class Role
/** Derived on myep from the account's email domain: "works here". */
public const EMPLOYEE = 'ROLE_EMPLOYEE';
/**
* Frees other people's bookings and reads the codes meant for supervisors, but does not
* configure anything -- the admin area stays ROLE_ADMIN's. Nominated by the CRM and approved
* on myep like ROLE_ADMIN. ROLE_ADMIN inherits it through role_hierarchy, so a check for this
* role is a check for "supervisor or above".
*/
public const SUPERVISOR = 'ROLE_SUPERVISOR';
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
public const ADMIN = 'ROLE_ADMIN';
/**
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused
* at login rather than let in without access, so they get an explanation instead of an
* empty screen they cannot act on.
* The roles that entitle someone to use ephub at all. Someone who holds none of them is
* refused at login rather than let in without access, so they get an explanation instead of
* an empty screen they cannot act on.
*
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep
* gates its own authorization endpoint on the same list and shows an explanation page
@@ -39,6 +47,7 @@ final class Role
*/
public const ELIGIBLE = [
self::EMPLOYEE,
self::SUPERVISOR,
self::ADMIN,
];
+1 -1
View File
@@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
* The acting user is NOT passed in. AuditActorProcessor takes it from the security token and
* puts it in `extra.actor`, so no call site can log the wrong one. What the context carries is
* the subject -- `holder` for a booking -- and a record whose holder is not its actor is an
* administrator having acted for somebody else.
* supervisor or administrator having acted for somebody else.
*
* Level is info throughout: an audit record is a fact, not a problem. Something that *is* a
* problem belongs in the framework log, which is what the default channel is for -- a refused