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,
|
# 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
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+16
-12
@@ -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,
|
||||||
+6
-6
@@ -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.
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+114
-27
@@ -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']);
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user