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

+14 -4
View File
@@ -7,6 +7,13 @@ security:
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep, # No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
# which owns the credentials; ephub only ever sees a userinfo response. # which owns the credentials; ephub only ever sees a userinfo response.
# An administrator can do everything a supervisor can, so the "may act on other people's
# bookings" checks ask for ROLE_SUPERVISOR alone. Only isGranted() sees this:
# User::getRoles() still returns the stored roles, which is why CodeVisibility names
# ROLE_ADMIN explicitly.
role_hierarchy:
ROLE_ADMIN: [ROLE_SUPERVISOR]
providers: providers:
app_user_provider: app_user_provider:
entity: entity:
@@ -46,8 +53,11 @@ security:
# Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be # Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be
# open to everybody. Order is the whole guard here. # open to everybody. Order is the whole guard here.
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https } - { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
# Either role opens the app -- access_control treats a list of roles as an OR. Not # Everybody's bookings, for supervisors and (through role_hierarchy) administrators. Kept
# out of ^/admin so that everything under it stays administrator-only.
- { path: ^/manage, roles: ROLE_SUPERVISOR, requires_channel: https }
# Any of these opens the app -- access_control treats a list of roles as an OR. Not
# ROLE_USER: myep authenticates far more people than work here, and someone who holds # ROLE_USER: myep authenticates far more people than work here, and someone who holds
# neither of these is refused at login with an explanation rather than let in to an # none of these is refused at login with an explanation rather than let in to an empty
# empty screen. # screen. Keep in step with Role::ELIGIBLE.
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN], requires_channel: https } - { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_SUPERVISOR, ROLE_ADMIN], requires_channel: https }
@@ -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 * 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 * 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 * 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)] #[IsGranted(Role::ADMIN)]
final class EditController extends AbstractController final class EditController extends AbstractController
+5 -5
View File
@@ -56,7 +56,7 @@ final class ReleaseController extends AbstractController
$onBehalf = !$booking->belongsTo($this->currentUser()); $onBehalf = !$booking->belongsTo($this->currentUser());
// The release itself is audited from BookingManager, which is also where the row still // 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. // extra here: the record carries the holder, and the actor comes from the token.
$this->manager->release($booking); $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 * Releasing is the one thing a supervisor may do to a booking that is not theirs -- a colleague
* colleague calls in sick and their desk has to go back into the pool. * calls in sick and their desk has to go back into the pool. Administrators inherit it.
*/ */
private function denyUnlessMayManage(Booking $booking): void private function denyUnlessMayManage(Booking $booking): void
{ {
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) { 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 an administrator.'); 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; use Symfony\Component\Routing\Attribute\Route;
/** /**
* The landing screen: one tile per bookable kind, plus tiles for the bookings list and, for an * The landing screen: one tile per bookable kind, plus tiles for the bookings list, for a
* administrator, the admin screens. * 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 * 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 * bookings list -- which is why the tiles are generated from ResourceType rather than written
@@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Admin\Booking; namespace App\Controller\Manage\Booking;
use App\Form\Admin\BookingFilter; use App\Form\Manage\BookingFilter;
use App\Form\Admin\BookingFilterType; use App\Form\Manage\BookingFilterType;
use App\Repository\BookingRepository; use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository; use App\Repository\BookingSeriesRepository;
use App\Security\Role; use App\Security\Role;
@@ -17,18 +17,22 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource * What a supervisor can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a
* on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool. * 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 * Not under /admin, which is administrator-only throughout: supervisors may free bookings but not
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit an * configure resources or codes.
* administrator, so there is exactly one code path that removes a booking and it still goes *
* 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. * 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. * survives a change to the route path.
*/ */
#[IsGranted(Role::ADMIN)] #[IsGranted(Role::SUPERVISOR)]
final class IndexController extends AbstractController 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 public function index(Request $request): Response
{ {
$filter = new BookingFilter(); $filter = new BookingFilter();
@@ -62,7 +66,7 @@ final class IndexController extends AbstractController
self::PER_PAGE, self::PER_PAGE,
); );
return $this->render('admin/bookings.html.twig', [ return $this->render('manage/bookings.html.twig', [
'form' => $form, 'form' => $form,
'filter' => $filter, 'filter' => $filter,
'pagination' => $pagination, 'pagination' => $pagination,
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Controller\Admin\Booking; namespace App\Controller\Manage\Booking;
use App\Controller\Traits\BoardResponseTrait; use App\Controller\Traits\BoardResponseTrait;
use App\Entity\Resource; use App\Entity\Resource;
@@ -16,13 +16,13 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; 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, * 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. * deliberately may not do to somebody else's booking.
*/ */
#[IsGranted(Role::ADMIN)] #[IsGranted(Role::SUPERVISOR)]
final class SpotController extends AbstractController final class SpotController extends AbstractController
{ {
use BoardResponseTrait; 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 public function index(Request $request, Resource $resource, string $date): Response
{ {
$selected = DateParam::parseOrToday($date); $selected = DateParam::parseOrToday($date);
return $this->render('admin/_modal_spot.html.twig', [ return $this->render('manage/_modal_spot.html.twig', [
'resource' => $resource, 'resource' => $resource,
'date' => $selected, 'date' => $selected,
// The board's span, so the modal lists exactly what the shape the user clicked shows. // 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 private function denyUnlessMayManageSeries(BookingSeries $series): void
{ {
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) { 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 an administrator.'); 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 * 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 * its fragments swapped instead. The opener says which -- the overview of everybody's
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own * bookings sends ?return=manage so freeing somebody else's desk does not drop a supervisor on
* bookings. * their own bookings.
*/ */
private function returnRoute(Request $request): ?string private function returnRoute(Request $request): ?string
{ {
return match ($request->query->get('return')) { return match ($request->query->get('return')) {
'admin' => 'app_admin_booking', 'manage' => 'app_manage_booking',
'bookings' => 'app_booking', 'bookings' => 'app_booking',
default => null, default => null,
}; };
+12 -4
View File
@@ -9,10 +9,10 @@ use App\Security\Role;
/** /**
* Who may read an access code. * Who may read an access code.
* *
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy * Deliberately an enum rather than a boolean column: "admin only" was one policy among several
* among several plausible ones -- a code for the reception desk or for the cleaning company is * plausible ones -- supervisors were the next, a code for the reception desk or for the cleaning
* the obvious next case -- and a third case here costs a case in the match below, where a third * company the obvious ones after that -- and another case here costs a case in the match below,
* value of a boolean costs a migration. * 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 * 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 * 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. */ /** Every signed-in user, which in this application means everyone who works here. */
case EVERYONE = 'everyone'; 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. */ /** Administrators only -- a code the whole staff has no business reading. */
case ADMIN = 'admin'; case ADMIN = 'admin';
@@ -30,6 +33,7 @@ enum CodeVisibility: string
{ {
return match ($this) { return match ($this) {
self::EVERYONE => 'Alle Mitarbeitenden', self::EVERYONE => 'Alle Mitarbeitenden',
self::SUPERVISOR => 'Leitung',
self::ADMIN => 'Administration', 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 * 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. * 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 * @param list<string> $roles
*/ */
public function isReadableBy(array $roles): bool public function isReadableBy(array $roles): bool
{ {
return match ($this) { return match ($this) {
self::EVERYONE => true, 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), self::ADMIN => \in_array(Role::ADMIN, $roles, true),
}; };
} }
@@ -2,14 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Form\Admin; namespace App\Form\Manage;
use App\Config\BookingHorizon; use App\Config\BookingHorizon;
use App\Entity\User; use App\Entity\User;
use App\Enum\ResourceType; 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. * live in the URL and a filtered view can be linked to or bookmarked.
*/ */
final class BookingFilter final class BookingFilter
@@ -24,7 +24,7 @@ final class BookingFilter
public function __construct() 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. // Past bookings are history and are not actionable, so they are not the default view.
$this->from = new \DateTimeImmutable('today'); $this->from = new \DateTimeImmutable('today');
$this->to = BookingHorizon::latest(); $this->to = BookingHorizon::latest();
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Form\Admin; namespace App\Form\Manage;
use App\Entity\User; use App\Entity\User;
use App\Enum\ResourceType; use App\Enum\ResourceType;
+3 -3
View File
@@ -9,7 +9,7 @@ use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\User; use App\Entity\User;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Form\Admin\BookingFilter; use App\Form\Manage\BookingFilter;
use App\Model\TimeRange; use App\Model\TimeRange;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder; 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 * 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. * 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 * 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 * 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. * it the page fires a query per series.
* *
* @return list<BookingSeries> * @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". */ /** Derived on myep from the account's email domain: "works here". */
public const EMPLOYEE = 'ROLE_EMPLOYEE'; 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. */ /** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
public const ADMIN = 'ROLE_ADMIN'; public const ADMIN = 'ROLE_ADMIN';
/** /**
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused * The roles that entitle someone to use ephub at all. Someone who holds none of them is
* at login rather than let in without access, so they get an explanation instead of an * refused at login rather than let in without access, so they get an explanation instead of
* empty screen they cannot act on. * an empty screen they cannot act on.
* *
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep * **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 * gates its own authorization endpoint on the same list and shows an explanation page
@@ -39,6 +47,7 @@ final class Role
*/ */
public const ELIGIBLE = [ public const ELIGIBLE = [
self::EMPLOYEE, self::EMPLOYEE,
self::SUPERVISOR,
self::ADMIN, 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 * 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 * 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 * 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 * 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 * problem belongs in the framework log, which is what the default channel is for -- a refused
+9 -8
View File
@@ -29,14 +29,15 @@
{% endif %} {% endif %}
{% set interactive = not is_past and spot.actionable %} {% set interactive = not is_past and spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot {# A supervisor (and so an administrator) may free somebody else's desk -- a colleague calls in
should go back into the pool. Colours are untouched: MINE still means mine, and only sick and the spot should go back into the pool. Colours are untouched: MINE still means
clickability widens. Past days stay inert for admins too; there is no past-write path. mine, and only clickability widens. Past days stay inert for them too; there is no
past-write path.
Only a card that is not already actionable: a partly booked one still opens the create Only a card that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach modal, because taking the free window is the more useful act there and a supervisor can
that booking from /admin/bookings anyway. #} reach that booking from /manage/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %} {% set manageable = not is_past and not interactive and is_granted('ROLE_SUPERVISOR') and spot.bookings is not empty %}
{# One line per booking, then the window still free if there is one -- that last line is the {# One line per booking, then the window still free if there is one -- that last line is the
reason a partly booked card is worth clicking, so it earns its place. #} reason a partly booked card is worth clicking, so it earns its place. #}
@@ -78,9 +79,9 @@
hx-target="body" hx-target="body"
hx-swap="beforeend" hx-swap="beforeend"
{%- elseif manageable %} {%- elseif manageable %}
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin {# Not app_booking_edit: a card can hold several bookings on one day, and a supervisor
releases rather than reschedules. The modal lists them and frees one. #} releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_manage_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body" hx-target="body"
hx-swap="beforeend" hx-swap="beforeend"
{%- endif %}> {%- endif %}>
+7 -4
View File
@@ -62,11 +62,12 @@
{% endif %} {% endif %}
{# {#
The header is deliberately not navigation, so the dashboard is the only place an The header is deliberately not navigation, so the dashboard is the only place the
admin screen can be reached from. overview and the admin screens can be reached from. ROLE_SUPERVISOR includes
administrators through role_hierarchy.
#} #}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_SUPERVISOR') %}
<a href="{{ path('app_admin_booking') }}" <a href="{{ path('app_manage_booking') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50"> class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2"> <div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }} {{ ux_icon('lucide:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
@@ -76,7 +77,9 @@
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben. Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
</p> </p>
</a> </a>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('app_admin_resource') }}" <a href="{{ path('app_admin_resource') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50"> class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2"> <div class="flex items-center gap-3 pb-2">
@@ -1,10 +1,10 @@
{# {#
What an administrator sees when clicking an occupied shape on the floor plan. What a supervisor (or an administrator) sees when clicking an occupied shape on the floor plan.
Not the edit modal: a shape can hold several bookings on one day once part-day spans exist, Not the edit modal: a shape can hold several bookings on one day once part-day spans exist,
so "the booking" has no single referent -- and an administrator releases rather than so "the booking" has no single referent -- and a supervisor releases rather than reschedules.
reschedules. Each row posts to the ordinary app_booking_release route, whose ownership check Each row posts to the ordinary app_booking_release route, whose ownership check admits
admits an admin, so there is still one code path that removes a booking. supervisors, so there is still one code path that removes a booking.
#} #}
{% extends 'htmx_modal.html.twig' %} {% extends 'htmx_modal.html.twig' %}
@@ -4,7 +4,7 @@
Filtering is a plain GET form -- the house rule hands navigation to the browser. The two Filtering is a plain GET form -- the house rule hands navigation to the browser. The two
destructive actions open the shared confirmation modal via htmx and then navigate back here, destructive actions open the shared confirmation modal via htmx and then navigate back here,
which is what ?return=admin says; without it a confirmed release swaps board fragments this which is what ?return=manage says; without it a confirmed release swaps board fragments this
page does not have. page does not have.
The appointment table is paginated; the series table is not, because a company has a handful of The appointment table is paginated; the series table is not, because a company has a handful of
@@ -28,7 +28,7 @@
{{ form_row(form.user) }} {{ form_row(form.user) }}
</div> </div>
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<a class="button button--small button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a> <a class="button button--small button--neutral" href="{{ path('app_manage_booking') }}">Zurücksetzen</a>
<button type="submit" class="button button--small button--primary">Filtern</button> <button type="submit" class="button button--small button--primary">Filtern</button>
</div> </div>
{{ form_end(form) }} {{ form_end(form) }}
@@ -58,7 +58,7 @@
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td> <td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
<td class="text-right"> <td class="text-right">
<button type="button" class="button button--small button--warning" <button type="button" class="button button--small button--warning"
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}" hx-get="{{ path('app_series_end', { id: entry.id, return: 'manage' }) }}"
hx-target="body" hx-target="body"
hx-swap="beforeend"> hx-swap="beforeend">
Wiederholung beenden Wiederholung beenden
@@ -115,7 +115,7 @@
<a class="button button--small button--neutral" <a class="button button--small button--neutral"
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a> href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
<button type="button" class="button button--small button--warning" <button type="button" class="button button--small button--warning"
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}" hx-get="{{ path('app_booking_release', { id: booking.id, return: 'manage' }) }}"
hx-target="body" hx-target="body"
hx-swap="beforeend"> hx-swap="beforeend">
Freigeben Freigeben
@@ -82,6 +82,46 @@ class AccessCodeControllerTest extends WebTestCase
self::assertStringContainsString('Administration', $card->text()); self::assertStringContainsString('Administration', $card->text());
} }
/**
* A supervisor code is read by supervisors and by administrators, who hold no
* ROLE_SUPERVISOR of their own -- role_hierarchy does not reach the stored roles the query
* filters on. And a supervisor is not an administrator: the admin-only code stays out of
* their response.
*/
public function testASupervisorCodeIsReadBySupervisorsAndAdminsOnly(): void
{
$this->code('Management office', '5555', visibility: CodeVisibility::SUPERVISOR);
$this->code('Serverraum', '9999', visibility: CodeVisibility::ADMIN);
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/access-codes');
self::assertStringNotContainsString('5555', (string) $this->client->getResponse()->getContent());
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$card = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
self::assertCount(1, $card);
self::assertSame('5555', $card->filter('[data-code]')->text());
// The visibility caption the page shows, which is German like all user-facing text.
self::assertStringContainsString('Leitung', $card->text());
self::assertStringNotContainsString('9999', (string) $this->client->getResponse()->getContent());
$this->client->loginUser($this->user('[email protected]', ['ROLE_ADMIN']));
self::assertCount(2, $this->client->request('GET', '/access-codes')->filter('[data-access-code]'));
}
/**
* A supervisor reads codes but does not manage them.
*/
public function testASupervisorIsNotOfferedTheTable(): void
{
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$crawler = $this->client->request('GET', '/access-codes');
self::assertCount(0, $crawler->filter('a[href="/admin/access-codes"]'));
}
/** /**
* Rotating a code. Only the one in force is shown: its predecessor is history, and its * Rotating a code. Only the one in force is shown: its predecessor is history, and its
* successor does not open anything yet -- both would only be another number to mistype. * successor does not open anything yet -- both would only be another number to mistype.
@@ -2,24 +2,29 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\Controller\Admin; namespace App\Tests\Controller\Manage;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\User; use App\Entity\User;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
* The admin area, and the widened ownership checks it leans on. * The overview of everybody's bookings, and the widened ownership checks it leans on. Most tests
* sign in as an administrator, who reaches it through role_hierarchy; the supervisor ones below
* pin down that the role itself is enough, and that it reaches no further than this.
* *
* The test that matters most here is the negative one: releasing somebody else's booking must * The test that matters most here is the negative one: releasing somebody else's booking must
* still be refused for an ordinary employee. Everything else on this screen is a list. * still be refused for an ordinary employee. Everything else on this screen is a list.
*/ */
class BookingAdminControllerTest extends WebTestCase class BookingManageControllerTest extends WebTestCase
{ {
private KernelBrowser $client; private KernelBrowser $client;
private EntityManagerInterface $em; private EntityManagerInterface $em;
@@ -38,21 +43,21 @@ class BookingAdminControllerTest extends WebTestCase
parent::tearDown(); parent::tearDown();
} }
public function testAnEmployeeCannotReachTheAdminList(): void public function testAnEmployeeCannotReachTheOverview(): void
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/bookings'); $this->client->request('GET', '/manage/bookings');
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode()); self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
} }
public function testTheAdminListShowsOtherPeoplesBookings(): void public function testTheOverviewShowsOtherPeoplesBookings(): void
{ {
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1')); $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings'); $crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text()); self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
@@ -66,15 +71,15 @@ class BookingAdminControllerTest extends WebTestCase
// The two-step flow a browser walks: the list opens the confirmation modal, the modal // The two-step flow a browser walks: the list opens the confirmation modal, the modal
// carries the token and posts back to the same URI. // carries the token and posts back to the same URI.
$crawler = $this->client->request('GET', '/admin/bookings'); $crawler = $this->client->request('GET', '/manage/bookings');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id))); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=manage"]', $id)));
$uri = \sprintf('/bookings/%d/release?return=admin', $id); $uri = \sprintf('/bookings/%d/release?return=manage', $id);
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
// ?return= means a genuine navigation rather than a board swap: this page has no board // ?return= means a genuine navigation rather than a board swap: this page has no board
// fragments, so HX-Redirect is the honest answer. // fragments, so HX-Redirect is the honest answer.
self::assertSame('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect')); self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear(); $this->em->clear();
self::assertNull($this->em->getRepository(Booking::class)->find($id)); self::assertNull($this->em->getRepository(Booking::class)->find($id));
} }
@@ -95,7 +100,7 @@ class BookingAdminControllerTest extends WebTestCase
// The ownership guard runs before the CSRF one, so asserting on its message is what // The ownership guard runs before the CSRF one, so asserting on its message is what
// distinguishes "refused because it is not yours" from "refused because of the token". // distinguishes "refused because it is not yours" from "refused because of the token".
self::assertStringContainsString( self::assertStringContainsString(
'released by the person holding it or by an administrator', 'released by the person holding it or by a supervisor',
(string) $this->client->getResponse()->getContent(), (string) $this->client->getResponse()->getContent(),
); );
self::assertNotNull($this->em->getRepository(Booking::class)->find($id)); self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
@@ -122,7 +127,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2')); $this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId()); $crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId());
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text(); $text = $crawler->filter('tbody')->text();
@@ -136,7 +141,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING)); $this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?type=parking'); $crawler = $this->client->request('GET', '/manage/bookings?type=parking');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text(); $text = $crawler->filter('tbody')->text();
@@ -156,11 +161,11 @@ class BookingAdminControllerTest extends WebTestCase
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date='.$date); $crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]')); self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/manage/spot/"]'));
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/book/desk?date='.$date); $crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date))); self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
} }
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
@@ -171,7 +176,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request( $crawler = $this->client->request(
'GET', 'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')), \sprintf('/manage/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'], server: ['HTTP_HX-Request' => 'true'],
); );
@@ -192,7 +197,7 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request( $crawler = $this->client->request(
'GET', 'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')), \sprintf('/manage/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'], server: ['HTTP_HX-Request' => 'true'],
); );
@@ -201,13 +206,90 @@ class BookingAdminControllerTest extends WebTestCase
self::assertStringNotContainsString('Carl Conrad', $text); self::assertStringNotContainsString('Carl Conrad', $text);
} }
public function testTheDashboardTileIsAdminOnly(): void public function testTheDashboardTileIsForSupervisorsAndAdminsOnly(): void
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]')); self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]'));
$this->client->loginUser($this->supervisor());
$crawler = $this->client->request('GET', '/');
self::assertCount(1, $crawler->filter('a[href="/manage/bookings"]'));
// The tile beside it leads into /admin, which a supervisor cannot enter.
self::assertCount(0, $crawler->filter('a[href="/admin/resources"]'));
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]')); self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]'));
}
public function testASupervisorSeesTheOverview(): void
{
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->client->loginUser($this->supervisor());
$crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
}
public function testASupervisorReleasesABookingOnBehalfOfSomebodyElse(): void
{
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$id = $booking->getId();
$this->client->loginUser($this->supervisor());
$uri = \sprintf('/bookings/%d/release?return=manage', $id);
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->getRepository(Booking::class)->find($id));
}
public function testASupervisorEndsSomebodyElsesSeries(): void
{
$series = new BookingSeries(
$this->user('[email protected]', 'Bea Berger'),
$this->resource('D1'),
RecurrenceRule::DAILY,
new \DateTimeImmutable('+1 day'),
new \DateTimeImmutable('+2 days'),
TimeRange::wholeDay(),
);
$this->em->persist($series);
$this->em->flush();
$this->client->loginUser($this->supervisor());
$uri = \sprintf('/series/%d/end?return=manage', $series->getId());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
}
public function testAnOccupiedDeskIsClickableForASupervisor(): void
{
$desk = $this->resource('D1');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
$this->client->loginUser($this->supervisor());
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
}
/**
* A supervisor frees bookings; configuring what can be booked and who reads which code stays
* with the administrators.
*/
public function testASupervisorCannotEnterTheAdminArea(): void
{
$this->client->loginUser($this->supervisor());
foreach (['/admin/resources', '/admin/access-codes'] as $path) {
$this->client->request('GET', $path);
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $path);
}
} }
/** /**
@@ -222,16 +304,16 @@ class BookingAdminControllerTest extends WebTestCase
$crawler = $this->client->request( $crawler = $this->client->request(
'GET', 'GET',
\sprintf('/bookings/%d/release?return=admin', $booking->getId()), \sprintf('/bookings/%d/release?return=manage', $booking->getId()),
server: ['HTTP_HX-Request' => 'true'], server: ['HTTP_HX-Request' => 'true'],
); );
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$modal = $crawler->filter('#htmx-confirmation-modal'); $modal = $crawler->filter('#htmx-confirmation-modal');
self::assertStringContainsString('Bea Berger', $modal->text()); self::assertStringContainsString('Bea Berger', $modal->text());
// Posts back to the same URI, which is what carries ?return=admin through the round trip. // Posts back to the same URI, which is what carries ?return=manage through the round trip.
self::assertSame( self::assertSame(
\sprintf('https://localhost/bookings/%d/release?return=admin', $booking->getId()), \sprintf('https://localhost/bookings/%d/release?return=manage', $booking->getId()),
$modal->filter('[hx-post]')->attr('hx-post'), $modal->filter('[hx-post]')->attr('hx-post'),
); );
} }
@@ -250,12 +332,12 @@ class BookingAdminControllerTest extends WebTestCase
} }
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings'); $crawler = $this->client->request('GET', '/manage/bookings');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertCount(25, $crawler->filter('tbody tr')); self::assertCount(25, $crawler->filter('tbody tr'));
$crawler = $this->client->request('GET', '/admin/bookings?page=2'); $crawler = $this->client->request('GET', '/manage/bookings?page=2');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertCount(5, $crawler->filter('tbody tr')); self::assertCount(5, $crawler->filter('tbody tr'));
@@ -272,7 +354,7 @@ class BookingAdminControllerTest extends WebTestCase
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2')); $this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin()); $this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId().'&sort=u.name&direction=desc'); $crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text(); $text = $crawler->filter('tbody')->text();
@@ -299,6 +381,11 @@ class BookingAdminControllerTest extends WebTestCase
return $booking; return $booking;
} }
private function supervisor(string $email = '[email protected]', string $name = 'Sam Supervisor'): User
{
return $this->user($email, $name, ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']);
}
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
{ {
return $this->user($email, $name, ['ROLE_ADMIN']); return $this->user($email, $name, ['ROLE_ADMIN']);
+8 -3
View File
@@ -9,14 +9,18 @@ use PHPUnit\Framework\TestCase;
class RoleTest extends TestCase class RoleTest extends TestCase
{ {
public function testBothEligibleRolesAreRecognised(): void public function testAllEligibleRolesAreRecognised(): void
{ {
self::assertSame([Role::EMPLOYEE, Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE'])); self::assertSame(
[Role::EMPLOYEE, Role::SUPERVISOR, Role::ADMIN],
Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_SUPERVISOR', 'ROLE_EMPLOYEE']),
);
} }
public function testEitherRoleOnItsOwnIsEnough(): void public function testEachRoleOnItsOwnIsEnough(): void
{ {
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE'])); self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
self::assertSame([Role::SUPERVISOR], Role::eligibleOnly(['ROLE_SUPERVISOR']));
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN'])); self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
} }
@@ -35,6 +39,7 @@ class RoleTest extends TestCase
{ {
// myep strips these before exporting, but that is its policy and not our guarantee. // myep strips these before exporting, but that is its policy and not our guarantee.
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING'])); self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
self::assertSame([], Role::eligibleOnly(['ROLE_SUPERVISOR_PENDING']));
} }
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void