From 3af326251dab1a16ecedf4cddc78a2fa376ed5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 5 Oct 2026 17:10:02 +0200 Subject: [PATCH] feat: add supervisor role --- config/packages/security.yaml | 18 ++- .../Admin/Resource/EditController.php | 2 +- src/Controller/Booking/ReleaseController.php | 10 +- src/Controller/Dashboard/IndexController.php | 4 +- .../Booking/IndexController.php | 28 ++-- .../Booking/SpotController.php | 12 +- src/Controller/Series/EndController.php | 4 +- .../Traits/ConfirmedActionTrait.php | 8 +- src/Enum/CodeVisibility.php | 16 +- src/Form/{Admin => Manage}/BookingFilter.php | 6 +- .../{Admin => Manage}/BookingFilterType.php | 2 +- src/Repository/BookingRepository.php | 6 +- src/Repository/BookingSeriesRepository.php | 4 +- src/Security/Role.php | 15 +- src/Service/AuditLogger.php | 2 +- templates/board/_spot.html.twig | 17 ++- templates/dashboard/index.html.twig | 11 +- .../{admin => manage}/_modal_spot.html.twig | 8 +- .../{admin => manage}/bookings.html.twig | 8 +- tests/Controller/AccessCodeControllerTest.php | 40 +++++ .../BookingManageControllerTest.php} | 141 ++++++++++++++---- tests/Security/RoleTest.php | 11 +- 22 files changed, 270 insertions(+), 103 deletions(-) rename src/Controller/{Admin => Manage}/Booking/IndexController.php (69%) rename src/Controller/{Admin => Manage}/Booking/SpotController.php (82%) rename src/Form/{Admin => Manage}/BookingFilter.php (83%) rename src/Form/{Admin => Manage}/BookingFilterType.php (99%) rename templates/{admin => manage}/_modal_spot.html.twig (85%) rename templates/{admin => manage}/bookings.html.twig (96%) rename tests/Controller/{Admin/BookingAdminControllerTest.php => Manage/BookingManageControllerTest.php} (67%) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 68c2074..d286365 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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 } diff --git a/src/Controller/Admin/Resource/EditController.php b/src/Controller/Admin/Resource/EditController.php index 0076008..c4f91c6 100644 --- a/src/Controller/Admin/Resource/EditController.php +++ b/src/Controller/Admin/Resource/EditController.php @@ -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 diff --git a/src/Controller/Booking/ReleaseController.php b/src/Controller/Booking/ReleaseController.php index 0ae9cfd..2ad2616 100644 --- a/src/Controller/Booking/ReleaseController.php +++ b/src/Controller/Booking/ReleaseController.php @@ -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.'); } } } diff --git a/src/Controller/Dashboard/IndexController.php b/src/Controller/Dashboard/IndexController.php index b0554cb..046e6ca 100644 --- a/src/Controller/Dashboard/IndexController.php +++ b/src/Controller/Dashboard/IndexController.php @@ -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 diff --git a/src/Controller/Admin/Booking/IndexController.php b/src/Controller/Manage/Booking/IndexController.php similarity index 69% rename from src/Controller/Admin/Booking/IndexController.php rename to src/Controller/Manage/Booking/IndexController.php index 513b061..827e4a6 100644 --- a/src/Controller/Admin/Booking/IndexController.php +++ b/src/Controller/Manage/Booking/IndexController.php @@ -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, diff --git a/src/Controller/Admin/Booking/SpotController.php b/src/Controller/Manage/Booking/SpotController.php similarity index 82% rename from src/Controller/Admin/Booking/SpotController.php rename to src/Controller/Manage/Booking/SpotController.php index 6868b5f..1ed7d37 100644 --- a/src/Controller/Admin/Booking/SpotController.php +++ b/src/Controller/Manage/Booking/SpotController.php @@ -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. diff --git a/src/Controller/Series/EndController.php b/src/Controller/Series/EndController.php index d7f98cb..9f78c8e 100644 --- a/src/Controller/Series/EndController.php +++ b/src/Controller/Series/EndController.php @@ -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.'); } } } diff --git a/src/Controller/Traits/ConfirmedActionTrait.php b/src/Controller/Traits/ConfirmedActionTrait.php index 1853f02..061463d 100644 --- a/src/Controller/Traits/ConfirmedActionTrait.php +++ b/src/Controller/Traits/ConfirmedActionTrait.php @@ -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, }; diff --git a/src/Enum/CodeVisibility.php b/src/Enum/CodeVisibility.php index 1209241..c1b215c 100644 --- a/src/Enum/CodeVisibility.php +++ b/src/Enum/CodeVisibility.php @@ -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 $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), }; } diff --git a/src/Form/Admin/BookingFilter.php b/src/Form/Manage/BookingFilter.php similarity index 83% rename from src/Form/Admin/BookingFilter.php rename to src/Form/Manage/BookingFilter.php index 93079e4..d2b509e 100644 --- a/src/Form/Admin/BookingFilter.php +++ b/src/Form/Manage/BookingFilter.php @@ -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(); diff --git a/src/Form/Admin/BookingFilterType.php b/src/Form/Manage/BookingFilterType.php similarity index 99% rename from src/Form/Admin/BookingFilterType.php rename to src/Form/Manage/BookingFilterType.php index ac7b653..06a765c 100644 --- a/src/Form/Admin/BookingFilterType.php +++ b/src/Form/Manage/BookingFilterType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Form\Admin; +namespace App\Form\Manage; use App\Entity\User; use App\Enum\ResourceType; diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php index 78b4f08..a858e18 100644 --- a/src/Repository/BookingRepository.php +++ b/src/Repository/BookingRepository.php @@ -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 diff --git a/src/Repository/BookingSeriesRepository.php b/src/Repository/BookingSeriesRepository.php index 761bd43..d2a262f 100644 --- a/src/Repository/BookingSeriesRepository.php +++ b/src/Repository/BookingSeriesRepository.php @@ -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 diff --git a/src/Security/Role.php b/src/Security/Role.php index 169c1df..80cc84b 100644 --- a/src/Security/Role.php +++ b/src/Security/Role.php @@ -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, ]; diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php index 158ccce..409f0e4 100644 --- a/src/Service/AuditLogger.php +++ b/src/Service/AuditLogger.php @@ -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 diff --git a/templates/board/_spot.html.twig b/templates/board/_spot.html.twig index ca63be2..c324ed2 100644 --- a/templates/board/_spot.html.twig +++ b/templates/board/_spot.html.twig @@ -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 %}> diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index d9c66ff..1f15803 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -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') %} -
{{ 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.

+ {% endif %} + {% if is_granted('ROLE_ADMIN') %}
diff --git a/templates/admin/_modal_spot.html.twig b/templates/manage/_modal_spot.html.twig similarity index 85% rename from templates/admin/_modal_spot.html.twig rename to templates/manage/_modal_spot.html.twig index 6e7c0cd..cea8cb6 100644 --- a/templates/admin/_modal_spot.html.twig +++ b/templates/manage/_modal_spot.html.twig @@ -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' %} diff --git a/templates/admin/bookings.html.twig b/templates/manage/bookings.html.twig similarity index 96% rename from templates/admin/bookings.html.twig rename to templates/manage/bookings.html.twig index d5f3469..eb3feb0 100644 --- a/templates/admin/bookings.html.twig +++ b/templates/manage/bookings.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) }}
{{ form_end(form) }} @@ -58,7 +58,7 @@ {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}