feat: add supervisor role
This commit is contained in:
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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+16
-12
@@ -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,
|
||||
+6
-6
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user