feat: add supervisor role
This commit is contained in:
1 parent
dcd85bb911
commit
3af326251d
22 files changed
+270
-103
No files matched your search
@@ -7,6 +7,13 @@ security:
|
||||
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
|
||||
# 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:
|
||||
app_user_provider:
|
||||
entity:
|
||||
@@ -46,8 +53,11 @@ security:
|
||||
# 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.
|
||||
- { 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
|
||||
# neither of these is refused at login with an explanation rather than let in to an
|
||||
# empty screen.
|
||||
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN], requires_channel: https }
|
||||
# none of these is refused at login with an explanation rather than let in to an empty
|
||||
# screen. Keep in step with Role::ELIGIBLE.
|
||||
- { 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
|
||||
* 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
|
||||
|
||||
@@ -29,14 +29,15 @@
|
||||
{% endif %}
|
||||
|
||||
{% set interactive = not is_past and spot.actionable %}
|
||||
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
|
||||
should go back into the pool. Colours are untouched: MINE still means mine, and only
|
||||
clickability widens. Past days stay inert for admins too; there is no past-write path.
|
||||
{# A supervisor (and so an administrator) may free somebody else's desk -- a colleague calls in
|
||||
sick and the spot should go back into the pool. Colours are untouched: MINE still means
|
||||
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
|
||||
modal, because taking the free window is the more useful act there and an admin can reach
|
||||
that booking from /admin/bookings anyway. #}
|
||||
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
|
||||
modal, because taking the free window is the more useful act there and a supervisor can
|
||||
reach that booking from /manage/bookings anyway. #}
|
||||
{% 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
|
||||
reason a partly booked card is worth clicking, so it earns its place. #}
|
||||
@@ -78,9 +79,9 @@
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- 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. #}
|
||||
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-swap="beforeend"
|
||||
{%- endif %}>
|
||||
|
||||
@@ -62,11 +62,12 @@
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
The header is deliberately not navigation, so the dashboard is the only place an
|
||||
admin screen can be reached from.
|
||||
The header is deliberately not navigation, so the dashboard is the only place the
|
||||
overview and the admin screens can be reached from. ROLE_SUPERVISOR includes
|
||||
administrators through role_hierarchy.
|
||||
#}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="{{ path('app_admin_booking') }}"
|
||||
{% if is_granted('ROLE_SUPERVISOR') %}
|
||||
<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">
|
||||
<div class="flex items-center gap-3 pb-2">
|
||||
{{ 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.
|
||||
</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<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">
|
||||
<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,
|
||||
so "the booking" has no single referent -- and an administrator releases rather than
|
||||
reschedules. Each row posts to the ordinary app_booking_release route, whose ownership check
|
||||
admits an admin, so there is still one code path that removes a booking.
|
||||
so "the booking" has no single referent -- and a supervisor releases rather than reschedules.
|
||||
Each row posts to the ordinary app_booking_release route, whose ownership check admits
|
||||
supervisors, so there is still one code path that removes a booking.
|
||||
#}
|
||||
{% 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
|
||||
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.
|
||||
|
||||
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) }}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
@@ -58,7 +58,7 @@
|
||||
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
|
||||
<td class="text-right">
|
||||
<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-swap="beforeend">
|
||||
Wiederholung beenden
|
||||
@@ -115,7 +115,7 @@
|
||||
<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>
|
||||
<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-swap="beforeend">
|
||||
Freigeben
|
||||
@@ -82,6 +82,46 @@ class AccessCodeControllerTest extends WebTestCase
|
||||
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
|
||||
* successor does not open anything yet -- both would only be another number to mistype.
|
||||
|
||||
+114
-27
@@ -2,24 +2,29 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
namespace App\Tests\Controller\Manage;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\User;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\TimeRange;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
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
|
||||
* 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 EntityManagerInterface $em;
|
||||
@@ -38,21 +43,21 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAnEmployeeCannotReachTheAdminList(): void
|
||||
public function testAnEmployeeCannotReachTheOverview(): void
|
||||
{
|
||||
$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());
|
||||
}
|
||||
|
||||
public function testTheAdminListShowsOtherPeoplesBookings(): void
|
||||
public function testTheOverviewShowsOtherPeoplesBookings(): void
|
||||
{
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
||||
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
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
|
||||
// carries the token and posts back to the same URI.
|
||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id)));
|
||||
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||
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']);
|
||||
|
||||
// ?return= means a genuine navigation rather than a board swap: this page has no board
|
||||
// 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();
|
||||
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
|
||||
// distinguishes "refused because it is not yours" from "refused because of the token".
|
||||
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(),
|
||||
);
|
||||
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->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();
|
||||
$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->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
|
||||
$crawler = $this->client->request('GET', '/manage/bookings?type=parking');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$text = $crawler->filter('tbody')->text();
|
||||
@@ -156,11 +161,11 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$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());
|
||||
$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
|
||||
@@ -171,7 +176,7 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'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'],
|
||||
);
|
||||
|
||||
@@ -192,7 +197,7 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'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'],
|
||||
);
|
||||
|
||||
@@ -201,13 +206,90 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
self::assertStringNotContainsString('Carl Conrad', $text);
|
||||
}
|
||||
|
||||
public function testTheDashboardTileIsAdminOnly(): void
|
||||
public function testTheDashboardTileIsForSupervisorsAndAdminsOnly(): void
|
||||
{
|
||||
$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());
|
||||
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(
|
||||
'GET',
|
||||
\sprintf('/bookings/%d/release?return=admin', $booking->getId()),
|
||||
\sprintf('/bookings/%d/release?return=manage', $booking->getId()),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$modal = $crawler->filter('#htmx-confirmation-modal');
|
||||
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(
|
||||
\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'),
|
||||
);
|
||||
}
|
||||
@@ -250,12 +332,12 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
}
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
||||
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
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::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->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();
|
||||
$text = $crawler->filter('tbody')->text();
|
||||
@@ -299,6 +381,11 @@ class BookingAdminControllerTest extends WebTestCase
|
||||
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
|
||||
{
|
||||
return $this->user($email, $name, ['ROLE_ADMIN']);
|
||||
@@ -9,14 +9,18 @@ use PHPUnit\Framework\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::SUPERVISOR], Role::eligibleOnly(['ROLE_SUPERVISOR']));
|
||||
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.
|
||||
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
|
||||
self::assertSame([], Role::eligibleOnly(['ROLE_SUPERVISOR_PENDING']));
|
||||
}
|
||||
|
||||
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void
|
||||
|
||||
Reference in new issue
Block a user