From 34e796c5d50310d541cee297e4788c06568a568e Mon Sep 17 00:00:00 2001 From: sebo Date: Fri, 3 Jul 2026 22:55:19 +0200 Subject: [PATCH 01/17] Rename book-as to book-for and fix seat display in view-only zones Book-as was really a book-for mechanism: a zone admin books on behalf of another user. Reframing it that way lets bookable seats in an admin's zone work correctly during book-for even when the target is a viewer, and lets the frontend show real occupancy/assignment icons in view-only zones instead of a blanket "unavailable" for every seat. - getSeats/apply/autoBook: bookable under book-for now requires the actor to administer the zone and the target to merely be a member (any role) of it, overriding seat-level assignment restrictions there. Release confinement to the actor's own zones is unchanged. - seat.js: removed the early !bookable short-circuit and the VIEW_ONLY_TAKEN state; every seat now computes its normal occupancy/assignment state, with bookable only demoting action states (book/rebook) to informational ones. - Renamed book-as -> book-for across code, UI, CSS, i18n, and docs. Co-Authored-By: Claude Sonnet 5 --- AUTOBOOK.md | 71 ++++--- FEATURES.md | 20 +- GLOSSARY.md | 19 +- PERMISSIONS.md | 99 +++++---- ...oup.spec.ts => bookfor-zone-group.spec.ts} | 121 +++++++---- e2e/tests/booking/zone-group.spec.ts | 12 +- ...ec.ts => zone-permissions-bookfor.spec.ts} | 191 +++++++++++------- e2e/tests/zone-admin/assignment.spec.ts | 2 +- .../{book-as.spec.ts => book-for.spec.ts} | 10 +- js/app/main.js | 2 +- js/app/router.js | 4 +- js/base/style.css | 20 +- js/views/html/plan.html | 8 +- js/views/modules/{bookas.js => bookfor.js} | 60 +++--- js/views/modules/planuserdata.js | 4 +- js/views/modules/seat.js | 70 +++---- js/views/plan.js | 19 +- warp/static/i18n/de.json | 4 +- warp/static/i18n/en.json | 4 +- warp/static/i18n/es.json | 4 +- warp/static/i18n/fr.json | 4 +- warp/static/i18n/pl.json | 4 +- warp/xhr/plan.py | 98 +++++---- 23 files changed, 499 insertions(+), 351 deletions(-) rename e2e/tests/booking/{bookas-zone-group.spec.ts => bookfor-zone-group.spec.ts} (74%) rename e2e/tests/booking/{zone-permissions-bookas.spec.ts => zone-permissions-bookfor.spec.ts} (78%) rename e2e/tests/zone-admin/{book-as.spec.ts => book-for.spec.ts} (91%) rename js/views/modules/{bookas.js => bookfor.js} (74%) diff --git a/AUTOBOOK.md b/AUTOBOOK.md index b792db3d..819a9083 100644 --- a/AUTOBOOK.md +++ b/AUTOBOOK.md @@ -24,7 +24,7 @@ definitions see [GLOSSARY.md](GLOSSARY.md). - `dates`: a list of `{fromTS, toTS}` slots (absolute unix seconds). There may be several slots, possibly spanning several days. -- `login` (optional): book _as_ this user instead of yourself (see §3). +- `login` (optional): book _for_ this user instead of yourself (see §3). **Response** (HTTP 200) is a report with four buckets: @@ -43,41 +43,56 @@ overlapping slots in the request or a database conflict at commit time. ## 2. Permission gate (the `autoBook` endpoint) -Auto-book is **always a regular-user action**: the seat it picks is one the -**subject** of the booking could have picked themselves. Roles are equal here — -there is **no** site-admin super-user bypass and **no** confinement to "the zones -the actor administers". The actor's role only governs _who may book for whom_: - -- **Booking for yourself** (`login` omitted) → always allowed. Seat selection - acts as you. -- **Booking as another user** (`login` differs) → only a **site admin** or a - **zone admin of at least one zone on this plan** may do this. Once allowed, seat - selection acts entirely as the **target** user (see §3) — the actor's own zones - are irrelevant. A regular user may never book as someone else (`403`, code 104). - -The plan must also contain at least one zone the **subject** can book in; -otherwise the request is rejected (`403`, code 104). - -> This is intentionally more permissive than **manual** book-as, which also -> requires the actor to administer the _specific seat's_ zone. Auto-book-as only -> requires admin standing on the plan, because the seat it ultimately picks is — -> by construction — always one the target could have booked on their own. +- **Booking for yourself** (`login` omitted) → always allowed, and always a + regular-user action: the seat picked is one you could have picked yourself — + no site-admin super-user bypass, no confinement to zones you merely oversee. +- **Booking for another user** (`login` differs) → only a **site admin** or a + **zone admin of at least one zone on this plan** may do this. Unlike + self-booking, this is a book-for operation performed under the **actor's** + zone-admin authority: the seat pool (and the set of the target's bookings that + may be released to make room) is confined to the zones the **actor** + administers (unconfined for a site admin — see §3). Within that pool the + target only needs to be a **member** of the zone (any role, viewers + included) — not `zone_role ≤ user` — since the actor's admin standing + overrides seat-level restrictions there. A regular user may never book for + someone else (`403`, code 104). + +The plan must also contain at least one eligible zone for the request (the +subject's own zones for self-booking; the actor's administered zones, +intersected with the target's membership, for book-for); otherwise the request +is rejected (`403`, code 104). + +> This mirrors **manual** book-for (PERMISSIONS.md §8): both require the actor +> to administer the zone and only require target membership, not `zone_role ≤ +> user`. Auto-book's difference from manual book-for is purely mechanical — it +> picks the seat instead of the admin picking one explicitly — the permission +> boundary (actor's administered zones) is the same for both. --- ## 3. Whose seats, whose access -`runAutoBook(login, pid, dates)` selects seats that the **subject `login`** is -allowed to book — and **only** those: +`runAutoBook(login, pid, dates, allowedZids, releaseZids, is_book_for)` selects +seats from: -- enabled seats, in non-disabled zones, where the subject has `zone_role ≤ user` - (this includes zones they administer, since admin ≤ user numerically); **plus** +- enabled seats, in non-disabled zones, where the subject has a + `user_to_zone_roles` row for the zone — and, **unless `is_book_for`**, that + role must be `≤ user` (this includes zones they administer, since admin ≤ + user numerically); **plus** - all enabled seats in **public-book** zones (everyone may book those). -This is the subject's _regular_ access — exactly what they would get if they -clicked "find me a seat" themselves. For a book-as call the subject is the -**target** user, so the target can never be placed on a seat they could not have -booked on their own. The actor's roles never widen (or narrow) this pool. +For self-booking this is exactly the subject's regular access — what they'd +get clicking "find me a seat" themselves, scoped to the zones they can +actually book in (`is_book_for` is false, so the `≤ user` filter applies). + +For book-for, the **subject** is the target user, `is_book_for` is true, and +the seat pool is additionally filtered by `allowedZids` to the zones the +**actor** administers (`releaseZids` confines which of the target's existing +bookings may be displaced the same way — see §2). Within `allowedZids`, the +`≤ user` filter is dropped: the target merely needs a membership row, so a +viewer in one of the actor's administered zones is a valid auto-book target +there. The target is never placed in a zone the actor doesn't administer, and +never in a zone they aren't at least a member of. All of this is scoped to the **plan named in the request** (`pid`) — i.e. the plan currently open in the UI. Auto-book only ever considers the zones and seats diff --git a/FEATURES.md b/FEATURES.md index 72efbd8d..fa97bce0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -101,7 +101,7 @@ WARP has two independent role layers: **account-level** roles and **zone-level** | Role | Value | Description | |-------------|-------|-------------------------------------------------------------------------------------------------------------------------------------------| -| **Admin** | 10 | Full system access: user/group/zone management and reports. Zone-level actions (e.g., "Book As", enabling/disabling seats) still require a Zone Admin assignment in that zone — which admins can grant themselves. | +| **Admin** | 10 | Full system access: user/group/zone management and reports. Zone-level actions (e.g., "Book For", enabling/disabling seats) still require a Zone Admin assignment in that zone — which admins can grant themselves. | | **User** | 20 | Regular user. Can book seats in zones they are assigned to. | | **Blocked** | 90 | Cannot log in. Account exists but is disabled. | | **Group** | 100 | Virtual account representing a user group. Not a real person; cannot log in. | @@ -112,7 +112,7 @@ Each user (or group) can be assigned a role **per zone**: | Role | Value | What they can do in the zone | |----------------|-------|---------------------------------------------------------------------------------------------| -| **Zone Admin** | 10 | Assign/unassign users to the zone and to seats, enable/disable seats, book on behalf of any zone user ("Book As"), see disabled seats. | +| **Zone Admin** | 10 | Assign/unassign users to the zone and to seats, enable/disable seats, book on behalf of any zone member including viewers ("Book For"), see disabled seats. | | **User** | 20 | Book, update, and delete their own bookings. | | **Viewer** | 30 | See the zone map, seats, and other people's bookings, but **cannot book**. | @@ -415,19 +415,19 @@ The UI modal surfaces this as sections titled "Booked", "Could not extend or reb The old "Already booked in another zone" section is gone; an existing booking on the same plan for the same slots takes top priority at step 1 instead. ### 8.4 Auto-Book for Zone Admins -- Zone admins can use the "Book As" feature with auto-book to find a seat for another user (see §9). -- When doing so via auto-book, the seat is chosen exactly as it would have been for the target user themselves (the actor's own zones do not restrict the choice). Manual "book as" is still scoped to the specific seat's zone adminship. +- Zone admins can use the "Book For" feature with auto-book to find a seat for another user (see §9). +- The seat pool is confined to the zones the **actor** administers on that plan (unconfined for a site admin). Within that pool, the target only needs to be a member of the zone — any role, including viewer — since booking-for overrides seat-level assignment restrictions there. Manual "book for" is likewise scoped to the specific seat's zone adminship, with the same membership-only requirement on the target. --- -## 9. "Book As" (Zone Admin Feature) +## 9. "Book For" (Zone Admin Feature) -- A "Book As" input field appears in the plan-view side panel (the booking map) for zone admins. -- It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users. -- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. +- A "Book For" input field appears in the plan-view side panel (the booking map) for zone admins. +- It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users, including viewers. +- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction and any seat-level assignment for that seat. - When the admin books, updates, or removes a booking, it is performed **on behalf of the selected user**. - The admin can also auto-book for the selected user. -- Clearing the "Book As" field (pressing Enter while empty) reverts to the admin's own view. +- Clearing the "Book For" field (pressing Enter while empty) reverts to the admin's own view. --- @@ -805,7 +805,7 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* | Seat State | No Dates | Green (Book) | Green (Rebook) | Blue (Update) | Blue (Conflict) | Blue (Exact) | Red (Taken) | Yellow (Assigned) | Gray (Disabled) | |-----------------------|----------|-----------------------|----------------------|---------------|-----------------|--------------|-------------|-----------------------------|-----------------------------| | **User actions** | — | Book | Book (replaces) | Update | Remove | Remove | — | — | — | -| **Zone Admin actions**| — | + Book As, + Edit | same | same | same | same | + Edit | + Edit | + Edit | +| **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Edit | + Edit | + Edit | | **Viewer actions** | — | — | — | — | — | — | — | — | — | --- diff --git a/GLOSSARY.md b/GLOSSARY.md index 652c4eaa..422e9b41 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -171,21 +171,24 @@ The range of dates that are open for booking at all, configured installation- wide (a number of weeks in advance). No one — not even an admin booking for themselves — can book outside this window through the normal booking screen. -### Book as +### Book for A convenience for zone (and site) admins: book (or auto-book) a seat **on behalf -of another user**. The booking is recorded under the target user, who must be -allowed to book that seat. (Manual book-as requires the admin to manage the -seat's own zone; auto-book-as only requires admin standing on the plan — see -[PERMISSIONS.md](PERMISSIONS.md) §8.) +of another user**, exercising the admin's own zone-admin authority rather than +the target's. The booking is recorded under the target user, who only needs to +be a **member** of the zone (any role, viewers included) — the admin's standing +overrides seat-level restrictions (assignments) there. Both manual and +auto-book-for require the admin to administer the zone in question — see +[PERMISSIONS.md](PERMISSIONS.md) §8. ### Auto-book ("find me a seat") Instead of picking a specific seat, the user asks the system to choose the best available seat on the plan for the selected dates, honouring assignments, the -release window, and existing bookings. Admins can also auto-book _as_ another -user, in which case it runs **as that user** — picking only seats the target -could book themselves. See [AUTOBOOK.md](AUTOBOOK.md) for the selection priority. +release window, and existing bookings. Admins can also auto-book _for_ another +user, in which case the seat pool is confined to the zones **the admin** +administers — picking only seats within that pool the target is at least a +member of. See [AUTOBOOK.md](AUTOBOOK.md) for the selection priority. ### Effective zone role diff --git a/PERMISSIONS.md b/PERMISSIONS.md index dd53d170..e3cdf74d 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -190,7 +190,7 @@ A plan can mix zones, so the screen picks a mode from the user's roles across al zones on that plan: - **Admin mode** — the user administers _at least one_ zone on the plan (or is a - site admin). The assign / enable / disable / book-as tools appear. + site admin). The assign / enable / disable / book-for tools appear. - **Viewer mode** — the user can only _view_ every zone they can reach on the plan (no booking anywhere). The booking actions and the auto-book button are hidden. @@ -228,38 +228,54 @@ Rule 6 is enforced at the database level, so it holds no matter which path --- -## 8. Booking on behalf of others ("book as") +## 8. Booking on behalf of others ("book for") -### Manual book-as (picking a specific seat for someone) +Book-for is fundamentally different from booking as yourself: the actor is +acting in their capacity as **zone admin**, not as the target. Self-booking +requires the booker's own effective role to be _user_ or better; book-for +instead requires the **actor** to administer the zone, and only requires the +**target** to be a _member_ of it — any zone role, viewers included. Because +the actor is the zone's admin, they may also override seat-level assignment +restrictions there (they could reassign the seat themselves anyway) — but only +in zones they administer. Disabled zones reject booking outright, for +everyone, book-for included. + +### Manual book-for (picking a specific seat for someone) - **The actor must be a zone admin of the seat's zone** (site admins qualify everywhere). Being an admin of a _different_ zone on the same plan is not - enough — e.g. an admin of the enabled zone cannot book-as into a public-book - zone on the same plan unless they also administer that public-book zone. -- **The target must be allowed to book in that zone** — i.e. the _target's_ - effective role must be _user_ or better. You cannot park a booking on someone - who has no business in the zone. (In a public-book zone that is everyone; in a - public-view zone only users with an explicit grant; in an enabled zone only - those granted _user_/_admin_.) + enough — e.g. an admin of the enabled zone cannot book for someone into a + public-book zone on the same plan unless they also administer that + public-book zone. +- **The target only needs to be a member of that zone** — a + `user_to_zone_roles` row for the zone, of _any_ role. A zone admin can book + for a viewer in their own zone; this is the point of the mechanism (it makes + view-only zones genuinely centrally managed — the office manager books, + viewers just see the outcome). +- **Assignments do not bind the target under book-for.** If the seat is + assigned to someone else, or its days-in-advance window would otherwise + block the date, the zone admin's book-for override still succeeds. +- **Release confinement still applies to unrelated zones.** Book-for can only + release (delete) an existing conflicting booking of the target's if that + booking is in a zone the actor also administers. If the conflicting booking + is in a zone outside the actor's control, the operation is rejected rather + than silently deleting it. - **Disabled zones** still reject the booking outright. ### Auto-book ("find me a seat") -Auto-book is always a **regular-user action**: it picks a seat the **subject** -(the booking's owner) could have picked themselves — no super-user bypass, and no -confinement to the actor's own zones. The actor's role only gates _who may book -for whom_: - -- **For yourself** — always allowed; the pool is the zones where _you_ have a - regular booking grant (or that are public-bookable). The site-admin bypass is - excluded here (§4), so auto-book never picks a zone you only oversee. -- **As another user** — only a **site admin** or a **zone admin of some zone on - the plan** may trigger it. The seat is then chosen exactly as the **target** - would get it (across the target's own accessible zones), so the target is never - placed on a seat they couldn't book themselves, and the actor's zones do not - constrain the choice. This is intentionally looser than manual book-as (which - also requires the actor to administer the specific seat's zone) — safe, because - the picked seat is by construction one the target could book on their own. +- **For yourself** — always a regular-user action: it picks a seat you could + have picked yourself — no super-user bypass, and no confinement to zones you + merely oversee. The pool is the zones where _you_ have a regular booking + grant (or that are public-bookable). The site-admin bypass is excluded here + (§4), so auto-book never picks a zone you only oversee. +- **For another user** — only a **site admin** or a **zone admin of some zone + on the plan** may trigger it. The seat pool (and the release side — which + bookings of the target's may be displaced) is confined to the zones the + **actor** administers (unconfined for a site admin). Within that pool the + target only needs to be a member — any role, viewers included — mirroring + the manual book-for rule; the auto-book heuristic then picks the best seat + among eligible ones exactly as it would for a normal booking. See [AUTOBOOK.md](AUTOBOOK.md) for the full auto-book selection priority. @@ -307,8 +323,10 @@ See [AUTOBOOK.md](AUTOBOOK.md) for the full auto-book selection priority. ✅ BOOKED ``` -(For _book as_, run the booking-access checks against the **target** user and -additionally require the **actor** to administer S's zone — §8.) +(For _book for_, replace the first two checks: require the **actor** to +administer S's zone, and the **target** to merely be a member of it — any +role — instead of running the role/assignment checks against the target. The +zone-disabled, booking-window, and conflict checks still apply — §8.) --- @@ -317,23 +335,28 @@ additionally require the **actor** to administer S's zone — §8.) A plan **"Office"** has three zones: - **Open** — public (book) -- **Quiet** — enabled (private); _Bob_ is zone admin, _Carol_ is zone user +- **Quiet** — enabled (private); _Bob_ is zone admin, _Carol_ is zone user, _Eve_ is zone viewer - **Lab** — disabled; _Bob_ is zone admin | Person | Open | Quiet | Lab | Notes | | ------------------------- | ----------- | ----------- | ----------- | -------------------------------------------- | | **Dana** (no grants) | book | – | – | public-book gives everyone _user_ in Open | | **Carol** (user in Quiet) | book | book | – | sees Lab? no — disabled & no admin grant | +| **Eve** (viewer in Quiet) | book | view only | – | can see Quiet's occupancy, cannot book there | | **Bob** (admin Quiet+Lab) | book | book+manage | view+manage | cannot _book_ in Lab (disabled), only manage | | **Site admin** | book+manage | book+manage | view+manage | super-user; still cannot book in Lab | -Book-as on this plan: - -- Bob books **as Carol** in **Quiet** → ✅ (Bob admins Quiet, Carol may book there). -- Bob books **as Dana** in **Open** → ❌ Bob is only a _user_ of Open, not its - admin; he cannot book-as there (even though Dana herself could book). -- Bob auto-books **as Carol** → he may trigger it (he admins Quiet, a zone on the - plan), and it then runs **as Carol**, so it may place her in **any zone Carol - can book** (Quiet or the public Open) by the auto-book priority — never in Lab. -- Site admin books **as Dana** in **Open** → ✅ (super-user admins Open; Dana may - book there). +Book-for on this plan: + +- Bob books **for Carol** in **Quiet** → ✅ (Bob admins Quiet, Carol is a member). +- Bob books **for Eve** in **Quiet** → ✅ (Bob admins Quiet, Eve is a member — + her _viewer_ role doesn't matter for book-for; if the seat happens to be + assigned to someone else, Bob's book-for still overrides that). +- Bob books **for Dana** in **Open** → ❌ Bob is only a _user_ of Open, not its + admin; he cannot book for anyone there (even though Dana herself could book). +- Bob auto-books **for Eve** → he may trigger it (he admins Quiet, a zone on + the plan), and the seat pool is confined to **zones Bob administers** + (Quiet, Lab) — never Open, even though Eve could book there herself. Lab is + excluded too (disabled). So the seat is picked from Quiet only. +- Site admin books **for Dana** in **Open** → ✅ (super-user admins Open; Dana + is a member there). diff --git a/e2e/tests/booking/bookas-zone-group.spec.ts b/e2e/tests/booking/bookfor-zone-group.spec.ts similarity index 74% rename from e2e/tests/booking/bookas-zone-group.spec.ts rename to e2e/tests/booking/bookfor-zone-group.spec.ts index 3efb4675..e014bf08 100644 --- a/e2e/tests/booking/bookas-zone-group.spec.ts +++ b/e2e/tests/booking/bookfor-zone-group.spec.ts @@ -1,19 +1,20 @@ /** - * Book-as interaction with zone-group / multi-plan booking. + * Book-for interaction with zone-group / multi-plan booking. * * These cover the bugs fixed alongside the unified zone-group conflict query: * - * - Book-as used to fetch only conflict seats (getSeats?onlyOtherZone=1). That + * - Book-for used to fetch only conflict seats (getSeats?onlyOtherZone=1). That * partial response also returned the target's bookings in *accessible* zones * that happened to share a conflict zone-group with the viewed plan, and the * client overwrote those live (rendered) seats with div-less "other zone" * ghosts — leaving the original sprite frozen on its stale TAKEN (padlock) - * icon. Book-as now does a full getSeats?login=target refresh, so the + * icon. Book-for now does a full getSeats?login=target refresh, so the * target's own bookings in accessible zones stay recognisable as theirs. * - * - `bookable` in getSeats now reflects the *target* user's role under book-as, - * so a seat the target can only view (VIEWER zone) is no longer offered as - * bookable to the admin (which apply() would then reject with 104). + * - `bookable` in getSeats reflects the *actor's* admin standing under book-for + * plus the target's membership in the zone (any role, viewers included) — so + * a seat in a zone the admin administers is offered as bookable even for a + * viewer target; apply() applies the same membership-only rule. */ import { test, expect } from '../../fixtures'; @@ -31,25 +32,25 @@ import { apiApply, } from '../../helpers/booking'; -/** Activate book-as for the given display label (e.g. "Bar [user2]"). */ -async function activateBookAs(page: any, label: string): Promise { - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially(label.split(' ')[0], { delay: 50 }); +/** Activate book-for for the given display label (e.g. "Bar [user2]"). */ +async function activateBookFor(page: any, label: string): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially(label.split(' ')[0], { delay: 50 }); const item = page.locator('ul.autocomplete-content li', { hasText: label }); await expect(item).toBeVisible({ timeout: 5000 }); await item.click(); - // book-as fires a full getSeats?login= refresh; wait for it to settle. + // book-for fires a full getSeats?login= refresh; wait for it to settle. await page.waitForLoadState('networkidle'); await page.waitForTimeout(400); } -/** Clear book-as (Enter on an empty input resets to the admin's own login). */ -async function clearBookAs(page: any): Promise { - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.fill(''); - await bookAsInput.press('Enter'); +/** Clear book-for (Enter on an empty input resets to the admin's own login). */ +async function clearBookFor(page: any): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.fill(''); + await bookForInput.press('Enter'); await page.waitForLoadState('networkidle'); await page.waitForTimeout(400); } @@ -74,7 +75,7 @@ async function seatActions(page: any, seat: any): Promise { return actions; } -test.describe('book-as + zone group', () => { +test.describe('book-for + zone group', () => { test('target\'s own booking in an accessible same-group zone shows as deletable, not a padlock', async ({ page }) => { const ts = futureDayTs(1); @@ -100,7 +101,7 @@ test.describe('book-as + zone group', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); // The seat user2 already holds must be recognised as user2's own booking // (delete offered), not shown as taken-by-someone-else (padlock → admin-only @@ -110,7 +111,7 @@ test.describe('book-as + zone group', () => { await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); }); - test('rebooking across a same-group zone via book-as leaves consistent state after refresh', async ({ page }) => { + test('rebooking across a same-group zone via book-for leaves consistent state after refresh', async ({ page }) => { const ts = futureDayTs(1); const zone1Seat = (await getZoneSeats(1))[0]; const zone2Seat = (await getZoneSeats(2))[0]; @@ -137,7 +138,7 @@ test.describe('book-as + zone group', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); // Zone 1B seat is same-group → rebook (update), moving user2's booking. await clickZoneSeat(page, zone2Seat); @@ -165,7 +166,7 @@ test.describe('book-as + zone group', () => { await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); }); - test('book-as a viewer-only user marks the seat not bookable (Fix 5)', async ({ page }) => { + test('book-for a viewer-only user still offers booking when the admin administers the zone', async ({ page }) => { const ts = futureDayTs(1); // Zone 3 (Parking): user2 has no group role here, so a direct VIEWER role // is genuinely viewer-only (zone 1 would be polluted by group_1a's USER role). @@ -189,15 +190,15 @@ test.describe('book-as + zone group', () => { await page.keyboard.press('Escape'); await page.waitForTimeout(200); - // Book-as user2 (viewer): the seat is now view-only for user2, so clicking - // it must not open the booking modal (VIEW_ONLY short-circuits the handler). - await activateBookAs(page, 'Bar [user2]'); + // Book-for user2 (viewer): user1 administers this zone, so book-for overrides + // user2's viewer restriction — the seat is still bookable, not view-only. + await activateBookFor(page, 'Bar [user2]'); await clickZoneSeat(page, parkingSeat); - await page.waitForTimeout(300); - await expect(page.locator('#action_modal')).not.toHaveClass(/open/); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).toBeVisible(); }); - test('GUARD: getSeats sends login on book-as conflict seats (Fix 1 server contract)', async ({ page }) => { + test('GUARD: getSeats sends login on book-for conflict seats (Fix 1 server contract)', async ({ page }) => { // Deterministic contract guard. The target's only booking is in a zone the // admin cannot access, so it reaches the response purely via the conflict // query (an other-zone seat: no x/y). Fix 1 requires that conflict booking @@ -235,9 +236,9 @@ test.describe('book-as + zone group', () => { expect(conflictSeat.book[0].login).toBe(USER2.login); }); - test('book-as conflict seat in an inaccessible same-group zone drives rebook (client uses server login)', async ({ page }) => { + test('book-for conflict seat in an inaccessible same-group zone drives rebook (client uses server login)', async ({ page }) => { // UI counterpart of the contract guard: the inaccessible Parking booking - // must make the accessible Zone 1A seat a rebook under book-as. This passes + // must make the accessible Zone 1A seat a rebook under book-for. This passes // only if the conflict seat is recognised as the target's, i.e. its login // arrived from the server and the client honoured it. const ts = futureDayTs(1); @@ -258,7 +259,7 @@ test.describe('book-as + zone group', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); await clickZoneSeat(page, zone1Seat); await expect(page.locator('#action_modal')).toHaveClass(/open/); @@ -269,9 +270,49 @@ test.describe('book-as + zone group', () => { await expect(page.locator('#action_modal_msg2')).toContainText('Parking'); }); + test('book-for cannot release a target\'s booking in a non-administered same-group zone', async ({ page }) => { + // user1 administers Zone 1A but is only a regular USER in Parking (Zone 3), + // same group. user2 already holds the Parking seat. Booking user2 into Zone + // 1A while asking to release the Parking booking must fail — release + // confinement (apply()'s per-seat zone-admin check) blocks the remove bid, + // and the whole atomic request is rejected, not partially applied. + const ts = futureDayTs(1); + const zone1Seat = (await getZoneSeats(1))[0]; + const parkingSeat = (await getZoneSeats(3))[0]; + const fromTS = ts + 9 * 3600, toTS = ts + 17 * 3600; + + await querySql("UPDATE zone SET zone_group = 'floor-1' WHERE id IN (1, 3)"); + await logIn(page, ADMIN); + await adminPost(page, '/xhr/zones/assign', { zid: 1, change: [{ login: USER1.login, role: 10 }] }); + await adminPost(page, '/xhr/zones/assign', { zid: 3, change: [{ login: USER1.login, role: 20 }] }); // USER only, not admin + await adminPost(page, '/xhr/zones/assign', { zid: 1, change: [{ login: USER2.login, role: 20 }] }); + await adminPost(page, '/xhr/zones/assign', { zid: 3, change: [{ login: USER2.login, role: 20 }] }); + + await querySql( + 'INSERT INTO book (login, sid, fromts, tots) VALUES ($1, $2, $3, $4)', + [USER2.login, parkingSeat.id, fromTS, toTS]); + const bidRow = await querySql( + 'SELECT id FROM book WHERE login = $1 AND sid = $2', + [USER2.login, parkingSeat.id]); + const removeBid = bidRow.rows[0].id; + + await logIn(page, USER1); + const resp = await apiApply(page, { + book: { sid: zone1Seat.id, login: USER2.login, dates: [{ fromTS, toTS }] }, + remove: [removeBid], + }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(102); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, parkingSeat.id]); + expect(r.rows[0].cnt).toBe(1); + }); + }); -test.describe('book-as target switching', () => { +test.describe('book-for target switching', () => { // Helper: zones 1 & 2 share group 'floor-1'; user1 admins both; the given // users get USER access to Zone 1B (zid 2). Returns the two seats of interest. @@ -287,7 +328,7 @@ test.describe('book-as target switching', () => { return { zone1Seat, zone2Seat }; } - test('switching book-as back to self restores the admin\'s own (conflict-free) view', async ({ page }) => { + test('switching book-for back to self restores the admin\'s own (conflict-free) view', async ({ page }) => { const ts = futureDayTs(1); const { zone1Seat, zone2Seat } = await setupGroup(page); const fromTS = ts + 9 * 3600, toTS = ts + 17 * 3600; @@ -303,18 +344,18 @@ test.describe('book-as target switching', () => { await page.waitForTimeout(400); // As user2: Zone 1B seat is a rebook (user2's same-group Zone 1A booking). - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); expect(await seatActions(page, zone2Seat)).toContain('update'); expect(await seatActions(page, zone2Seat)).not.toContain('book'); // Back to self (admin user1, who holds nothing): plain book, no rebook. - await clearBookAs(page); + await clearBookFor(page); const selfActions = await seatActions(page, zone2Seat); expect(selfActions).toContain('book'); expect(selfActions).not.toContain('update'); }); - test('switching book-as between two targets reflects each target\'s own conflicts', async ({ page }) => { + test('switching book-for between two targets reflects each target\'s own conflicts', async ({ page }) => { const ts = futureDayTs(1); const { zone1Seat, zone2Seat } = await setupGroup(page, [USER3.login]); const fromTS = ts + 9 * 3600, toTS = ts + 17 * 3600; @@ -330,16 +371,16 @@ test.describe('book-as target switching', () => { await page.waitForTimeout(400); // user2 → rebook (has conflict); user3 → plain book (no conflict). - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); expect(await seatActions(page, zone2Seat)).toContain('update'); - await activateBookAs(page, 'Baz [user3]'); + await activateBookFor(page, 'Baz [user3]'); const u3 = await seatActions(page, zone2Seat); expect(u3).toContain('book'); expect(u3).not.toContain('update'); // Back to user2 → rebook again (no stale ghost from the user3 view). - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); expect(await seatActions(page, zone2Seat)).toContain('update'); }); diff --git a/e2e/tests/booking/zone-group.spec.ts b/e2e/tests/booking/zone-group.spec.ts index cca44ccd..cb8b8150 100644 --- a/e2e/tests/booking/zone-group.spec.ts +++ b/e2e/tests/booking/zone-group.spec.ts @@ -356,7 +356,7 @@ test.describe('zone group constraint (non-null zone_group)', () => { expect(Number(rows.rows[0].sid)).toBe(zone2Seat.id); }); - test('UI: book-as shows rebook icon when target has same-group booking on another plan', async ({ page }) => { + test('UI: book-for shows rebook icon when target has same-group booking on another plan', async ({ page }) => { const ts = futureDayTs(1); const zone1Seat = (await getZoneSeats(1))[0]; const zone2Seat = (await getZoneSeats(2))[0]; @@ -375,17 +375,17 @@ test.describe('zone group constraint (non-null zone_group)', () => { await logIn(page, USER2); await apiApply(page, { book: { sid: zone1Seat.id, dates: [{ fromTS, toTS }] } }); - // user1 (admin of zone 1B) opens plan 2 and book-as user2 + // user1 (admin of zone 1B) opens plan 2 and books for user2 await logIn(page, USER1); await page.goto('/plan/2'); await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - // Activate book-as for user2 - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially('Bar', { delay: 50 }); + // Activate book-for for user2 + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially('Bar', { delay: 50 }); const item = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); await expect(item).toBeVisible({ timeout: 5000 }); await item.click(); diff --git a/e2e/tests/booking/zone-permissions-bookas.spec.ts b/e2e/tests/booking/zone-permissions-bookfor.spec.ts similarity index 78% rename from e2e/tests/booking/zone-permissions-bookas.spec.ts rename to e2e/tests/booking/zone-permissions-bookfor.spec.ts index e12df08c..306f45e5 100644 --- a/e2e/tests/booking/zone-permissions-bookas.spec.ts +++ b/e2e/tests/booking/zone-permissions-bookfor.spec.ts @@ -1,5 +1,5 @@ /** - * Zone permission edge cases around "book as" (acting on behalf of another user), + * Zone permission edge cases around "book for" (acting on behalf of another user), * site-admin super-user access, multi-zone plans, and public zones mixed with * regular/enabled zones. * @@ -8,30 +8,33 @@ * A. Site admin (account_type 10) is a super-user over every zone, even ones * they are not explicitly assigned to: * A1 view + self-book any plan with no zone assignment - * A2 book-as a user who has access, in an unassigned zone - * A3 cannot book-as a user who has no access to the zone + * A2 book-for a user who has access, in an unassigned zone + * A3 cannot book-for a user who has no access to the zone * A4 still cannot book a DISABLED-zone seat * - * B. Manual book-as by a *zone* admin, in multi-zone / public-mixed plans: - * B1 zone admin books-as in the zone they administer - * B2 zone admin CANNOT book-as in a PUBLIC_BOOK zone they don't administer - * B3 admin of a PUBLIC_BOOK zone can book-as anyone (public ⇒ USER) - * B4 admin of a PUBLIC_VIEW zone can book-as an explicit USER but not a - * view-only (public VIEWER) user + * B. Manual book-for by a *zone* admin, in multi-zone / public-mixed plans: + * B1 zone admin books-for in the zone they administer + * B2 zone admin CANNOT book-for in a PUBLIC_BOOK zone they don't administer + * B3 admin of a PUBLIC_BOOK zone can book-for anyone (public ⇒ USER) + * B4 admin of a PUBLIC_VIEW zone can book-for both an explicit USER and a + * view-only (public VIEWER) user — book-for only requires membership + * B5 book-for overrides a seat assignment to someone else + * B6 book-for overrides a seat's days-in-advance assignment window * - * C. Auto-book ("find me a seat") as another user: - * C1 zone admin can auto-book-as (previously wrongly rejected with 403) - * C2 auto-book-as is confined to the zones the actor administers - * C3 site admin can auto-book-as anyone - * C4 a non-admin cannot auto-book-as - * C5 zone admin can auto-book-as via the FAB (UI) + * C. Auto-book ("find me a seat") for another user: + * C1 zone admin can auto-book-for (previously wrongly rejected with 403) + * C2 auto-book-for is confined to the zones the actor administers + * C3 site admin can auto-book-for anyone + * C4 a non-admin cannot auto-book-for + * C5 zone admin can auto-book-for via the FAB (UI) + * C12 zone admin can auto-book-for a viewer into a zone they administer * - * D. Multi-zone exclusivity (book_overlap trigger) combined with book-as: - * D1 same zone-group ⇒ one seat per group (second book-as rejected, 109) + * D. Multi-zone exclusivity (book_overlap trigger) combined with book-for: + * D1 same zone-group ⇒ one seat per group (second book-for rejected, 109) * D2 ungrouped zones ⇒ a user may hold a seat in each simultaneously * - * E. getSeats book-as guard: - * E1 a non-admin cannot use the ?login= book-as parameter + * E. getSeats book-for guard: + * E1 a non-admin cannot use the ?login= book-for parameter */ import { test, expect } from '../../fixtures'; import { logIn } from '../../helpers/auth'; @@ -45,10 +48,12 @@ import { ZONE_TYPE_PUBLIC_BOOK, ZONE_ROLE_ADMIN, ZONE_ROLE_USER, + ZONE_ROLE_VIEWER, createPlan, createZone, addSeats, assignZoneRole, + assignSeat, clearZoneRoles, countBookings, insertBooking, @@ -84,8 +89,8 @@ test.describe('site admin is a super-user over all zones', () => { expect(await countBookings('admin', seatId)).toBe(1); }); - test('A2: can book as a user who has access, in an unassigned zone', async ({ page }) => { - const pid = await createPlan('Superuser BookAs Plan'); + test('A2: can book for a user who has access, in an unassigned zone', async ({ page }) => { + const pid = await createPlan('Superuser BookFor Plan'); const zid = await createZone('Enabled X', ZONE_TYPE_ENABLED); const [seatId] = await addSeats(pid, zid, ['X.1']); await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); @@ -98,7 +103,7 @@ test.describe('site admin is a super-user over all zones', () => { expect(await countBookings('admin', seatId)).toBe(0); }); - test('A3: cannot book as a user who has no access to the zone', async ({ page }) => { + test('A3: cannot book for a user who has no access to the zone', async ({ page }) => { const pid = await createPlan('Superuser NoAccess Plan'); const zid = await createZone('Enabled Y', ZONE_TYPE_ENABLED); const [seatId] = await addSeats(pid, zid, ['Y.1']); @@ -124,10 +129,10 @@ test.describe('site admin is a super-user over all zones', () => { }); // --------------------------------------------------------------------------- -// B. Manual book-as by a zone admin in multi-zone / public-mixed plans +// B. Manual book-for by a zone admin in multi-zone / public-mixed plans // --------------------------------------------------------------------------- -test.describe('manual book-as in multi-zone / public-mixed plans', () => { +test.describe('manual book-for in multi-zone / public-mixed plans', () => { /** Plan with an ENABLED zone (user1 admin, user2 user) + a PUBLIC_BOOK zone. */ async function setupEnabledPlusPublicBook() { @@ -142,7 +147,7 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { return { pid, enabledZid, publicZid, enabledSeat, publicSeat }; } - test('B1: zone admin books-as a user in the zone they administer', async ({ page }) => { + test('B1: zone admin books-for a user in the zone they administer', async ({ page }) => { const { enabledSeat } = await setupEnabledPlusPublicBook(); await logIn(page, USER1); const resp = await apiApply(page, { book: { sid: enabledSeat, login: 'user2', dates: [slot(1)] } }); @@ -150,7 +155,7 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(await countBookings('user2', enabledSeat)).toBe(1); }); - test('B2: zone admin CANNOT book-as in a public-book zone they do not administer', async ({ page }) => { + test('B2: zone admin CANNOT book-for in a public-book zone they do not administer', async ({ page }) => { const { publicSeat } = await setupEnabledPlusPublicBook(); await logIn(page, USER1); // user1 is admin of the enabled zone but only an implicit USER of the public zone @@ -160,8 +165,8 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(await countBookings('user2', publicSeat)).toBe(0); }); - test('B3: admin of a PUBLIC_BOOK zone can book-as any user', async ({ page }) => { - const pid = await createPlan('PublicBook BookAs Plan'); + test('B3: admin of a PUBLIC_BOOK zone can book-for any user', async ({ page }) => { + const pid = await createPlan('PublicBook BookFor Plan'); const zid = await createZone('PB Zone', ZONE_TYPE_PUBLIC_BOOK); const [seatId] = await addSeats(pid, zid, ['PB.1']); await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); @@ -173,8 +178,8 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(await countBookings('user3', seatId)).toBe(1); }); - test('B4: admin of a PUBLIC_VIEW zone can book-as an explicit USER but not a view-only user', async ({ page }) => { - const pid = await createPlan('PublicView BookAs Plan'); + test('B4: admin of a PUBLIC_VIEW zone can book-for both an explicit USER and a view-only user', async ({ page }) => { + const pid = await createPlan('PublicView BookFor Plan'); const zid = await createZone('PV Zone', ZONE_TYPE_PUBLIC_VIEW); const [seatId] = await addSeats(pid, zid, ['PV.1']); await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); @@ -186,19 +191,54 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(ok.status()).toBe(200); expect(await countBookings('user2', seatId)).toBe(1); - // user3 only has implicit VIEWER via PUBLIC_VIEW → cannot be booked for - const denied = await apiApply(page, { book: { sid: seatId, login: 'user3', dates: [slot(2)] } }); - expect(denied.status()).toBe(403); - expect((await denied.json()).code).toBe(104); - expect(await countBookings('user3', seatId)).toBe(0); + // user3 only has implicit VIEWER via PUBLIC_VIEW — under book-for, membership + // (not role <= USER) is all that's required, since the admin overrides the + // viewer restriction for this booking. + const alsoOk = await apiApply(page, { book: { sid: seatId, login: 'user3', dates: [slot(2)] } }); + expect(alsoOk.status()).toBe(200); + expect(await countBookings('user3', seatId)).toBe(1); + }); + + test('B5: book-for overrides a seat assignment to someone else', async ({ page }) => { + const pid = await createPlan('BookFor Assignment Override Plan'); + const zid = await createZone('BA Assign Zone', ZONE_TYPE_ENABLED); + const [seatId] = await addSeats(pid, zid, ['BA.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await assignZoneRole(zid, 'user3', ZONE_ROLE_USER); + await assignSeat(seatId, 'user3'); // seat is assigned to user3 only + + await logIn(page, USER1); + // A regular (non-book-for) booking by user2 would be rejected with 106 — + // but the zone admin booking FOR user2 overrides the assignment. + const resp = await apiApply(page, { book: { sid: seatId, login: 'user2', dates: [slot(1)] } }); + expect(resp.status()).toBe(200); + expect(await countBookings('user2', seatId)).toBe(1); + }); + + test('B6: book-for overrides a seat\'s days-in-advance assignment window', async ({ page }) => { + const pid = await createPlan('BookFor Days-In-Advance Override Plan'); + const zid = await createZone('BA Dia Zone', ZONE_TYPE_ENABLED); + const [seatId] = await addSeats(pid, zid, ['DIA.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await assignSeat(seatId, 'user2', 0); // user2 may normally only book 0 days out + + await logIn(page, USER1); + // 3 days out: beyond the assignment's 0-days-in-advance window (would + // normally be 110), but within the default 1-week global booking horizon + // (code 103), so this isolates the assignment-window override. + const resp = await apiApply(page, { book: { sid: seatId, login: 'user2', dates: [slot(3)] } }); + expect(resp.status()).toBe(200); + expect(await countBookings('user2', seatId)).toBe(1); }); }); // --------------------------------------------------------------------------- -// C. Auto-book ("find me a seat") as another user +// C. Auto-book ("find me a seat") for another user // --------------------------------------------------------------------------- -test.describe('auto-book as another user', () => { +test.describe('auto-book for another user', () => { async function autoBook(page: any, pid: number, dates: object[], login?: string) { const data: any = { dates }; @@ -210,8 +250,8 @@ test.describe('auto-book as another user', () => { }); } - test('C1: a zone admin can auto-book as another user', async ({ page }) => { - const pid = await createPlan('AutoBookAs Plan'); + test('C1: a zone admin can auto-book for another user', async ({ page }) => { + const pid = await createPlan('AutoBookFor Plan'); const zid = await createZone('ABA Zone', ZONE_TYPE_ENABLED); await addSeats(pid, zid, ['A.1', 'A.2']); await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); @@ -229,8 +269,8 @@ test.describe('auto-book as another user', () => { expect(r.rows[0].cnt).toBeGreaterThan(0); }); - test('C2: auto-book-as is confined — cannot book into a zone the actor does not administer', async ({ page }) => { - const pid = await createPlan('AutoBookAs Confined Plan'); + test('C2: auto-book-for is confined — cannot book into a zone the actor does not administer', async ({ page }) => { + const pid = await createPlan('AutoBookFor Confined Plan'); const zidA = await createZone('Conf A', ZONE_TYPE_ENABLED); const zidB = await createZone('Conf B', ZONE_TYPE_ENABLED); const [seatA] = await addSeats(pid, zidA, ['CA.1']); @@ -238,7 +278,7 @@ test.describe('auto-book as another user', () => { await assignZoneRole(zidA, 'user1', ZONE_ROLE_ADMIN); await assignZoneRole(zidB, 'user2', ZONE_ROLE_USER); // user2 has no role in zone A, and user1 is not admin of zone B. - // user1 auto-books-as user2: subject has no accessible seat in user1's managed zone A, + // user1 auto-books-for user2: subject has no accessible seat in user1's managed zone A, // so the endpoint rejects with 403/104. await logIn(page, USER1); @@ -249,8 +289,8 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user2', seatB)).toBe(0); }); - test('C2b: auto-book-as lands the subject in a zone the actor administers', async ({ page }) => { - const pid = await createPlan('AutoBookAs Positive Plan'); + test('C2b: auto-book-for lands the subject in a zone the actor administers', async ({ page }) => { + const pid = await createPlan('AutoBookFor Positive Plan'); const zidA = await createZone('Pos A', ZONE_TYPE_ENABLED); const zidB = await createZone('Pos B', ZONE_TYPE_ENABLED); const [seatA] = await addSeats(pid, zidA, ['PA.1']); @@ -259,7 +299,7 @@ test.describe('auto-book as another user', () => { await assignZoneRole(zidA, 'user2', ZONE_ROLE_USER); await assignZoneRole(zidB, 'user2', ZONE_ROLE_USER); // user1 administers zone A only; user2 has USER in both A and B. - // Auto-book-as must confine to zone A — user2 lands in A, never B. + // Auto-book-for must confine to zone A — user2 lands in A, never B. await logIn(page, USER1); const resp = await autoBook(page, pid, [slot(1)], 'user2'); @@ -270,8 +310,8 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user2', seatB)).toBe(0); }); - test('C3: a site admin can auto-book as anyone', async ({ page }) => { - const pid = await createPlan('AutoBookAs Admin Plan'); + test('C3: a site admin can auto-book for anyone', async ({ page }) => { + const pid = await createPlan('AutoBookFor Admin Plan'); const zid = await createZone('ABA Admin Zone', ZONE_TYPE_ENABLED); await addSeats(pid, zid, ['AA.1']); await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); @@ -288,8 +328,8 @@ test.describe('auto-book as another user', () => { expect(r.rows[0].cnt).toBeGreaterThan(0); }); - test('C4: a non-admin cannot auto-book as another user', async ({ page }) => { - const pid = await createPlan('AutoBookAs Denied Plan'); + test('C4: a non-admin cannot auto-book for another user', async ({ page }) => { + const pid = await createPlan('AutoBookFor Denied Plan'); const zid = await createZone('ABA Denied Zone', ZONE_TYPE_ENABLED); await addSeats(pid, zid, ['AD.1']); await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); // user2 is only a USER here @@ -300,7 +340,7 @@ test.describe('auto-book as another user', () => { expect((await resp.json()).code).toBe(104); }); - test('C5: a zone admin can auto-book as another user via the FAB (UI)', async ({ page }) => { + test('C5: a zone admin can auto-book for another user via the FAB (UI)', async ({ page }) => { // Plan 1 / Zone 1A: user1 is admin, user2 is a USER (via group_1a). await logIn(page, USER1); await page.goto('/plan/1'); @@ -308,9 +348,9 @@ test.describe('auto-book as another user', () => { await pickFirstDate(page); await page.waitForTimeout(400); - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially('Bar', { delay: 50 }); + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially('Bar', { delay: 50 }); const item = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); await expect(item).toBeVisible({ timeout: 5000 }); await item.click(); @@ -391,7 +431,7 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user3', seatId)).toBe(1); }); - test('C10: release gate — auto-book-as cannot release a booking in an unmanaged same-group zone', async ({ page }) => { + test('C10: release gate — auto-book-for cannot release a booking in an unmanaged same-group zone', async ({ page }) => { const pid = await createPlan('Release Gate Plan'); const zidA = await createZone('RelGrp A', ZONE_TYPE_ENABLED, 'relGrp'); const zidB = await createZone('RelGrp B', ZONE_TYPE_ENABLED, 'relGrp'); @@ -402,7 +442,7 @@ test.describe('auto-book as another user', () => { await assignZoneRole(zidB, 'user2', ZONE_ROLE_USER); // user1 administers zone A only; zone B is in the same group but not administered. const s = slot(1); - // Pre-book user2 in zone B for the morning only (09:00-13:00). Auto-book-as + // Pre-book user2 in zone B for the morning only (09:00-13:00). Auto-book-for // requests the full day (09:00-17:00) — the exact-match shortcut won't fire // because the times differ. A candidate in zone A would conflict with B's // same-group booking, and can_release must block releasing B's booking. @@ -451,16 +491,31 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user2', seatA)).toBe(1); expect(await countBookings('user2', seatB)).toBe(1); }); + + test('C12: a zone admin can auto-book for a viewer into a zone they administer', async ({ page }) => { + const pid = await createPlan('AutoBookFor Viewer Plan'); + const zid = await createZone('ABA Viewer Zone', ZONE_TYPE_ENABLED); + const [seatId] = await addSeats(pid, zid, ['AV.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_VIEWER); // viewer only, not a booking role + + await logIn(page, USER1); + const resp = await autoBook(page, pid, [slot(1)], 'user2'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body.booked.length).toBe(1); + expect(await countBookings('user2', seatId)).toBe(1); + }); }); // --------------------------------------------------------------------------- -// D. Multi-zone exclusivity (book_overlap trigger) combined with book-as +// D. Multi-zone exclusivity (book_overlap trigger) combined with book-for // --------------------------------------------------------------------------- -test.describe('multi-zone exclusivity with book-as', () => { +test.describe('multi-zone exclusivity with book-for', () => { - test('D1: same zone-group enforces one seat per group (second book-as rejected)', async ({ page }) => { - const pid = await createPlan('Group BookAs Plan'); + test('D1: same zone-group enforces one seat per group (second book-for rejected)', async ({ page }) => { + const pid = await createPlan('Group BookFor Plan'); const zidA = await createZone('Grp A', ZONE_TYPE_ENABLED, 'grpG'); const zidB = await createZone('Grp B', ZONE_TYPE_ENABLED, 'grpG'); const [seatA] = await addSeats(pid, zidA, ['GA.1']); @@ -533,12 +588,12 @@ test.describe('multi-zone exclusivity with book-as', () => { }); // --------------------------------------------------------------------------- -// E. getSeats book-as guard +// E. getSeats book-for guard // --------------------------------------------------------------------------- -test.describe('getSeats book-as guard', () => { +test.describe('getSeats book-for guard', () => { - test('E1: a non-admin cannot use the book-as query parameters', async ({ page }) => { + test('E1: a non-admin cannot use the book-for query parameters', async ({ page }) => { // Zone 1A is enabled by default; user2 is a USER there (via group_1a), not an admin. await logIn(page, USER2); const resp = await page.request.get('/xhr/plan/getSeats/1?login=user1', { maxRedirects: 0 }); @@ -548,7 +603,7 @@ test.describe('getSeats book-as guard', () => { }); // --------------------------------------------------------------------------- -// F. Cross-zone book-as release confinement (security regression) +// F. Cross-zone book-for release confinement (security regression) // // Guards the invariant that a zone admin who administers only Z1 (but is a // mere USER in Z2, same zone group) can NEVER release another user's Z2 booking @@ -559,7 +614,7 @@ test.describe('getSeats book-as guard', () => { // (See plan-expand-user-to-zone-roles.md §3.3.) // --------------------------------------------------------------------------- -test.describe('cross-zone book-as release confinement', () => { +test.describe('cross-zone book-for release confinement', () => { async function autoBook(page: any, pid: number, dates: object[], login?: string) { const data: any = { dates }; @@ -571,10 +626,10 @@ test.describe('cross-zone book-as release confinement', () => { }); } - test('F1: auto-book-as never releases a booking in an unmanaged same-group zone', async ({ page }) => { + test('F1: auto-book-for never releases a booking in an unmanaged same-group zone', async ({ page }) => { // Z1 and Z2 share a zone group. A1 (user1) is ADMIN on Z1 but only USER on // Z2. U (user2) has USER access to both. U already holds a seat in Z2. - // A1 auto-books-as U for the same day: a Z1 candidate would conflict with U's + // A1 auto-books-for U for the same day: a Z1 candidate would conflict with U's // same-group Z2 booking, but can_release must reject releasing it (Z2 is not // in A1's manageableZids). The day is returned unbookable / not_extended, and // U's Z2 booking survives untouched. @@ -589,7 +644,7 @@ test.describe('cross-zone book-as release confinement', () => { await assignZoneRole(zid2, 'user2', ZONE_ROLE_USER); // U can book Z2 const s = slot(1); - // U holds a partial-day booking in Z2 (09:00-13:00). Auto-book-as requests + // U holds a partial-day booking in Z2 (09:00-13:00). Auto-book-for requests // the full day (09:00-17:00), so the exact-match shortcut won't fire and a // Z1 candidate would conflict with U's same-group Z2 booking — can_release // must block releasing it (Z2 is not in A1's manageableZids). @@ -638,7 +693,7 @@ test.describe('cross-zone book-as release confinement', () => { test('F3: apply() book only inserts — a same-group conflict is rejected, never silently released', async ({ page }) => { // The book action never releases a conflicting booking: it inserts only, and - // the DB trigger rejects a same-group overlap (109). A1 books-as U on Z1 for + // the DB trigger rejects a same-group overlap (109). A1 books-for U on Z1 for // the same slot U already holds in Z2 (same group) → 109, and U's Z2 booking // is untouched. const pid = await createPlan('XConf Book Plan'); diff --git a/e2e/tests/zone-admin/assignment.spec.ts b/e2e/tests/zone-admin/assignment.spec.ts index bcc8b010..1775b240 100644 --- a/e2e/tests/zone-admin/assignment.spec.ts +++ b/e2e/tests/zone-admin/assignment.spec.ts @@ -195,7 +195,7 @@ test.describe('edit seat modal UI', () => { await addInput.pressSequentially('Bar', { delay: 50 }); // 2.x renders every autocomplete's dropdown items on init (not only when // typed into), and the dropdown is moved to document.body, so a bare - // `ul.autocomplete-content li` also matches the BookAs dropdown on this + // `ul.autocomplete-content li` also matches the BookFor dropdown on this // page. Scope to this input's own dropdown via its data-target id. const dropdownId = await addInput.getAttribute('data-target'); const dropdownItem = page.locator(`ul#${dropdownId} li`, { hasText: 'Bar [user2]' }); diff --git a/e2e/tests/zone-admin/book-as.spec.ts b/e2e/tests/zone-admin/book-for.spec.ts similarity index 91% rename from e2e/tests/zone-admin/book-as.spec.ts rename to e2e/tests/zone-admin/book-for.spec.ts index 73cd231c..59a535ad 100644 --- a/e2e/tests/zone-admin/book-as.spec.ts +++ b/e2e/tests/zone-admin/book-for.spec.ts @@ -11,7 +11,7 @@ import { } from '../../helpers/booking'; import { pickFirstDate } from '../../helpers/zone-admin'; -test.describe('booking as another user', () => { +test.describe('booking for another user', () => { test('zone admin can book a seat for another user via API', async ({ page }) => { await logIn(page, USER1); @@ -44,7 +44,7 @@ test.describe('booking as another user', () => { expect(result.rows[0].cnt).toBe(0); }); - test('zone admin can book a seat via "Book as" UI (autocomplete)', async ({ page }) => { + test('zone admin can book a seat via "Book for" UI (autocomplete)', async ({ page }) => { await logIn(page, USER1); await page.goto('/plan/1'); await waitForSeatsLoaded(page); @@ -52,9 +52,9 @@ test.describe('booking as another user', () => { await pickFirstDate(page); await page.waitForTimeout(400); - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially('Bar', { delay: 50 }); + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially('Bar', { delay: 50 }); const dropdownItem = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); await expect(dropdownItem).toBeVisible({ timeout: 5000 }); await dropdownItem.click(); diff --git a/js/app/main.js b/js/app/main.js index affa6c9c..44b01488 100644 --- a/js/app/main.js +++ b/js/app/main.js @@ -83,7 +83,7 @@ async function boot() { // Opt every WARP form field into Materialize 2.x's built-in `.outlined` // text-field variant (bordered box). Scoped to .warp-fields containers so - // the nav search and the zone "book-as" underline field are left untouched; + // the nav search and the zone "book-for" underline field are left untouched; // chips are excluded (they are a multi-value container, not a text field). document.querySelectorAll('.warp-fields .input-field:not(.chips)').forEach(function (el) { el.classList.add('outlined'); diff --git a/js/app/router.js b/js/app/router.js index b1438ad6..881cf866 100644 --- a/js/app/router.js +++ b/js/app/router.js @@ -72,7 +72,7 @@ export async function transition(pathname, search) { // 2. Await the previous view's unmount, then clear the mount point. // Null the handle BEFORE awaiting it: two rapid navigations could both // observe a non-null currentUnmount and double-invoke the same unmount - // (double table.destroy()/om.destroy()/BookAs.reset()), where the second + // (double table.destroy()/om.destroy()/BookFor.reset()), where the second // throw is swallowed by the catch below and any teardown after it is // silently skipped (leaked observers/sliders across the navigation). var prevUnmount = currentUnmount; @@ -151,7 +151,7 @@ export async function transition(pathname, search) { // a slow getContext): the just-returned unmount is NOT assigned to // currentUnmount (the seq check below would skip it), so without // invoking it here B's OfficeMap listeners/theme observer leak - // permanently and its PlanUserData/BookAs singletons stay init'd, + // permanently and its PlanUserData/BookFor singletons stay init'd, // then C's mount throws "already initialized" -> "Page not found". // Tear B down now so C mounts clean. currentController = null; diff --git a/js/base/style.css b/js/base/style.css index a791f1e7..8b488a5d 100644 --- a/js/base/style.css +++ b/js/base/style.css @@ -482,16 +482,16 @@ nav .dropdown-content li > span { margin: 0 32px; } -.book-as_container { +.book-for_container { padding-top: 4px; } -/* The "Book as" autocomplete in the zone sidepanel. Keep it as a clean +/* The "Book for" autocomplete in the zone sidepanel. Keep it as a clean underline-style input (no filled box) but give it enough height so the value and label don't overlap or look cramped in the narrow panel. */ -.book-as_container.input-field { +.book-for_container.input-field { margin-top: 0; } -.book-as_container .book-as_input { +.book-for_container .book-for_input { background-color: transparent; border: none; border-bottom: 1px solid var(--warp-grey-muted); @@ -503,8 +503,8 @@ nav .dropdown-content li > span { color: var(--warp-text); font-size: 1rem; } -.book-as_container.input-field > label, -.book-as_container.input-field > label.active { +.book-for_container.input-field > label, +.book-for_container.input-field > label.active { position: absolute; top: 2px; left: 0 !important; @@ -513,7 +513,7 @@ nav .dropdown-content li > span { font-size: 0.75rem; color: var(--warp-grey-text); } -.book-as_container .book-as_input:focus { +.book-for_container .book-for_input:focus { border-bottom-color: var(--warp-primary); outline: none; } @@ -1161,14 +1161,14 @@ nav .dropdown-content li > span { .plan_sidepanel_topcontainer { display: flex; flex-direction: row; - flex: 0 0 auto; /* book-as (+ close) pinned at the top */ - margin-bottom: var(--warp-space-3); /* gap between the book-as row and the calendar widget (point 4) */ + flex: 0 0 auto; /* book-for (+ close) pinned at the top */ + margin-bottom: var(--warp-space-3); /* gap between the book-for row and the calendar widget (point 4) */ } .plan_datetime_container { display: flex; flex-direction: column; /* slider on top, calendar below (point 2) */ - flex: 1 1 auto; /* fill the panel below the book-as row */ + flex: 1 1 auto; /* fill the panel below the book-for row */ min-height: 0; /* so the calendar grid can scroll instead of growing the panel */ gap: var(--warp-space-3); /* padding between the slider and the calendar */ } diff --git a/js/views/html/plan.html b/js/views/html/plan.html index 6288360f..7520af76 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -83,7 +83,7 @@
- The seat is not available for booking: no dates selected, disabled, or view-only zone. + The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon. @@ -100,9 +100,9 @@
-
- - +
+ +
diff --git a/js/views/modules/bookas.js b/js/views/modules/bookfor.js similarity index 74% rename from js/views/modules/bookas.js rename to js/views/modules/bookfor.js index bb1a43d3..cf74094c 100644 --- a/js/views/modules/bookas.js +++ b/js/views/modules/bookfor.js @@ -2,10 +2,10 @@ import PlanUserData from "./planuserdata"; -export default function BookAs() { +export default function BookFor() { - if (typeof(BookAs.instance) !== 'undefined') - throw Error('BookAs is a singleton'); + if (typeof(BookFor.instance) !== 'undefined') + throw Error('BookFor is a singleton'); this.listeners = { init: new Set(), @@ -14,18 +14,18 @@ export default function BookAs() { this.changed = false; - BookAs.instance = this; + BookFor.instance = this; } -BookAs.getInstance = function() { +BookFor.getInstance = function() { - if (typeof(BookAs.instance) === 'undefined') - return new BookAs(); + if (typeof(BookFor.instance) === 'undefined') + return new BookFor(); - return BookAs.instance; + return BookFor.instance; } -BookAs.prototype.on = function (type,listener) { +BookFor.prototype.on = function (type,listener) { if (type in this.listeners && typeof(listener) === 'function') { this.listeners[type].add(listener); @@ -39,10 +39,10 @@ BookAs.prototype.on = function (type,listener) { } // if skipMine this function will return null in case my login is selected -BookAs.prototype.getSelectedLogin = function(skipMine) { +BookFor.prototype.getSelectedLogin = function(skipMine) { if (!('selectedLogin' in this)) - throw Error("BookAs not initialized") + throw Error("BookFor not initialized") if (skipMine && this.zoneUserData.whoami() === this.selectedLogin) return null; @@ -50,10 +50,10 @@ BookAs.prototype.getSelectedLogin = function(skipMine) { return this.selectedLogin; } -BookAs.prototype._setSelectedLogin = function(login) { +BookFor.prototype._setSelectedLogin = function(login) { if (!('selectedLogin' in this)) - throw Error("BookAs not initialized") + throw Error("BookFor not initialized") if (!(login in this.zoneUserData.data)) login = this.zoneUserData.whoami(); @@ -64,7 +64,7 @@ BookAs.prototype._setSelectedLogin = function(login) { } } -BookAs.prototype._callChangeListeners = function() { +BookFor.prototype._callChangeListeners = function() { if (this.changed) { for (let l of this.listeners['change']) @@ -74,7 +74,7 @@ BookAs.prototype._callChangeListeners = function() { } -BookAs.prototype._onBlur = function(e) { +BookFor.prototype._onBlur = function(e) { let target = e.target; @@ -88,7 +88,7 @@ BookAs.prototype._onBlur = function(e) { } } -BookAs.prototype._onAutocomplete = function(el,sel) { +BookFor.prototype._onAutocomplete = function(el,sel) { var login = PlanUserData.makeUserStrRev(sel); this._setSelectedLogin(login); @@ -96,7 +96,7 @@ BookAs.prototype._onAutocomplete = function(el,sel) { this._callChangeListeners(); } -BookAs.prototype._onKeyUp = function(e) { +BookFor.prototype._onKeyUp = function(e) { let target = e.target; @@ -111,25 +111,25 @@ BookAs.prototype._onKeyUp = function(e) { } } -BookAs.prototype._init = function(zoneUserData) { +BookFor.prototype._init = function(zoneUserData) { if ('selectedLogin' in this) - throw Error("BookAs can be initialized only once") + throw Error("BookFor can be initialized only once") this.zoneUserData = zoneUserData; this.selectedLogin = this.zoneUserData.whoami(); - this.bookAsData = {}; + this.bookForData = {}; for (let d of this.zoneUserData.formatedIterator()) { - this.bookAsData[ d] = null; + this.bookForData[ d] = null; } this.autocompleteInstances = []; - var bookAsElements = document.getElementsByClassName('book-as_input'); - for (let el of bookAsElements) { + var bookForElements = document.getElementsByClassName('book-for_input'); + for (let el of bookForElements) { this.autocompleteInstances.push( M.Autocomplete.init(el, { - data: this.bookAsData, + data: this.bookForData, dropdownOptions: { constrainWidth: false, container: document.body @@ -139,7 +139,7 @@ BookAs.prototype._init = function(zoneUserData) { })); } - for (let el of bookAsElements) { + for (let el of bookForElements) { this._onBlur({target: el}); // show selectedLoginStr el.addEventListener('blur',this._onBlur.bind(this)); el.addEventListener('keyup',this._onKeyUp.bind(this)); @@ -153,14 +153,14 @@ BookAs.prototype._init = function(zoneUserData) { // Clears this singleton's per-mount state (the plan view's unmount() calls // this) so _init() can run again on the next admin plan mount instead of -// throwing "can be initialized only once". Its book-as_input DOM elements are +// throwing "can be initialized only once". Its book-for_input DOM elements are // gone already (router.js replaces #view-root wholesale) but their Materialize // Autocomplete dropdown panels are appended to document.body — outside // #view-root — so they'd leak across navigations without an explicit destroy. -// Also clears 'change'/'init' listeners: each plan mount's initBookAs() +// Also clears 'change'/'init' listeners: each plan mount's initBookFor() // registers its own; left stale, a second mount would fire the FIRST mount's // listener too, calling seatFactory.setLogin() on an abandoned factory. -BookAs.prototype.reset = function() { +BookFor.prototype.reset = function() { if (this.autocompleteInstances) { for (let inst of this.autocompleteInstances) { if (inst && typeof inst.destroy === 'function') inst.destroy(); @@ -168,7 +168,7 @@ BookAs.prototype.reset = function() { } delete this.selectedLogin; delete this.zoneUserData; - delete this.bookAsData; + delete this.bookForData; delete this.autocompleteInstances; this.changed = false; this.listeners['init'].clear(); @@ -176,5 +176,5 @@ BookAs.prototype.reset = function() { } PlanUserData.getInstance().on('load',function(zoneUserData) { - BookAs.getInstance()._init(zoneUserData); + BookFor.getInstance()._init(zoneUserData); }); diff --git a/js/views/modules/planuserdata.js b/js/views/modules/planuserdata.js index b44a90b4..72388f93 100644 --- a/js/views/modules/planuserdata.js +++ b/js/views/modules/planuserdata.js @@ -61,8 +61,8 @@ PlanUserData.prototype.getData = function() { } // Clears loaded data so init() can reload it on a fresh SPA mount (the plan -// view's unmount() calls this). Deliberately keeps this.listeners — bookas.js -// wires BookAs to the 'load' event once, at module-import time, against this +// view's unmount() calls this). Deliberately keeps this.listeners — bookfor.js +// wires BookFor to the 'load' event once, at module-import time, against this // same singleton instance; replacing the instance instead of resetting it // would orphan that wiring. // Bumps the init generation so an in-flight PlanUserData.init() from a previous diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 575bdba6..b78f32aa 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -39,8 +39,7 @@ WarpSeat.SeatStates = { CAN_CHANGE: 6, // seat is already booked by this user, but can be changed (extended, reduced, deleted) CAN_DELETE: 7, // seat is already booked by this user, but cannot be changed CAN_DELETE_EXACT: 8, // seat is already booked by this user, cannot be changed and selected dated are exactly matching booking dates - VIEW_ONLY: 9, // seat is visible but cannot be booked — free (grey empty circle) - VIEW_ONLY_TAKEN: 10 // seat is visible but taken by someone else (grey circle with person) + VIEW_ONLY: 9 // bookable-shaped seat in a zone where this user may only view (free, unassigned) } WarpSeat.Sprites = { @@ -102,7 +101,7 @@ WarpSeatFactory.prototype.getLogin = function() { return this.login; } -// Switch the "acting" login (book-as). Callers must follow this with a full +// Switch the "acting" login (book-for). Callers must follow this with a full // downloadSeatData()/setSeatsData() refresh so every seat — accessible and // conflict — is rebuilt consistently for the new login. (The old partial // onlyOtherZone path is gone: it overwrote accessible seats that happened to @@ -375,32 +374,6 @@ WarpSeat.prototype._updateState = function() { return this.state; } - if (!this.bookable) { - // View-only access (PUBLIC_VIEW zone, or DISABLED zone even for admins) - // Existing own bookings can still be cancelled, but no new bookings allowed. - var hasOwnBooking = false; - for (var i of this._bookingsIterator()) { - if (i.book.login == this.factory.login) { - hasOwnBooking = true; - break; - } - } - if (hasOwnBooking) { - // Allow cancellation only (CAN_DELETE or CAN_DELETE_EXACT), not rebooking. - // Fall through to normal logic to compute isMine/isExact, but then - // override the state to prevent CAN_CHANGE / CAN_REBOOK / CAN_BOOK. - } else { - // No own booking — seat is not bookable, show as view-only or taken. - var isFree = true; - for (var i of this._bookingsIterator()) { - isFree = false; - break; - } - this.state = isFree ? WarpSeat.SeatStates.VIEW_ONLY : WarpSeat.SeatStates.VIEW_ONLY_TAKEN; - return this.state; - } - } - var assignedButNotForMe = false; if (Object.keys(this.assignments).length > 0) { @@ -409,8 +382,19 @@ WarpSeat.prototype._updateState = function() { const hasEveryone = everyoneData !== undefined; const userAssignment = this.assignments[this.factory.login]; const hasUserAssignment = userAssignment !== undefined; - - if (hasUserAssignment || hasEveryone) { + const hasSpecificAssignment = Object.keys(this.assignments).some(k => k !== EVERYONE_KEY); + + if (!this.bookable) { + // Under book-for/viewer access, a specific-login assignment (anyone's, + // including this acting user's own) is informational only — it never + // grants booking rights here, so any named assignee marks the seat as + // assigned to someone. An everyone-only assignment names no one, so it + // carries no information for a non-booker and is ignored (falls through + // to occupancy, i.e. CAN_BOOK below, later demoted to VIEW_ONLY). + if (hasSpecificAssignment) { + assignedButNotForMe = true; + } + } else if (hasUserAssignment || hasEveryone) { // Compute most-permissive days_in_advance across user's own row and everyone row let bestDays; if (hasUserAssignment) { @@ -498,11 +482,16 @@ WarpSeat.prototype._updateState = function() { else this.state = WarpSeat.SeatStates.TAKEN; - // For non-bookable seats with own bookings, only allow cancellation. - // Clamp CAN_CHANGE / CAN_REBOOK / CAN_BOOK → CAN_DELETE. - if (!this.bookable && this.state != WarpSeat.SeatStates.CAN_DELETE_EXACT) { - if (this.state == WarpSeat.SeatStates.CAN_CHANGE || - this.state == WarpSeat.SeatStates.CAN_DELETE) { + // Demote action states to their informational equivalent for !bookable + // seats (view-only zones, or book-for into a zone the actor doesn't + // administer): occupancy/assignment states are permission-independent, + // `bookable` only demotes the action states. CAN_REBOOK doesn't exist yet + // here — it's set in _updateView only for CAN_BOOK, which is already + // demoted below, so it can never fire for a !bookable seat. + if (!this.bookable) { + if (this.state == WarpSeat.SeatStates.CAN_BOOK) { + this.state = WarpSeat.SeatStates.VIEW_ONLY; + } else if (this.state == WarpSeat.SeatStates.CAN_CHANGE) { this.state = WarpSeat.SeatStates.CAN_DELETE; } } @@ -520,8 +509,7 @@ function spriteFor(state, assignedToMe) { case WarpSeat.SeatStates.CAN_CHANGE: return 'yoursChange'; case WarpSeat.SeatStates.CAN_DELETE_EXACT: return 'yours'; case WarpSeat.SeatStates.CAN_DELETE: - case WarpSeat.SeatStates.TAKEN: - case WarpSeat.SeatStates.VIEW_ONLY_TAKEN: return 'taken'; + case WarpSeat.SeatStates.TAKEN: return 'taken'; case WarpSeat.SeatStates.ASSIGNED: return 'assigned'; case WarpSeat.SeatStates.VIEW_ONLY: case WarpSeat.SeatStates.DISABLED: @@ -543,9 +531,9 @@ WarpSeat.prototype._updateView = function() { switch (this.state) { case WarpSeat.SeatStates.CAN_BOOK: - if (!this.bookable) - this.state = WarpSeat.SeatStates.VIEW_ONLY; - else if (this.factory._conflictCount(this.exclusivityKey) > 0) + // !bookable seats never reach _updateView as CAN_BOOK — they were + // already demoted to VIEW_ONLY at the end of _updateState. + if (this.factory._conflictCount(this.exclusivityKey) > 0) this.state = WarpSeat.SeatStates.CAN_REBOOK; // conflict map is final here break; // all other states are already final from _updateState diff --git a/js/views/plan.js b/js/views/plan.js index 9ec6c297..eaf91091 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -6,7 +6,7 @@ import WarpModal from './modules/modal.js'; import {WarpSeatFactory,WarpSeat,EVERYONE_KEY} from './modules/seat.js'; import { OfficeMap } from './modules/officeMap.js'; import PlanUserData from './modules/planuserdata.js'; -import BookAs from './modules/bookas.js'; +import BookFor from './modules/bookfor.js'; import { WarpCalendar } from './modules/calendarGrid.js'; import { M } from '../app/materialize.js'; import warpDialog from '../app/dialog.js'; @@ -632,7 +632,7 @@ export async function mount(ctx) { var state = this.getState(); - if (state == WarpSeat.SeatStates.NOT_AVAILABLE || state == WarpSeat.SeatStates.VIEW_ONLY || state == WarpSeat.SeatStates.VIEW_ONLY_TAKEN) + if (state == WarpSeat.SeatStates.NOT_AVAILABLE || state == WarpSeat.SeatStates.VIEW_ONLY) return; var actions = []; @@ -753,7 +753,7 @@ export async function mount(ctx) { } if (window.warpGlobals.isZoneAdmin) { - let login = BookAs.getInstance().getSelectedLogin(true); + let login = BookFor.getInstance().getSelectedLogin(true); if (login !== null) applyData['book']['login'] = login; } @@ -868,7 +868,6 @@ export async function mount(ctx) { rebook: 'rebook', conflict: 'taken', viewOnly: 'unavailable', - viewOnlyTaken: 'taken', userExact: 'yours', userRebook: 'yoursChange', userConflict: 'taken', @@ -924,9 +923,9 @@ export async function mount(ctx) { } } - function initBookAs(seatFactory) { + function initBookFor(seatFactory) { - BookAs.getInstance().on('change', function(newLogin) { + BookFor.getInstance().on('change', function(newLogin) { // Full refresh under the new acting login. downloadSeatData adds // ?login= when newLogin differs from our own, so the server returns a @@ -970,7 +969,7 @@ export async function mount(ctx) { var payload = { dates: dates }; if (window.warpGlobals.isZoneAdmin) { - let login = BookAs.getInstance().getSelectedLogin(true); + let login = BookFor.getInstance().getSelectedLogin(true); if (login !== null) payload['login'] = login; } @@ -1130,7 +1129,7 @@ export async function mount(ctx) { if (window.warpGlobals.isZoneAdmin) { PlanUserData.init(); - initBookAs(seatFactory); + initBookFor(seatFactory); } return function unmount() { @@ -1143,10 +1142,10 @@ export async function mount(ctx) { if (slider && slider.noUiSlider) slider.noUiSlider.destroy(); // Both are app-wide module singletons (getInstance()) that guard // against double-init — a re-mount (this plan again, or a different - // one) must clear their state or PlanUserData.init()/initBookAs() + // one) must clear their state or PlanUserData.init()/initBookFor() // throw "already initialized" instead of loading fresh data. if (PlanUserData.instance) PlanUserData.instance.reset(); - if (BookAs.instance) BookAs.instance.reset(); + if (BookFor.instance) BookFor.instance.reset(); }; } diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index 3e213bf2..b8f00ba2 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -171,9 +171,9 @@ "The seat is booked by you exactly on the selected date and time.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", "The seat is booked by someone else or not available for booking.": "Der Platz ist von einer anderen Person gebucht oder nicht zur Buchung verfügbar.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "Der Platz ist nicht zur Buchung verfügbar: keine Daten ausgewählt, deaktiviert oder Nur-Ansicht-Zone.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone. Belegte oder zugewiesene Plätze in einer Nur-Ansicht-Zone zeigen weiterhin ihr echtes Symbol.", "The seat is assigned to some people but currently not booked.": "Der Platz ist bestimmten Personen zugewiesen, aber derzeit nicht gebucht.", - "Book as": "Buchen als", + "Book for": "Buchen für", "Seat %{seat_name}": "Sitz %{seat_name}", "Assigned to:": "zugeordnet zu:", "Bookings:": "Buchungen:", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index 97badd97..0896d79d 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -171,9 +171,9 @@ "The seat is booked by you exactly on the selected date and time.": "The seat is booked by you exactly on the selected date and time.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", "The seat is booked by someone else or not available for booking.": "The seat is booked by someone else or not available for booking.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "The seat is not available for booking: no dates selected, disabled, or view-only zone.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.", "The seat is assigned to some people but currently not booked.": "The seat is assigned to some people but currently not booked.", - "Book as": "Book as", + "Book for": "Book for", "Seat %{seat_name}": "Seat %{seat_name}", "Assigned to:": "Assigned to:", "Bookings:": "Bookings:", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index 1c8043e6..d924b3cc 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -171,9 +171,9 @@ "The seat is booked by you exactly on the selected date and time.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", "The seat is booked by someone else or not available for booking.": "Este asiento está reservado por otra persona o no está disponible para reservar.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "El asiento no está disponible para reservar: no hay fechas seleccionadas, está deshabilitado o es una zona de solo lectura.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura. Los asientos ocupados o asignados en una zona de solo lectura siguen mostrando su icono real.", "The seat is assigned to some people but currently not booked.": "El asiento está asignado a algunas personas pero actualmente no está reservado.", - "Book as": "Reservar como", + "Book for": "Reservar para", "Seat %{seat_name}": "Asiento %{seat_name}", "Assigned to:": "Asignado a:", "Bookings:": "Reservas:", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index 2420c591..b785a67c 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -171,9 +171,9 @@ "The seat is booked by you exactly on the selected date and time.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", "The seat is booked by someone else or not available for booking.": "Le siège est réservé par quelqu'un d'autre ou non disponible à la réservation.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "Le siège n'est pas disponible à la réservation : aucune date sélectionnée, désactivé, ou zone en lecture seule.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule. Les sièges occupés ou assignés dans une zone en lecture seule affichent toujours leur icône réelle.", "The seat is assigned to some people but currently not booked.": "Le siège est assigné à certaines personnes mais actuellement non réservé.", - "Book as": "Réserver en tant que", + "Book for": "Réserver pour", "Seat %{seat_name}": "Siège %{seat_name}", "Assigned to:": "Affecté à:", "Bookings:": "Réservations:", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index bdafff44..9c02dea9 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -171,9 +171,9 @@ "The seat is booked by you exactly on the selected date and time.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", "The seat is booked by someone else or not available for booking.": "Miejsce jest zarezerwowane przez kogoś innego lub niedostępne do rezerwacji.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "Miejsce jest niedostępne do rezerwacji: nie wybrano dat, wyłączone lub strefa tylko do odczytu.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu. Zajęte lub przypisane miejsca w strefie tylko do odczytu nadal pokazują swoją prawdziwą ikonę.", "The seat is assigned to some people but currently not booked.": "Miejsce jest przypisane do niektórych osób, ale obecnie nie jest zarezerwowane.", - "Book as": "Zarezerwuj jako", + "Book for": "Zarezerwuj dla", "Seat %{seat_name}": "Miejsce %{seat_name}", "Assigned to:": "Przypisane do:", "Bookings:": "Rezerwacje:", diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index 338f1d42..3db44352 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -113,17 +113,19 @@ def getContext(pid): # } # } # -# bookable: true if the relevant user's effective role for this seat's zone is -# ≤ ZONE_ROLE_USER (i.e. they can actually book the seat). False for -# VIEWER-only access or for seats in DISABLED zones (even if admin there -# — disabled zones are fully locked down and should not allow booking -# from the UI). The "relevant user" is normally the authenticated user, -# but under book-as (?login=target) it is the target user, so the UI -# only offers seats apply()/autoBook would actually let them book. +# bookable: true if the acting user can book this seat. Normally the +# authenticated user's effective role for the zone must be +# ≤ ZONE_ROLE_USER. False for VIEWER-only access or for seats in +# DISABLED zones (even if admin there — disabled zones are fully +# locked down). Under book-for (?login=target) the acting user is +# the admin, not the target: bookable reflects whether the admin +# administers the zone and the target is a member of it, so the UI +# only offers seats apply()/autoBook would actually let them book for. # # Optional query args: -# login=string – view/operate the plan as this login (book-as; requires -# plan admin). Conflict bookings are scoped to this login. +# login=string – book for this login (requires the acting user to +# administer the seat's zone). Conflict bookings are +# scoped to this login. def resolve_conflict_bookings(conflict_zids, login, open_tz, tr, exclude_sids=()): """Resolve a user's conflicting bookings for the plan being opened. @@ -243,7 +245,7 @@ def getSeats(pid): accessible_zids = set(effective_roles.keys()) is_plan_admin = any(r <= ZONE_ROLE_ADMIN for r in effective_roles.values()) - # Book-as: ?login=target lets a plan admin view and operate the plan as + # Book-for: ?login=target lets a plan admin view and operate the plan for # another user (requires plan admin). Fetched once and reused below. targetLogin = flask.request.args.get('login') @@ -256,7 +258,7 @@ def getSeats(pid): # at least one administered zone (non-public) or, for a public plan, the # admin has a synthetic row on the public zone and every user has one # too — so any non-group user validates. The view's account_type < 100 - # filter excludes group logins (more correct for book-as than the old + # filter excludes group logins (more correct for book-for than the old # any_public branch, which only checked Users.login existence). loginInPlan = UserToZoneRoles.select(SQL_ONE) \ .where(UserToZoneRoles.zid.in_(list(accessible_zids))) \ @@ -266,22 +268,22 @@ def getSeats(pid): return {"msg": "Forbidden", "code": 132}, 403 # Whose booking permission decides `bookable`. Normally the authenticated - # user. Under book-as (?login=target) the admin views the plan through the - # target's eyes, so `bookable` must reflect whether *target* can book each - # seat — otherwise seats the target can't book (e.g. VIEWER-only zones) would - # look bookable yet apply()/autoBook would reject them (code 104). Visibility - # of seats still follows the admin's (effective_roles) access. - bookable_roles = effective_roles + # user (role <= USER). Under book-for (?login=target) the actor books ON + # BEHALF OF the target: `bookable` reflects whether the *actor* administers + # the zone (role <= ADMIN) and the target is a member of it — the actor can + # override seat-level restrictions (assignments) there, so target role value + # doesn't matter, only that a UserToZoneRoles row exists. Visibility of seats + # still follows the admin's (effective_roles) access. + target_membership_zids = None if targetLogin is not None and targetLogin != flask.g.login: # Roles from the view are already effective — query once for the target. - target_roles = {} + target_membership_zids = set() if accessible_zids: - for row in UserToZoneRoles.select(UserToZoneRoles.zid, UserToZoneRoles.zone_role) \ + for row in UserToZoneRoles.select(UserToZoneRoles.zid) \ .where(UserToZoneRoles.zid.in_(list(accessible_zids))) \ .where(UserToZoneRoles.login == targetLogin) \ .iterator(): - target_roles[row['zid']] = row['zone_role'] - bookable_roles = target_roles + target_membership_zids.add(row['zid']) tr = utils.getTimeRange(tz=plan_tz) usedZids = set() @@ -313,14 +315,18 @@ def getSeats(pid): if not s['enabled'] and zone_role > ZONE_ROLE_ADMIN: continue - book_role = bookable_roles.get(zid) + if target_membership_zids is not None: + bookable = zone_role <= ZONE_ROLE_ADMIN and zid in target_membership_zids \ + and zone_type_map[zid] != ZONE_TYPE_DISABLED + else: + bookable = zone_role <= ZONE_ROLE_USER and zone_type_map[zid] != ZONE_TYPE_DISABLED seatD = { "name": s['name'], "x": s['x'], "y": s['y'], "zid": zid, "enabled": s['enabled'] != 0, - "bookable": book_role is not None and book_role <= ZONE_ROLE_USER and zone_type_map[zid] != ZONE_TYPE_DISABLED, + "bookable": bookable, "book": [] } if s['id'] in assignments: @@ -518,6 +524,12 @@ def apply(): return {"msg": "Forbidden", "code": 103}, 403 sid = apply_data['book']['sid'] + # Explicit book.login means book-for: the actor books on behalf of the + # target. The actor's zone-admin status for this seat was already + # enforced above via seatsReqZoneAdmin (code 102), so here we only + # confirm the target is a member of the seat's zone and skip the + # seat-level assignment checks (106/110) — a zone admin may override them. + is_book_for = 'login' in apply_data['book'] login = apply_data['book'].get('login', flask.g.login) seatZone = Seat.select(Seat.enabled, Seat.zid, Zone.zone_type.alias('zone_type')) \ @@ -535,8 +547,12 @@ def apply(): # bookerRole from the view IS the effective role. isSelfAdminBooking = flask.g.isAdmin and login == flask.g.login - if not isSelfAdminBooking and (bookerRole is None or bookerRole > ZONE_ROLE_USER): - return {"msg": "Forbidden", "code": 104}, 403 + if not isSelfAdminBooking: + if is_book_for: + if bookerRole is None: + return {"msg": "Forbidden", "code": 104}, 403 + elif bookerRole is None or bookerRole > ZONE_ROLE_USER: + return {"msg": "Forbidden", "code": 104}, 403 # Disabled zones cannot be booked at all — even by admins. # Enable the zone first, then book. @@ -546,8 +562,8 @@ def apply(): if not seatZone['enabled']: return {"msg": "Forbidden", "code": 105}, 403 - assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) - if assignedQ.scalar() is not None: + assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) if not is_book_for else None + if assignedQ is not None and assignedQ.scalar() is not None: myAssignments = list(SeatAssign.select(SeatAssign.days_in_advance) .where((SeatAssign.sid == sid) & ((SeatAssign.login == login) | SeatAssign.login.is_null())) @@ -723,15 +739,18 @@ class ApplyError(Exception): } -def runAutoBook(login, pid, dates, allowedZids=None, releaseZids=None): +def runAutoBook(login, pid, dates, allowedZids=None, releaseZids=None, is_book_for=False): """Core autobook algorithm. Selects the best seat on the plan (pid) for the given login and dates. When allowedZids is given, only seats in those zones are eligible — used to - confine a zone admin booking *as* another user to the zones they administer. + confine a zone admin booking *for* another user to the zones they administer. When releaseZids is given, existing bookings may only be deleted if they are - in one of those zones — used to confine the release side of a book-as operation. + in one of those zones — used to confine the release side of a book-for operation. None means no confinement (current behaviour for self-book / iCal / site admin). + When is_book_for is True, the subject's zone role is not required to be + <= USER — a zone admin overrides seat-level membership restrictions when + booking for someone else, so only membership (a UserToZoneRoles row) matters. Returns (result_dict, None) on success or (None, error_code) on failure. Error codes: 103 (time out of window), 140 (overlapping dates), 109 (DB conflict), 130 (bad pid). """ @@ -784,8 +803,10 @@ def runAutoBook(login, pid, dates, allowedZids=None, releaseZids=None): .where(Seat.pid == pid) \ .where(Seat.enabled == True) \ .where(Zone.zone_type != ZONE_TYPE_DISABLED) \ - .where(UserToZoneRoles.login == login) \ - .where(UserToZoneRoles.zone_role <= ZONE_ROLE_USER) + .where(UserToZoneRoles.login == login) + + if not is_book_for: + accessibleSeatQ = accessibleSeatQ.where(UserToZoneRoles.zone_role <= ZONE_ROLE_USER) if allowedZids is not None: allowedZidsList = list(allowedZids) @@ -1054,7 +1075,7 @@ def autoBook(pid): payload = flask.request.get_json() dates = payload['dates'] login = payload.get('login', flask.g.login) - is_book_as = login != flask.g.login + is_book_for = login != flask.g.login # Zones (with type) that have seats on this plan. zone_type_map = { @@ -1075,12 +1096,12 @@ def _rolesFor(loginToCheck): roles[r['zid']] = r['zone_role'] return roles - # Confine book-as to zones the actor administers: the seat pool + # Confine book-for to zones the actor administers: the seat pool # (allowedZids) and the set of bookings that may be released # (releaseZids) are both limited to manageableZids. None means # unconfined (self-book, site admin, iCal). manageableZids = None - if is_book_as and not flask.g.isAdmin: + if is_book_for and not flask.g.isAdmin: actor_roles = _rolesFor(flask.g.login) # Roles from the view are already effective — synthetic public-zone rows # are always USER/VIEWER (never ADMIN), so they cannot inflate @@ -1095,10 +1116,12 @@ def _rolesFor(loginToCheck): subject_roles = _rolesFor(login) zone_iter = manageableZids if manageableZids is not None else zone_type_map.keys() # Roles from the view are already effective; DISABLED is a business rule. + # Under book-for, the actor already administers zone_iter (manageableZids) + # or is a site admin, so only membership is required — not role <= USER. can_book = any( zone_type_map[zid] != ZONE_TYPE_DISABLED and subject_roles.get(zid) is not None - and subject_roles.get(zid) <= ZONE_ROLE_USER + and (is_book_for or subject_roles.get(zid) <= ZONE_ROLE_USER) for zid in zone_iter ) if not can_book: @@ -1106,7 +1129,8 @@ def _rolesFor(loginToCheck): result, err = runAutoBook(login, pid, dates, allowedZids=manageableZids, - releaseZids=manageableZids) + releaseZids=manageableZids, + is_book_for=is_book_for) if err == 103: return {"msg": "Forbidden", "code": 103}, 403 From db3c93ee6ed77ae4c2a10b0d87eb984f5e156b69 Mon Sep 17 00:00:00 2001 From: sebo Date: Fri, 3 Jul 2026 23:52:34 +0200 Subject: [PATCH 02/17] code-rev1 --- AUTOBOOK.md | 29 +++++++++++++++++++++-------- FEATURES.md | 3 ++- PERMISSIONS.md | 20 ++++++++++++-------- js/views/modules/seat.js | 32 ++++++++++++++++++++++++++++++++ js/views/plan.js | 27 ++++++++++++++++++++++++--- warp/static/i18n/de.json | 1 + warp/static/i18n/en.json | 1 + warp/static/i18n/es.json | 1 + warp/static/i18n/fr.json | 1 + warp/static/i18n/pl.json | 1 + warp/xhr/plan.py | 11 +++++++++++ 11 files changed, 107 insertions(+), 20 deletions(-) diff --git a/AUTOBOOK.md b/AUTOBOOK.md index 819a9083..ada3ffb1 100644 --- a/AUTOBOOK.md +++ b/AUTOBOOK.md @@ -53,20 +53,25 @@ overlapping slots in the request or a database conflict at commit time. may be released to make room) is confined to the zones the **actor** administers (unconfined for a site admin — see §3). Within that pool the target only needs to be a **member** of the zone (any role, viewers - included) — not `zone_role ≤ user` — since the actor's admin standing - overrides seat-level restrictions there. A regular user may never book for - someone else (`403`, code 104). + included) — not `zone_role ≤ user` — for the request to be accepted at all. + A regular user may never book for someone else (`403`, code 104). +- **This does not relax seat-level assignments.** The algorithm still only ever + picks a seat the target is eligible for by assignment (§3) — auto-book never + auto-places someone on a seat assigned to a different person, for either + self-book or book-for. That override is manual-book-for-only, where an admin + deliberately picks the exact seat with full knowledge of what it's assigned + to (PERMISSIONS.md §8) — auto-book's heuristic has no such context and must + not silently displace a third party's dedicated desk. The plan must also contain at least one eligible zone for the request (the subject's own zones for self-booking; the actor's administered zones, intersected with the target's membership, for book-for); otherwise the request is rejected (`403`, code 104). -> This mirrors **manual** book-for (PERMISSIONS.md §8): both require the actor -> to administer the zone and only require target membership, not `zone_role ≤ -> user`. Auto-book's difference from manual book-for is purely mechanical — it -> picks the seat instead of the admin picking one explicitly — the permission -> boundary (actor's administered zones) is the same for both. +> This mirrors **manual** book-for (PERMISSIONS.md §8) only on the zone-admin/ +> membership gate: both require the actor to administer the zone and only +> require target membership, not `zone_role ≤ user`. It does **not** mirror +> manual book-for's assignment override (see above and §3). --- @@ -94,6 +99,14 @@ viewer in one of the actor's administered zones is a valid auto-book target there. The target is never placed in a zone the actor doesn't administer, and never in a zone they aren't at least a member of. +`is_book_for` only affects that zone-role gate. The seat-level assignment +classification below it (`seatInfo`: `none` / `direct` / `everyone` / +`blocked`) is computed identically for self-book and book-for — a seat +assigned to a login other than the subject, with no `everyone` row, is +`blocked` and excluded from the candidate pool either way. Only **manual** +book-for (`apply()`) skips the assignment check; the auto-book heuristic never +does. + All of this is scoped to the **plan named in the request** (`pid`) — i.e. the plan currently open in the UI. Auto-book only ever considers the zones and seats on that one plan; it never looks at other plans. diff --git a/FEATURES.md b/FEATURES.md index fa97bce0..fbe3dd2a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -416,7 +416,8 @@ The old "Already booked in another zone" section is gone; an existing booking on ### 8.4 Auto-Book for Zone Admins - Zone admins can use the "Book For" feature with auto-book to find a seat for another user (see §9). -- The seat pool is confined to the zones the **actor** administers on that plan (unconfined for a site admin). Within that pool, the target only needs to be a member of the zone — any role, including viewer — since booking-for overrides seat-level assignment restrictions there. Manual "book for" is likewise scoped to the specific seat's zone adminship, with the same membership-only requirement on the target. +- The seat pool is confined to the zones the **actor** administers on that plan (unconfined for a site admin). Within that pool, the target only needs to be a member of the zone — any role, including viewer — for the request to be accepted. Manual "book for" is likewise scoped to the specific seat's zone adminship, with the same membership-only requirement on the target. +- Unlike manual "book for", auto-book does **not** override seat-level assignments: a seat assigned to a different person is never auto-picked for the target, whether self-booking or booking for someone else. Only a manually-picked seat (an admin deliberately choosing that exact seat) can override an assignment. --- diff --git a/PERMISSIONS.md b/PERMISSIONS.md index e3cdf74d..8c6ad7a7 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -234,11 +234,12 @@ Book-for is fundamentally different from booking as yourself: the actor is acting in their capacity as **zone admin**, not as the target. Self-booking requires the booker's own effective role to be _user_ or better; book-for instead requires the **actor** to administer the zone, and only requires the -**target** to be a _member_ of it — any zone role, viewers included. Because -the actor is the zone's admin, they may also override seat-level assignment -restrictions there (they could reassign the seat themselves anyway) — but only -in zones they administer. Disabled zones reject booking outright, for -everyone, book-for included. +**target** to be a _member_ of it — any zone role, viewers included. When the +actor **manually** picks the seat, they may also override seat-level +assignment restrictions there (they could reassign the seat themselves +anyway) — but only in zones they administer, and only for that deliberate, +explicit pick; the auto-book heuristic never does this (see below). Disabled +zones reject booking outright, for everyone, book-for included. ### Manual book-for (picking a specific seat for someone) @@ -273,9 +274,12 @@ everyone, book-for included. on the plan** may trigger it. The seat pool (and the release side — which bookings of the target's may be displaced) is confined to the zones the **actor** administers (unconfined for a site admin). Within that pool the - target only needs to be a member — any role, viewers included — mirroring - the manual book-for rule; the auto-book heuristic then picks the best seat - among eligible ones exactly as it would for a normal booking. + target only needs to be a member — any role, viewers included — for the + request to be accepted at all; this mirrors the manual book-for rule only on + that zone-admin/membership gate. **It does not mirror the assignment + override**: the heuristic still excludes seats assigned to someone other + than the target, exactly as it would for a self-book — auto-book never + silently displaces a third party's dedicated desk, even under book-for. See [AUTOBOOK.md](AUTOBOOK.md) for the full auto-book selection priority. diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index b78f32aa..0476c07c 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -145,6 +145,10 @@ WarpSeatFactory.prototype.setSeatsData = function(seatsData = {}) { // Full refresh — replace the zone-group map. this.zoneGroups = seatsData.zoneGroups || {}; + // zid -> true if the acting (real, not book-for target) user administers + // that zone. Used to gate seat-edit per-seat instead of the plan-wide + // isZoneAdmin flag. + this.zoneAdmin = seatsData.zoneAdmin || {}; var oldSeatsIds = new Set( Object.keys(this.instances)) @@ -217,6 +221,26 @@ WarpSeatFactory.prototype.setSeatsData = function(seatsData = {}) { return res; } +// True if updating forSeat would need to release a conflicting booking that +// lies in a zone the acting admin does not administer. Only relevant under +// book-for: apply() only requires zone-admin to release someone ELSE's +// booking (self-releases are never gated — see apply()'s seatsReqZoneAdmin, +// which excludes Book.login == flask.g.login). Releasing a foreign zone's +// booking would 403 (code 102) and roll back the whole book+remove request, +// so the frontend must not offer that "update" as if it would succeed. +WarpSeatFactory.prototype.hasUnmanageableConflict = function(forSeat) { + + if (this.login === window.warpGlobals.login) + return false; + + for (var c of this.getMyConflictingBookings(forSeat)) { + var conflictSeat = this.instances[c.sid]; + if (conflictSeat && !this.zoneAdmin[conflictSeat.zid]) + return true; + } + return false; +} + // Plan-wide: used by the auto-book FAB to detect when the current selection is // already exactly satisfied by an existing booking in any zone. WarpSeatFactory.prototype.isExactMatch = function() { @@ -301,6 +325,14 @@ WarpSeat.prototype.getZoneName = function() { return this.zoneName; } +// True if the acting user (the real logged-in admin, not a book-for target) +// administers this seat's zone — the correct per-seat gate for admin-only +// actions (seat-edit), as opposed to the plan-wide isZoneAdmin flag which is +// true if they administer ANY zone on the plan. +WarpSeat.prototype.isMyZoneAdmin = function() { + return !!this.factory.zoneAdmin[this.zid]; +} + WarpSeat.prototype.getSid = function() { return parseInt(this.sid); //TODO: convert this.sid to int in constructor diff --git a/js/views/plan.js b/js/views/plan.js index eaf91091..9d3e97df 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -638,6 +638,7 @@ export async function mount(ctx) { var actions = []; var bookMsg = false; var removeMsg = false; + var blockedMsg = false; switch (state) { case WarpSeat.SeatStates.CAN_BOOK: @@ -646,11 +647,22 @@ export async function mount(ctx) { break; case WarpSeat.SeatStates.CAN_CHANGE: actions.push('delete'); - // no break here - case WarpSeat.SeatStates.CAN_REBOOK: actions.push('update'); bookMsg = removeMsg = true; break; + case WarpSeat.SeatStates.CAN_REBOOK: + // Under book-for, updating this free seat would release the + // target's conflicting booking elsewhere in the zone group. + // If that booking is outside the zones we administer, apply() + // rejects the release (403/102) and the whole request rolls + // back — don't offer an action that's guaranteed to fail. + if (seatFactory.hasUnmanageableConflict(this)) { + blockedMsg = true; + } else { + actions.push('update'); + bookMsg = removeMsg = true; + } + break; case WarpSeat.SeatStates.CAN_DELETE: case WarpSeat.SeatStates.CAN_DELETE_EXACT: actions.push('delete'); @@ -658,7 +670,10 @@ export async function mount(ctx) { break; }; - if (window.warpGlobals.isZoneAdmin) { + // Per-seat check: isZoneAdmin is plan-wide (true if the actor admins + // ANY zone on the plan), which would otherwise offer seat-edit (and + // its always-403 save) on seats in zones the actor merely views. + if (this.isMyZoneAdmin()) { actions.push('seat-edit'); actions.push('seat-edit-save'); } @@ -669,6 +684,12 @@ export async function mount(ctx) { let msg1El = root.querySelector("#action_modal_msg1"); msg1El.innerHTML = ""; + if (blockedMsg) { + let p = document.createElement('P'); + p.innerText = TR("This seat can't be updated here: the conflicting booking is in a zone you don't administer."); + msg1El.appendChild(p); + } + if (bookMsg) { var bookDatesTable = document.createElement("table"); diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index b8f00ba2..c550ab6b 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -179,6 +179,7 @@ "Bookings:": "Buchungen:", "Seat %{seat_name} to be booked:": "Sitzplatz %{seat_name} soll gebucht werden:", "To be released:": "Soll freigegeben werden:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Dieser Platz kann hier nicht aktualisiert werden: Die widersprüchliche Buchung befindet sich in einer Zone, die Sie nicht verwalten.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Sitzplatz ist erfolgreich deaktiviert.
Es gibt jedoch bestehende Reservierungen für die nächsten Wochen. Bestehende Reservierungen werden nicht automatisch freigegeben, dies muss manuell geschehen.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Der Sitzplatz wurde erfolgreich zugewiesen.
Allerdings gibt es in den nächsten Wochen bestehende Reservierungen von Nicht-Empfängern. Bestehende Reservierungen werden nicht automatisch freigegeben, sondern müssen manuell vorgenommen werden.

", "Warning": "Warnung", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index 0896d79d..20dc317c 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -179,6 +179,7 @@ "Bookings:": "Bookings:", "Seat %{seat_name} to be booked:": "Seat %{seat_name} to be booked:", "To be released:": "To be released:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "This seat can't be updated here: the conflicting booking is in a zone you don't administer.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

", "Warning": "Warning", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index d924b3cc..f0feb723 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -179,6 +179,7 @@ "Bookings:": "Reservas:", "Seat %{seat_name} to be booked:": "Asiento %{seat_name} para ser reservado:", "To be released:": "A liberar:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Este asiento no se puede actualizar aquí: la reserva en conflicto está en una zona que usted no administra.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "El asiento fué deshabilitado correctamente.
Sin embargo, hay reservas existentes para las próximas pocas semanas. Las reservas existentes no se liberarán automáticamente, se deberá realizar manualmente!

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "El asiento fué correctamente asignado.
Sin embargo, hay reservas existentes de no asignados en las próximas semanas. Las reservas existentes no se liberan automáticamente, tiene que hacerse manualmente.

", "Warning": "Advertencia", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index b785a67c..ff3be765 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -179,6 +179,7 @@ "Bookings:": "Réservations:", "Seat %{seat_name} to be booked:": "Le siège %{seat_name} est réservé:", "To be released:": "À libérer:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Ce siège ne peut pas être mis à jour ici : la réservation en conflit se trouve dans une zone que vous n'administrez pas.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Siège désactivé avec succès.
Cependant, il y a des réservations pour les prochaines semaines. Les réservations existantes ne sont pas automatiquement libérées, ce doit être fait manuellement.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Siège assigné avec succès.
Cependant, il y des réservations de personnes non-assignées pour les prochaines semaines. Les réservations existantes ne sont pas automatiquement libérées, ce doit être fait manuellement.

", "Warning": "Avertissement", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index 9c02dea9..5e7e8c4e 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -179,6 +179,7 @@ "Bookings:": "Rezerwacje:", "Seat %{seat_name} to be booked:": "Miejsce %{seat_name} zostanie zarezerwowane:", "To be released:": "Rezerwacje zostaną zwolnione:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Tego miejsca nie można tutaj zaktualizować: konfliktowa rezerwacja znajduje się w strefie, którą nie zarządzasz.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Miejsce zostało wyłączone.
Jednak w kolejnych dniach istnieją jego rezerwacje. Istniejące rezerwacje nie są zwalniane automatycznie, należy zwolnić je ręcznie.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Miejsce zostało przypisane.
Jednak w kolejnych dniach istnieję rezerwacje zrobione przez osoby, które nie zostały do niego przypisane. Istniejące rezerwacje nie są zwalniane automatycznie, należy zwolnić je ręcznie.

", "Warning": "Uwaga", diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index 3db44352..dc35572c 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -404,6 +404,17 @@ def getSeats(pid): res['zones'][str(i[0])] = i[1] res['zoneGroups'][str(i[0])] = i[2] + # zoneAdmin reflects the ACTOR's own admin standing (effective_roles, not + # bookable_roles/targetLogin) — seat-edit is an admin action performed by + # the actor regardless of who book-for is viewing as. The frontend uses + # this to gate seat-edit per-seat instead of the plan-wide isZoneAdmin + # flag, which would otherwise offer seat-edit (and its always-403 save) on + # seats in zones the actor merely views. + res['zoneAdmin'] = { + str(zid): (effective_roles.get(zid) is not None and effective_roles[zid] <= ZONE_ROLE_ADMIN) + for zid in usedZids + } + usedUsersQuery = Users.select(Users.login, Users.name).where(Users.login.in_(usedUsers)).tuples() res['users'] = {str(i[0]): i[1] for i in usedUsersQuery.iterator()} From 55bcb53fb254ea3faad75f35fe02fcf3bb00f7f0 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 01:18:22 +0200 Subject: [PATCH 03/17] Allow releasing own bookings from the bookings table in any zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user's own booking is always releasable server-side (apply()'s remove bypasses the per-seat zone-admin check for own bookings via the Book.login != g.login filter, but the bookings table denied it: viewers got rw=false (role 30 > USER), and bookings in zones the user had lost access to vanished entirely (the INNER JOIN on user_to_zone_roles dropped them). Switch the list query to a LEFT JOIN with the login in the ON clause and admit own bookings in no-access zones via Book.login == g.login (other users' bookings there stay hidden — the disjunct only admits own rows); make rw true for any own booking unconditionally. Foreign bookings stay gated by the actor's zone-admin standing. Updates the bookings-page e2e (flip the wrong-behaviour PUBLIC_VIEW own-booking test to assert the delete icon; clarify the inaccessible-zone test as another user's booking; add an own-booking-in-inaccessible-zone end-to-end release test) and FEATURES.md §11.1 + the §26 footnote. Co-Authored-By: GLM 5.2 EOF ) --- FEATURES.md | 6 +-- e2e/tests/bookings-page/list.spec.ts | 40 +++++++++++++++++++- e2e/tests/bookings-page/public-zones.spec.ts | 8 ++-- warp/xhr/bookings.py | 36 +++++++++++++----- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index fbe3dd2a..3274a87c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -466,9 +466,9 @@ The old "Already booked in another zone" section is gone; an existing booking on ## 11. Bookings List & Report ### 11.1 Bookings List (available to all users) -- Shows **future** bookings in zones the user is assigned to. +- Shows **future** bookings in zones the user is assigned to, plus the user's own future bookings in zones they no longer have access to (so they can still release them — the plan map can't reach a seat in a zone they can't open). - Columns: User name, Plan, Seat, Time (merged from/to into one column). -- A **delete button** (🗑) appears for bookings the user can remove (own bookings where they have User role, or any booking where they have Zone Admin role). +- A **delete button** (🗑) appears for any of the user's **own** bookings (regardless of their role in that zone — viewers and users who have lost access can still release their own), plus any booking in a zone where they have **Zone Admin** role. - Filtering by user name, plan, seat, and date range. - Sorting by time and user name. - Paginated with remote data loading. @@ -796,7 +796,7 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* | Change own password | ✅³ | ✅³ | ✅³ | ✅³ | ¹ System Admins do not automatically hold zone-level rights. Zone-level actions require the corresponding zone role (User or Zone Admin) in that zone — which a System Admin can always grant themselves via zone user assignment. -² Viewers can remove their own bookings (even from zones they are only a viewer in), to clean up leftover bookings after role changes. +² Viewers — and users who have lost access to a zone entirely — can remove their own bookings there, to clean up leftover bookings after role changes. ³ Only available with built-in authentication (not SSO). --- diff --git a/e2e/tests/bookings-page/list.spec.ts b/e2e/tests/bookings-page/list.spec.ts index 1df153b0..b7c7c138 100644 --- a/e2e/tests/bookings-page/list.spec.ts +++ b/e2e/tests/bookings-page/list.spec.ts @@ -3,7 +3,7 @@ */ import { test, expect } from '../../fixtures'; import { logIn } from '../../helpers/auth'; -import { USER1, USER2 } from '../../helpers/users'; +import { USER1, USER2, USER3 } from '../../helpers/users'; import { querySql } from '../../helpers/db'; import { futureDayTs, getZoneSeats } from '../../helpers/booking'; import { insertBooking, clearDefaultUserFilter } from '../../helpers/bookings-page'; @@ -55,7 +55,9 @@ test.describe('bookings list visibility', () => { await expect(page.locator('.tabulator-row')).toHaveCount(2); }); - test('bookings in zones the user cannot access are NOT shown', async ({ page }) => { + test("another user's bookings in zones the user cannot access are NOT shown", async ({ page }) => { + // user2 has no access to zone 2; user1's booking there is foreign to + // user2 → stays hidden (the own-booking disjunct only admits own rows). const zone2Seats = await getZoneSeats(2); await insertBooking('user1', zone2Seats[0].id); @@ -65,6 +67,40 @@ test.describe('bookings list visibility', () => { await expect(page.locator('.tabulator-row')).toHaveCount(0); }); + test('own booking in a zone the user has no access to IS shown and deletable', async ({ page }) => { + // user3 has no zone_assign at all → no access to enabled zone 2. But they + // hold a booking there (e.g. access was revoked after booking, or an + // admin booked them in then removed their role). The plan map can't reach + // a seat in a zone they can't open, so the bookings table is the only UI + // path to release it: the row must be visible and the delete icon shown. + const zone2Seats = await getZoneSeats(2); + const bid = await insertBooking('user3', zone2Seats[0].id); + + await logIn(page, USER3); + await page.goto('/bookings'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.tabulator-row')).toHaveCount(1); + await expect(page.locator('.tabulator-row').first()).toContainText(zone2Seats[0].name); + await expect( + page.locator('.tabulator-row').first().locator('.material-icons.warp-icon-danger'), + ).toBeVisible(); + + // And the release actually works end-to-end. + await page.locator('.tabulator-row').first().locator('.material-icons.warp-icon-danger').click(); + const modal = page.locator('.modal', { hasText: 'Are you sure to release this booking?' }); + await expect(modal).toBeVisible(); + await Promise.all([ + page.waitForResponse(r => r.url().includes('/xhr/plan/apply') && r.status() === 200), + modal.locator('button.modal-close', { hasText: /yes/i }).click(), + ]); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.tabulator-row')).toHaveCount(0); + const result = await querySql('SELECT COUNT(*)::int AS cnt FROM book WHERE id = $1', [bid]); + expect(result.rows[0].cnt).toBe(0); + }); + test('delete icon is present for own bookings (rw=true)', async ({ page }) => { const [seat] = await getZoneSeats(1); await insertBooking('user1', seat.id); diff --git a/e2e/tests/bookings-page/public-zones.spec.ts b/e2e/tests/bookings-page/public-zones.spec.ts index 1d9231f2..2f2f6915 100644 --- a/e2e/tests/bookings-page/public-zones.spec.ts +++ b/e2e/tests/bookings-page/public-zones.spec.ts @@ -55,7 +55,7 @@ test.describe('bookings page — public zone visibility', () => { ).toBeVisible(); }); - test('PUBLIC_VIEW: unassigned user sees own booking but cannot delete (rw=false)', async ({ page }) => { + test('PUBLIC_VIEW: unassigned user sees own booking and can delete (own booking always releasable)', async ({ page }) => { await setZoneType(1, ZONE_TYPE_PUBLIC_VIEW); const [seat] = await getZoneSeats(1); @@ -67,10 +67,12 @@ test.describe('bookings page — public zone visibility', () => { await expect(page.locator('.tabulator-row')).toHaveCount(1); await expect(page.locator('.tabulator-row').first()).toContainText(seat.name); - // PUBLIC_VIEW grants VIEWER only → rw=false → no delete icon + // PUBLIC_VIEW grants VIEWER only, but a user can always release their + // OWN booking (apply()'s remove bypasses the zone-admin check for own + // bookings) → rw=true → delete icon visible. await expect( page.locator('.tabulator-row').first().locator('.material-icons.warp-icon-danger'), - ).toHaveCount(0); + ).toBeVisible(); }); test('PUBLIC_BOOK: unassigned user sees another user booking but cannot delete (rw=false)', async ({ page }) => { diff --git a/warp/xhr/bookings.py b/warp/xhr/bookings.py index 0b8fa5c2..a4ccd41d 100644 --- a/warp/xhr/bookings.py +++ b/warp/xhr/bookings.py @@ -2,7 +2,7 @@ import xlsxwriter import io from jsonschema import validate, ValidationError -from peewee import SQL, Expression, fn +from peewee import SQL, Expression, fn, JOIN from warp.db import * from warp import utils @@ -111,10 +111,23 @@ def listW(report = False): # list is a built-in type # (mirrors users.delete, which compares view timestamptz columns). # today_in_tz_sql encapsulates the e2e debug time-offset. today_in_plan_tz = utils.today_in_tz_sql(Plan.timezone, as_epoch=True) + # LEFT JOIN the actor's own role row: a matching row exists iff they + # have effective access to the booking's zone (synthetic public-zone + # rows included). A user's OWN booking in a zone they no longer have a + # role for (access revoked after booking, or booked there via book-for + # then removed) must still be visible + deletable here — the plan map + # can't reach a seat in a zone they can't open, so the bookings table + # is the only UI path. Admit those own rows via Book.login == g.login; + # other users' bookings in no-access zones stay hidden (the disjunct + # only admits own rows). zone_role is NULL for the no-role case. query = query.select_extend(UserToZoneRoles.zone_role) \ - .join(UserToZoneRoles, on=(UserToZoneRoles.zid == Seat.zid)) \ - .where((UserToZoneRoles.login == flask.g.login) & - (_FROM_UTC_SQL >= today_in_plan_tz)) + .join(UserToZoneRoles, + join_type=JOIN.LEFT_OUTER, + on=((UserToZoneRoles.zid == Seat.zid) & + (UserToZoneRoles.login == flask.g.login))) \ + .where((_FROM_UTC_SQL >= today_in_plan_tz) & + (UserToZoneRoles.zone_role.is_null(False) | + (Book.login == flask.g.login))) columnsMap = { "id": Book.id, @@ -235,11 +248,16 @@ def funOperator(field,value): if not report: - # zone_role from the view IS the effective role. A row exists - # iff the user has access, so no LEFT JOIN / zone_type predicate. - d['rw'] = \ - (row["login"] == flask.g.login and row['zone_role'] <= ZONE_ROLE_USER) \ - or row['zone_role'] <= ZONE_ROLE_ADMIN + # rw = deletable via the bookings-table delete icon. A user can + # always release their OWN booking: apply()'s remove bypasses + # the per-seat zone-admin check for own bookings (the + # Book.login != g.login filter), so this is true even when they + # are only a VIEWER in the zone, or have lost access entirely + # (zone_role NULL via the LEFT JOIN above). Foreign bookings + # stay gated by the actor's zone-admin standing. + own = row["login"] == flask.g.login + role = row['zone_role'] + d['rw'] = own or (role is not None and role <= ZONE_ROLE_ADMIN) else: d["login"] = row["login"] From 8f2b120b60425b8236cf4537ae6dafcb9b739f24 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 01:18:56 +0200 Subject: [PATCH 04/17] Add Phase 2 sprite-assertion e2e for view-only zone display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view-only-zone display fix (taken/assigned/yours icons survive, book demoted to unavailable) had no e2e regression guard — only the backend-free OfficeMap component suite asserted sprite hrefs; the booking/permission specs only assert action-button visibility. Add the 7 sprite tests from PLAN_VIEW_ONLY_SEATS.md Phase 2 test plan: 1. viewer-zone seat booked by another -> cell-taken; click opens no modal 2. assigned to another, free -> cell-assigned 3. everyone-only, free -> cell-unavailable 4. free unassigned -> cell-unavailable; click opens no modal 5. own exact booking in a view-only zone -> cell-yours; release via modal 6. mixed ENABLED + PUBLIC_VIEW in one zone_group: viewer-zone taken stays taken, enabled-zone free shows rebook (risk #1 conflict-map guard) 7. assigned-to-me in a bookable ENABLED zone -> cell-availableAssigned (risk #5 guard) Asserts the active href on #sprite- (OfficeMap keeps one per distinct cell name and toggles display, so the helper reads the visible one and retries via expect.poll). Co-Authored-By: GLM 5.2 --- e2e/tests/booking/zone-view-sprites.spec.ts | 253 ++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 e2e/tests/booking/zone-view-sprites.spec.ts diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts new file mode 100644 index 00000000..48844e89 --- /dev/null +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -0,0 +1,253 @@ +/** + * Phase 2 display-fix sprite assertions (PLAN_VIEW_ONLY_SEATS.md "Phase 2 test + * plan"). These lock down the core invariant of the view-only-zone fix: in a + * view-only zone the user sees real occupancy/assignment icons (taken / + * assigned / yours), and `bookable: false` only demotes the *action* states + * (book → unavailable). Without these, a future refactor could revert + * viewer-zone seats to a uniform `unavailable` and no e2e would catch it — the + * bookings/permission specs only assert action-button visibility, never the + * rendered sprite. + * + * Sprite cell names come from js/views/modules/seat.js `spriteFor`: + * taken, assigned, unavailable, yours, available, rebook, availableAssigned + * Asserted via the active `` href on `#sprite-` (OfficeMap keeps one + * `` per distinct cell name and toggles `display` to switch — see + * expectSprite below; same pattern as e2e/tests/officemap/officemap.spec.ts). + * + * Setup rule: seed all DB state (bookings, assignments) BEFORE navigating to the + * plan — getSeats runs on mount and the seat data is fixed then; selecting a + * date only recomputes state from already-loaded data, it does not re-fetch. + */ +import { test, expect } from '../../fixtures'; +import { logIn } from '../../helpers/auth'; +import { USER1, USER2, USER3 } from '../../helpers/users'; +import { querySql } from '../../helpers/db'; +import { + futureDayTs, + selectOnlyDates, + waitForSeatsLoaded, + clickActionBtn, +} from '../../helpers/booking'; +import { + ZONE_TYPE_ENABLED, + ZONE_TYPE_PUBLIC_VIEW, + ZONE_ROLE_USER, + createPlan, + createZone, + addSeats, + assignZoneRole, + assignSeat, + insertBooking, +} from '../../helpers/zone-setup'; + +/** Assert the seat's ACTIVE sprite href ends in #cell-. + * OfficeMap keeps one per distinct cell name and toggles `display` to + * switch sprites (it never mutates href or removes old uses, to avoid a + * re-resolve blink). So a seat that started `unavailable` then became `taken` + * has two children — only the active one has `display !== 'none'`. + * Auto-retries via expect.poll until the right sprite is active. */ +async function expectSprite(page: any, seatId: number, cell: string) { + await expect.poll(async () => { + return await page.evaluate((id) => { + const glyph = document.querySelector(`#sprite-${id} .OMSeatGlyph`); + if (!glyph) return null; + for (const u of glyph.querySelectorAll('use')) { + if (u.style.display !== 'none') return u.getAttribute('href'); + } + return null; + }, seatId); + }).toMatch(RegExp(`#${cell}$`)); +} + +/** Click a seat and assert the action modal does NOT open. For a pure viewer + * (isZoneViewer) the modal is removed from the DOM entirely; for a mixed-plan + * user it exists but must stay closed. Both are correct "no action" outcomes. */ +async function clickExpectsNoModal(page: any, seatId: number) { + await page.locator(`#sprite-${seatId}`).click(); + await page.waitForTimeout(300); + const modal = page.locator('#action_modal'); + if (await modal.count() > 0) { + await expect(modal).not.toHaveClass(/open/); + } +} + +/** A 09:00–17:00 slot for the default-slider day (tomorrow). */ +const DAY = futureDayTs(1); +function slot() { + return { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 }; +} + +// --------------------------------------------------------------------------- +// Viewer zone (PUBLIC_VIEW): informational icons survive, action icon demoted +// --------------------------------------------------------------------------- + +test.describe('view-only zone: informational icons survive, action icon demoted', () => { + /** Plan with one PUBLIC_VIEW zone + 4 seats; user3 is an implicit viewer. */ + async function setupViewZone() { + const pid = await createPlan('Sprite View Plan', 1); + const zid = await createZone('Sprite View Zone', ZONE_TYPE_PUBLIC_VIEW); + const [sTaken, sAssigned, sEveryone, sFree] = await addSeats(pid, zid, [ + 'SV.taken', 'SV.assigned', 'SV.everyone', 'SV.free', + ]); + return { pid, zid, sTaken, sAssigned, sEveryone, sFree }; + } + + test('1. seat booked by another user → taken; click opens no modal', async ({ page }) => { + const { pid, sTaken } = await setupViewZone(); + const { fromTS, toTS } = slot(); + await insertBooking(USER1.login, sTaken, fromTS, toTS); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, sTaken, 'cell-taken'); + await clickExpectsNoModal(page, sTaken); + }); + + test('2. seat assigned to another user, free → assigned', async ({ page }) => { + const { pid, sAssigned } = await setupViewZone(); + await assignSeat(sAssigned, USER1.login, null); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, sAssigned, 'cell-assigned'); + }); + + test('3. seat with everyone-assignment only, free → unavailable', async ({ page }) => { + const { pid, sEveryone } = await setupViewZone(); + await assignSeat(sEveryone, null, null); // everyone-only, no named assignee + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // Everyone-only carries no person info for a non-booker → falls through to + // CAN_BOOK → demoted to VIEW_ONLY → unavailable. + await expectSprite(page, sEveryone, 'cell-unavailable'); + }); + + test('4. free unassigned seat → unavailable; click opens no modal', async ({ page }) => { + const { pid, sFree } = await setupViewZone(); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, sFree, 'cell-unavailable'); + await clickExpectsNoModal(page, sFree); + }); +}); + +// --------------------------------------------------------------------------- +// Own booking in a view-only zone: release still works via the modal +// --------------------------------------------------------------------------- + +test.describe('own booking in a view-only zone', () => { + test('5. own booking exact match → yours; delete via modal works', async ({ page }) => { + // Mixed plan so the user is NOT a pure viewer (isZoneViewer false → the + // action modal exists in the DOM). user3 is USER in the enabled zone and an + // implicit viewer in the PUBLIC_VIEW zone; their own booking is on the + // view-only seat. CAN_DELETE_EXACT is informational (not demoted by + // !bookable) → sprite `yours`, and release is offered (apply()'s remove + // bypasses the zone-admin check for own bookings). + const pid = await createPlan('Sprite Own Plan', 1); + const enabledZid = await createZone('SO Enabled', ZONE_TYPE_ENABLED); + const viewZid = await createZone('SO View', ZONE_TYPE_PUBLIC_VIEW); + const [enabledSeat] = await addSeats(pid, enabledZid, ['SO.enabled']); + const [viewSeat] = await addSeats(pid, viewZid, ['SO.view']); + await assignZoneRole(enabledZid, 'user3', ZONE_ROLE_USER); + + const { fromTS, toTS } = slot(); + await insertBooking(USER3.login, viewSeat, fromTS, toTS); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, viewSeat, 'cell-yours'); + + await page.locator(`#sprite-${viewSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'delete'); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER3.login, viewSeat], + ); + expect(r.rows[0].cnt).toBe(0); + // enabledSeat stays bookable (untouched) — sanity that the mixed plan held. + await expectSprite(page, enabledSeat, 'cell-available'); + }); +}); + +// --------------------------------------------------------------------------- +// Risk #1 guard: conflict map across a mixed ENABLED + PUBLIC_VIEW zone_group +// --------------------------------------------------------------------------- + +test.describe('mixed ENABLED + PUBLIC_VIEW in one zone_group (risk #1 guard)', () => { + test('6. viewer-zone taken stays taken; enabled-zone free shows rebook', async ({ page }) => { + const pid = await createPlan('Sprite Mixed Plan', 1); + const enabledZid = await createZone('SM Enabled', ZONE_TYPE_ENABLED, 'smGrp'); + const viewZid = await createZone('SM View', ZONE_TYPE_PUBLIC_VIEW, 'smGrp'); + const [enabledOwn, enabledFree] = await addSeats(pid, enabledZid, ['SM.own', 'SM.free']); + const [viewTaken] = await addSeats(pid, viewZid, ['SM.taken']); + await assignZoneRole(enabledZid, 'user2', ZONE_ROLE_USER); + + const { fromTS, toTS } = slot(); + // user2 holds enabledOwn (exact match); user1 holds viewTaken (foreign). + await insertBooking(USER2.login, enabledOwn, fromTS, toTS); + await insertBooking(USER1.login, viewTaken, fromTS, toTS); + + await logIn(page, USER2); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // The viewer-zone TAKEN seat must stay `taken` — not be demoted to + // unavailable, and must NOT suppress the same-group rebook in the enabled + // zone (isMine=false for the foreign booking → no conflict-map entry). + await expectSprite(page, viewTaken, 'cell-taken'); + // user2's own same-group booking makes enabledFree a rebook. + await expectSprite(page, enabledFree, 'cell-rebook'); + // Own exact booking → yours. + await expectSprite(page, enabledOwn, 'cell-yours'); + }); +}); + +// --------------------------------------------------------------------------- +// Risk #5 guard: assigned-to-me in a bookable ENABLED zone stays availableAssigned +// --------------------------------------------------------------------------- + +test.describe('assigned-to-me in a bookable ENABLED zone (risk #5 guard)', () => { + test('7. seat assigned to me, within window, free → availableAssigned', async ({ page }) => { + const pid = await createPlan('Sprite AssignMe Plan', 1); + const zid = await createZone('SAM Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SAM.1']); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + // Assigned to user2, unlimited days_in_advance → within window → falls + // through to CAN_BOOK (not ASSIGNED), with assignedToMe → availableAssigned. + await assignSeat(seat, USER2.login, null); + + await logIn(page, USER2); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, seat, 'cell-availableAssigned'); + }); +}); \ No newline at end of file From 89054017078a3e09b57c50f16611844cb58d0734 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 01:33:07 +0200 Subject: [PATCH 05/17] Fix book-for release-confinement check for cross-plan conflict zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasUnmanageableConflict() (frontend) gates the doomed "update" when a book-for rebook would release the target's booking in a zone the actor doesn't administer. It reads the zoneAdmin map from getSeats, but that map was built from effective_roles — which only covers zones ON the current plan. A conflict booking can sit in a same-group zone on ANOTHER plan, so an administered cross-plan conflict zone was marked not-administered and the update was wrongly blocked. Query the actor's roles for every zone in the response (plan + conflict) so the cross-plan case is judged correctly; site admins administer every zone. zoneAdmin is only read by hasUnmanageableConflict (conflict seats) and the per-seat seat-edit gate (plan zones, already correct), so the change is scoped to the cross-plan release-confinement path. Update the stale bookfor-zone-group e2e that expected the old doomed "update" for an inaccessible same-group conflict: the seat is still a rebook (the conflict is recognised from the server-supplied login — book is not offered) but the update is now correctly blocked by release confinement, with the explanatory "zone you don't administer" message. The server-side 102 pin is the companion test in the same file. Co-Authored-By: GLM 5.2 --- e2e/tests/booking/bookfor-zone-group.spec.ts | 15 ++++++--- warp/xhr/plan.py | 34 ++++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/e2e/tests/booking/bookfor-zone-group.spec.ts b/e2e/tests/booking/bookfor-zone-group.spec.ts index e014bf08..cbd93da1 100644 --- a/e2e/tests/booking/bookfor-zone-group.spec.ts +++ b/e2e/tests/booking/bookfor-zone-group.spec.ts @@ -240,7 +240,12 @@ test.describe('book-for + zone group', () => { // UI counterpart of the contract guard: the inaccessible Parking booking // must make the accessible Zone 1A seat a rebook under book-for. This passes // only if the conflict seat is recognised as the target's, i.e. its login - // arrived from the server and the client honoured it. + // arrived from the server and the client honoured it. The release sits in + // Parking, which user1 does NOT administer → release confinement blocks + // the doomed "update" (hasUnmanageableConflict): the seat is still a + // rebook (conflict recognised) but the update action is not offered and an + // explanatory message is shown instead. (The server-side 102 pin is the + // companion test "book-for cannot release a target's booking..." below.) const ts = futureDayTs(1); const zone1Seat = (await getZoneSeats(1))[0]; const parkingSeat = (await getZoneSeats(3))[0]; @@ -261,13 +266,13 @@ test.describe('book-for + zone group', () => { await page.waitForTimeout(400); await activateBookFor(page, 'Bar [user2]'); + // The conflict was recognised (the seat is a rebook, not a plain book): + // book is NOT offered, and the update is blocked by release confinement. await clickZoneSeat(page, zone1Seat); await expect(page.locator('#action_modal')).toHaveClass(/open/); - await expect(page.locator('.plan_action_btn[data-action="update"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); - // The conflicting Parking booking is listed for removal (built by - // getMyConflictingBookings, which matches on the acting login). - await expect(page.locator('#action_modal_msg2')).toContainText('Parking'); + await expect(page.locator('#action_modal_msg1')).toContainText("don't administer"); }); test('book-for cannot release a target\'s booking in a non-administered same-group zone', async ({ page }) => { diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index dc35572c..e65eb301 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -404,16 +404,30 @@ def getSeats(pid): res['zones'][str(i[0])] = i[1] res['zoneGroups'][str(i[0])] = i[2] - # zoneAdmin reflects the ACTOR's own admin standing (effective_roles, not - # bookable_roles/targetLogin) — seat-edit is an admin action performed by - # the actor regardless of who book-for is viewing as. The frontend uses - # this to gate seat-edit per-seat instead of the plan-wide isZoneAdmin - # flag, which would otherwise offer seat-edit (and its always-403 save) on - # seats in zones the actor merely views. - res['zoneAdmin'] = { - str(zid): (effective_roles.get(zid) is not None and effective_roles[zid] <= ZONE_ROLE_ADMIN) - for zid in usedZids - } + # zoneAdmin reflects the ACTOR's own admin standing (not bookable_roles / + # targetLogin) — seat-edit is an admin action performed by the actor + # regardless of who book-for is viewing as. The frontend also uses it in + # hasUnmanageableConflict() to decide whether a book-for "update" can + # release the target's conflicting booking. effective_roles is plan-scoped + # (zones ON this plan), but a conflict booking can sit in a same-group zone + # on ANOTHER plan, so effective_roles alone would mark an administered + # cross-plan conflict zone as not-administered and wrongly block the + # update. Query the actor's roles for every zone in the response (plan + + # conflict) so cross-plan release confinement is judged correctly. Site + # admins administer every zone. + if flask.g.isAdmin: + admin_zids = set(usedZids) + elif usedZids: + admin_zids = { + r['zid'] for r in UserToZoneRoles.select(UserToZoneRoles.zid) + .where(UserToZoneRoles.zid.in_(list(usedZids))) + .where(UserToZoneRoles.login == flask.g.login) + .where(UserToZoneRoles.zone_role <= ZONE_ROLE_ADMIN) + .iterator() + } + else: + admin_zids = set() + res['zoneAdmin'] = {str(zid): (zid in admin_zids) for zid in usedZids} usedUsersQuery = Users.select(Users.login, Users.name).where(Users.login.in_(usedUsers)).tuples() res['users'] = {str(i[0]): i[1] for i in usedUsersQuery.iterator()} From 96c7ae015000bd54fcf61d5ce903b1e58bea2636 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 01:33:32 +0200 Subject: [PATCH 06/17] Expose book-for override of an assigned seat in the plan UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply() already lets a zone admin booking FOR a target override a seat-level assignment (it skips 106/110 under is_book_for — pinned by zone-permissions-bookfor B5/B6 via API), but the plan UI showed the seat as grey ASSIGNED with no book action, so the only path was the seat-edit workaround. Under book-for (factory.login != the real login) and where bookable (which under book-for already means the actor administers the zone), let a seat assigned to a third person — or beyond the target's days-in-advance window — fall through to CAN_BOOK instead of ASSIGNED, flagged so a new green "assignedOverride" sprite distinguishes "assigned, but you can book here via override" from both the grey ASSIGNED (not bookable here) and the blue availableAssigned (assigned to the target, within window). Self-booking never overrides (106/110 apply), so the new icon is book-for-only. The book action already attaches book.login under book-for, so apply() gets is_book_for and overrides — only the state/sprite was hiding it. Adds a new cell-assignedOverride to seat_icons.svg (the assigned-to head+tie glyph in the available colour family — reuses existing --warp-seat-available-* vars, no new theme colour), the legend row + help-map entry + the 5 i18n legend strings, a FEATURES.md §9 note, and a sprite e2e (self-view = grey assigned; book-for = green override; book succeeds for the target). Co-Authored-By: GLM 5.2 --- FEATURES.md | 2 +- e2e/tests/booking/zone-view-sprites.spec.ts | 60 +++++++++++++++++++++ js/views/html/plan.html | 4 ++ js/views/modules/seat.js | 45 +++++++++++++--- js/views/plan.js | 1 + warp/static/i18n/de.json | 1 + warp/static/i18n/en.json | 1 + warp/static/i18n/es.json | 1 + warp/static/i18n/fr.json | 1 + warp/static/i18n/pl.json | 1 + warp/static/images/seat_icons.svg | 10 +++- 11 files changed, 118 insertions(+), 9 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 3274a87c..549688e4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -425,7 +425,7 @@ The old "Already booked in another zone" section is gone; an existing booking on - A "Book For" input field appears in the plan-view side panel (the booking map) for zone admins. - It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users, including viewers. -- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction and any seat-level assignment for that seat. +- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction and any seat-level assignment for that seat. A seat assigned to someone else (or beyond the target's days-in-advance window) that the admin can book onto this way shows a distinct green "assigned but bookable via override" icon (the assigned-to glyph in the book colour), so the admin sees the assignment and the override at once. - When the admin books, updates, or removes a booking, it is performed **on behalf of the selected user**. - The admin can also auto-book for the selected user. - Clearing the "Book For" field (pressing Enter while empty) reverts to the admin's own view. diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 48844e89..b76f12b5 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -32,6 +32,7 @@ import { ZONE_TYPE_ENABLED, ZONE_TYPE_PUBLIC_VIEW, ZONE_ROLE_USER, + ZONE_ROLE_ADMIN, createPlan, createZone, addSeats, @@ -250,4 +251,63 @@ test.describe('assigned-to-me in a bookable ENABLED zone (risk #5 guard)', () => await expectSprite(page, seat, 'cell-availableAssigned'); }); +}); + +// --------------------------------------------------------------------------- +// Book-for override of an assignment: admin books FOR a target onto a seat +// assigned to someone else (apply() skips 106/110 under is_book_for). The +// seat is bookable despite the assignment, so it renders a distinct green +// "assigned + bookable via override" icon (head+tie in the available colour +// family), not the grey ASSIGNED one, and the book action is offered. +// --------------------------------------------------------------------------- + +/** Activate book-for for the given display label (e.g. "Bar [user2]"). */ +async function activateBookFor(page: any, label: string): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially(label.split(' ')[0], { delay: 50 }); + const item = page.locator('ul.autocomplete-content li', { hasText: label }); + await expect(item).toBeVisible({ timeout: 5000 }); + await item.click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(400); +} + +test.describe('book-for override of an assignment', () => { + test('8. seat assigned to a third person, book-for target is a member → assignedOverride + book works', async ({ page }) => { + const pid = await createPlan('Sprite Override Plan', 1); + const zid = await createZone('SO Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SO.1']); + // user1 administers the zone; user2 (the book-for target) is a member; + // the seat is assigned to user3 (a third person, not the target). + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await assignSeat(seat, USER3.login, null); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + // Default self-view (admin): the seat is bookable at the zone level but + // self-book is blocked by 106 → grey ASSIGNED, NOT the override icon. + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assigned'); + + // Switch to book-for user2: the admin may now override the assignment → + // green assignedOverride icon, and the book action books for user2. + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assignedOverride'); + + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'book'); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seat], + ); + expect(r.rows[0].cnt).toBe(1); + }); }); \ No newline at end of file diff --git a/js/views/html/plan.html b/js/views/html/plan.html index 7520af76..e25c5ef3 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -61,6 +61,10 @@
The seat is assigned to you and available to be booked. + +
+ The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here. +
The seat is assigned to you and available, but you have another booking at that time which will be automatically updated. diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 0476c07c..f71182cf 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -407,6 +407,16 @@ WarpSeat.prototype._updateState = function() { } var assignedButNotForMe = false; + // Book-for override of a seat-level assignment (see apply() skipping + // 106/110 under is_book_for): a zone admin booking FOR a target may book + // onto a seat assigned to someone else, or beyond the target's + // days-in-advance window. Only under book-for (factory.login != the real + // login) and where `bookable` holds — which under book-for already means + // the actor administers the zone. Self-booking never overrides (106/110 + // apply), so the green override icon is book-for-only. Set in the + // assignment block below; read by spriteFor to render cell-assignedOverride. + this.assignedOverride = false; + const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; if (Object.keys(this.assignments).length > 0) { @@ -446,15 +456,30 @@ WarpSeat.prototype._updateState = function() { // server-anchored: service timezone may differ from the client's var cutoffTs = window.warpGlobals.today + (bestDays + 1) * 24 * 3600; if (this.factory.selectedDates.some(d => d.fromTS >= cutoffTs)) { - this.state = WarpSeat.SeatStates.ASSIGNED; - return this.state; + if (bookForOverride) { + // Book-for overrides the days-in-advance window + // (apply() skips 110 under is_book_for): fall through to + // CAN_BOOK, flagged so the sprite renders the green + // override icon instead of the grey ASSIGNED one. + this.assignedOverride = true; + } else { + this.state = WarpSeat.SeatStates.ASSIGNED; + return this.state; + } } } // dates are within window — fall through to booking state } else { // Specific assignment(s) exist, but not for this user and no everyone row. - // The seat is effectively assigned to others; still show as booked if it is. - assignedButNotForMe = true; + if (bookForOverride) { + // Book-for overrides the assignment check (apply() skips 106 + // under is_book_for): fall through to CAN_BOOK, flagged for the + // green override icon. + this.assignedOverride = true; + } else { + // The seat is effectively assigned to others; still show as booked if it is. + assignedButNotForMe = true; + } } } @@ -534,9 +559,15 @@ WarpSeat.prototype._updateState = function() { // Map a (final) seat state + assignedToMe flag to a #cell- sprite name // (PLAN_officemap.md §3). The state must already reflect the CAN_REBOOK / // VIEW_ONLY side-effects applied in _updateView below. -function spriteFor(state, assignedToMe) { +function spriteFor(state, assignedToMe, assignedOverride) { switch (state) { - case WarpSeat.SeatStates.CAN_BOOK: return assignedToMe ? 'availableAssigned' : 'available'; + case WarpSeat.SeatStates.CAN_BOOK: + // Book-for override of an assignment (assigned to another, or beyond + // the target's window) takes precedence over the "assigned to you" + // blue variant — the seat is only bookable because the admin is + // overriding, not because it's within the target's normal rights. + if (assignedOverride) return 'assignedOverride'; + return assignedToMe ? 'availableAssigned' : 'available'; case WarpSeat.SeatStates.CAN_REBOOK: return assignedToMe ? 'rebookAssigned' : 'rebook'; case WarpSeat.SeatStates.CAN_CHANGE: return 'yoursChange'; case WarpSeat.SeatStates.CAN_DELETE_EXACT: return 'yours'; @@ -571,7 +602,7 @@ WarpSeat.prototype._updateView = function() { // all other states are already final from _updateState } - this.sprite = spriteFor(this.state, assignedToMe); + this.sprite = spriteFor(this.state, assignedToMe, this.assignedOverride); } WarpSeat.prototype._destroy = function() { diff --git a/js/views/plan.js b/js/views/plan.js index 9d3e97df..60d1c559 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -893,6 +893,7 @@ export async function mount(ctx) { userRebook: 'yoursChange', userConflict: 'taken', bookAssigned: 'availableAssigned', + bookAssignedOverride: 'assignedOverride', rebookAssigned: 'rebookAssigned', disabled: 'unavailable', assigned: 'assigned' diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index c550ab6b..baef8e94 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -167,6 +167,7 @@ "The seat is available to be booked on the selected dates and time.": "Der Sitzplatz kann zu den ausgewählten Daten und Uhrzeiten gebucht werden.", "The seat is available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is assigned to you and available to be booked.": "Der Sitzplatz ist Ihnen zugewiesen und kann gebucht werden.", + "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Der Sitzplatz ist einer anderen Person zugewiesen, aber als Zonen-Administrator, der für einen Benutzer bucht, können Sie diese Zuweisung überschreiben und hier für ihn buchen.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist Ihnen zugewiesen und verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is booked by you exactly on the selected date and time.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index 20dc317c..cb8891f5 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -167,6 +167,7 @@ "The seat is available to be booked on the selected dates and time.": "The seat is available to be booked on the selected dates and time.", "The seat is available, but you have another booking at that time which will be automatically updated.": "The seat is available, but you have another booking at that time which will be automatically updated.", "The seat is assigned to you and available to be booked.": "The seat is assigned to you and available to be booked.", + "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.", "The seat is booked by you exactly on the selected date and time.": "The seat is booked by you exactly on the selected date and time.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index f0feb723..a9e950bb 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -167,6 +167,7 @@ "The seat is available to be booked on the selected dates and time.": "Este asiento está disponible para ser reservado en las fechas y horas seleccionadas.", "The seat is available, but you have another booking at that time which will be automatically updated.": "Este asiento está disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is assigned to you and available to be booked.": "El asiento está asignado a usted y disponible para reservar.", + "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "El asiento está asignado a otra persona, pero como administrador de zona que reserva para un usuario, puede anular esa asignación y reservarlo para él aquí.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "El asiento está asignado a usted y disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is booked by you exactly on the selected date and time.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index ff3be765..9a83e916 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -167,6 +167,7 @@ "The seat is available to be booked on the selected dates and time.": "Le siège est disponible à la réservation aux dates et horaires sélectionnés.", "The seat is available, but you have another booking at that time which will be automatically updated.": "Le siège est disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is assigned to you and available to be booked.": "Le siège vous est assigné et disponible à la réservation.", + "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Le siège est assigné à quelqu'un d'autre, mais en tant qu'administrateur de zone réservant pour un utilisateur, vous pouvez outrepasser cette assignation et le réserver pour lui ici.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Le siège vous est assigné et disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is booked by you exactly on the selected date and time.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index 5e7e8c4e..0cbbec0e 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -167,6 +167,7 @@ "The seat is available to be booked on the selected dates and time.": "Miejsce jest dostępne do zarezerwowania w wybranym czasie", "The seat is available, but you have another booking at that time which will be automatically updated.": "Miejsce jest dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is assigned to you and available to be booked.": "Miejsce jest przypisane do Ciebie i dostępne do rezerwacji.", + "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Miejsce jest przypisane do innej osoby, ale jako administrator strefy rezerwujący dla użytkownika możesz pominąć to przypisanie i zarezerwować to miejsce dla niego tutaj.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Miejsce jest przypisane do Ciebie i dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is booked by you exactly on the selected date and time.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", diff --git a/warp/static/images/seat_icons.svg b/warp/static/images/seat_icons.svg index ad811555..7de5e556 100644 --- a/warp/static/images/seat_icons.svg +++ b/warp/static/images/seat_icons.svg @@ -1,4 +1,4 @@ - + + + + + + + @@ -102,4 +109,5 @@ + From 58d798197825e5f311d34f865eacb5ba8ab1d068 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 01:50:15 +0200 Subject: [PATCH 07/17] Let pure viewers release their own booking from the plan map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pure viewer (isZoneViewer: view-only access to every zone on the plan) could see their own booking on the map (blue "yours" head) but couldn't release it there — the action modal was removed from the DOM by data-requires="notZoneViewer" and initActionMenu bailed out on isZoneViewer, so the only path was the bookings table. That conflicted with the "always can unbook your own booking" invariant the bookings table now upholds. Keep the action modal in the DOM for viewers (drop the data-requires="notZoneViewer") and let initActionMenu register the click handler. The existing state machine already restricts what a viewer sees: CAN_BOOK is demoted to VIEW_ONLY (early return, no modal), TAKEN/ASSIGNED push no action and isMyZoneAdmin() is false so no seat-edit (actions empty → return before open), and only CAN_DELETE / CAN_DELETE_EXACT (own booking) push 'delete' → the Release modal. So non-actionable seats open no panel, and the auto-book FAB + book-for input stay hidden for viewers via their own gates. Update the stale zone-permissions assertion that expected the action modal absent for viewers (it now exists; the auto-book FAB still doesn't). Add a sprite e2e: pure viewer with an own booking → yours + Release works end-to-end, while a free seat and a taken-by-other seat open no modal. FEATURES.md §27 notes the viewer release path. Co-Authored-By: GLM 5.2 --- FEATURES.md | 2 + e2e/tests/booking/zone-permissions.spec.ts | 8 ++-- e2e/tests/booking/zone-view-sprites.spec.ts | 44 +++++++++++++++++++++ js/views/html/plan.html | 9 +++-- js/views/plan.js | 12 ++++-- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 549688e4..dd0e303e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -809,6 +809,8 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* | **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Edit | + Edit | + Edit | | **Viewer actions** | — | — | — | — | — | — | — | — | — | +A viewer (view-only access to every zone on the plan) can still **release their own booking** from the plan map — clicking it opens the action panel with only the Release button. Non-actionable seats (free, taken by others, assigned) open no panel. The auto-book button and book-for input stay hidden in viewer mode. + --- ## 28. Configuration Reference diff --git a/e2e/tests/booking/zone-permissions.spec.ts b/e2e/tests/booking/zone-permissions.spec.ts index 571e941f..778d6fae 100644 --- a/e2e/tests/booking/zone-permissions.spec.ts +++ b/e2e/tests/booking/zone-permissions.spec.ts @@ -424,10 +424,12 @@ test.describe('plan with all view-only zones → isZoneViewer', () => { await page.goto('/plan/1'); await waitForSeatsLoaded(page); - // The action modal HTML should not exist (viewer mode hides it) - await expect(page.locator('#action_modal')).toHaveCount(0); + // The action modal HTML stays in the DOM for viewers so a pure viewer can + // still release their OWN booking from the plan map; the click handler opens + // it only for an own booking (CAN_DELETE*), never for non-actionable seats. + await expect(page.locator('#action_modal')).toHaveCount(1); - // Auto-book FAB should not exist either + // Auto-book FAB must not exist (viewers can't auto-book). await expect(page.locator('#auto_book_btn')).toHaveCount(0); }); }); diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index b76f12b5..5c24ced2 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -194,6 +194,50 @@ test.describe('own booking in a view-only zone', () => { }); }); +// --------------------------------------------------------------------------- +// Pure viewer (isZoneViewer): the action modal is kept in the DOM so an own +// booking can be released from the plan map, but non-actionable seats open no +// modal. (Counterpart to test 5, which uses a mixed plan so isZoneViewer is +// false; here the user has ONLY view-only access.) +// --------------------------------------------------------------------------- + +test.describe('pure viewer releasing own booking from the plan map', () => { + test('9. pure viewer: own booking -> yours + release works; free/TAKEN open no modal', async ({ page }) => { + const pid = await createPlan('Sprite Pure Viewer Plan', 1); + const zid = await createZone('SPV Zone', ZONE_TYPE_PUBLIC_VIEW); + const [ownSeat, freeSeat, takenSeat] = await addSeats(pid, zid, ['SPV.own', 'SPV.free', 'SPV.taken']); + const { fromTS, toTS } = slot(); + await insertBooking(USER3.login, ownSeat, fromTS, toTS); + await insertBooking(USER1.login, takenSeat, fromTS, toTS); + + await logIn(page, USER3); // user3 has no explicit role -> pure viewer + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // Own booking -> yours; the other two are non-actionable for a viewer. + await expectSprite(page, ownSeat, 'cell-yours'); + await expectSprite(page, freeSeat, 'cell-unavailable'); + await expectSprite(page, takenSeat, 'cell-taken'); + + // Non-actionable seats must NOT open the bottom-sheet panel. + await clickExpectsNoModal(page, freeSeat); + await clickExpectsNoModal(page, takenSeat); + + // Own booking -> Release modal -> booking gone (apply() remove bypasses the + // zone-admin check for own bookings). + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'delete'); + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER3.login, ownSeat], + ); + expect(r.rows[0].cnt).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // Risk #1 guard: conflict map across a mixed ENABLED + PUBLIC_VIEW zone_group // --------------------------------------------------------------------------- diff --git a/js/views/html/plan.html b/js/views/html/plan.html index e25c5ef3..74a7c493 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -1,6 +1,9 @@ - - + + diff --git a/js/views/plan.js b/js/views/plan.js index 60d1c559..7e55b4a6 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -438,9 +438,15 @@ export async function mount(ctx) { function initActionMenu(seatFactory) { - if (window.warpGlobals.isZoneViewer) - return; - + // isZoneViewer no longer bails out: a pure viewer can still release + // their OWN booking from the plan map (CAN_DELETE/CAN_DELETE_EXACT → + // 'delete'). The click handler's state machine already opens no modal + // for non-actionable seats (VIEW_ONLY/NOT_AVAILABLE early-return; + // TAKEN/ASSIGNED push no action and isMyZoneAdmin() is false for a + // viewer so no seat-edit → actions empty → return before open()). + // Booking actions (book/rebook) can't fire for !bookable seats, and + // the auto-book FAB + book-for input stay hidden for viewers via their + // own data-requires/isZoneViewer gates. var seat = null; // used for passing seat to btn click events (closure) var assignedData = []; From dd38364dae1e20b7b52d6997199631dac8162114 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 02:06:19 +0200 Subject: [PATCH 08/17] Keep an own booking blue in a view-only zone when the time isn't exact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a view-only zone (!bookable), an own booking that overlaps the selected dates but isn't an exact time match used to fall from CAN_CHANGE to CAN_DELETE via the !bookable demotion, and CAN_DELETE renders the grey "taken" icon — so the moment the slider didn't match the booking exactly, the user's own booking turned grey (looking like someone else's / not actionable), even though it was still theirs and releasable. Demote the action (CAN_CHANGE -> CAN_DELETE: only Release is offered, not Update — apply() would 403 a change in a view-only zone) but flag it so spriteFor renders the blue "yours" head for that case. The exact-match case (CAN_DELETE_EXACT) is unchanged. Normal (bookable) zones are untouched — CAN_CHANGE still shows the blue "yoursChange" (with change arrows). isExactMatch (auto-book FAB "already exactly booked" detection) only checks state === CAN_DELETE_EXACT, so the new flag doesn't affect it. Update the legend copy for the "yours" icon (all 5 locales) to note it also shows for a non-exact own booking in a view-only zone (release only). Add a sprite e2e: pure viewer, own booking seeded at 10:00-16:00 with the default 09:00-17:00 slider -> cell-yours (not grey taken, not yoursChange), and release still works. Co-Authored-By: GLM 5.2 --- e2e/tests/booking/zone-view-sprites.spec.ts | 32 +++++++++++++++++++++ js/views/html/plan.html | 2 +- js/views/modules/seat.js | 20 +++++++++++-- warp/static/i18n/de.json | 2 +- warp/static/i18n/en.json | 2 +- warp/static/i18n/es.json | 2 +- warp/static/i18n/fr.json | 2 +- warp/static/i18n/pl.json | 2 +- 8 files changed, 56 insertions(+), 8 deletions(-) diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 5c24ced2..081f1fc6 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -236,6 +236,38 @@ test.describe('pure viewer releasing own booking from the plan map', () => { ); expect(r.rows[0].cnt).toBe(0); }); + + test('10. pure viewer: own booking with non-matching time stays blue (yours), release works', async ({ page }) => { + // user3's booking is seeded at 10:00-16:00; the default slider selects + // 09:00-17:00, so the booking overlaps but is NOT an exact match. Previously + // this demoted CAN_CHANGE -> CAN_DELETE -> grey "taken"; the own-booking + // release-only demotion now keeps the blue "yours" icon (release still + // offered, change is not). + const pid = await createPlan('Sprite NonExact Plan', 1); + const zid = await createZone('SNE Zone', ZONE_TYPE_PUBLIC_VIEW); + const [ownSeat] = await addSeats(pid, zid, ['SNE.1']); + await insertBooking(USER3.login, ownSeat, DAY + 10 * 3600, DAY + 16 * 3600); + + await logIn(page, USER3); // pure viewer + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); // default slider 09:00-17:00 + await page.waitForTimeout(400); + + // Non-exact own booking -> still blue "yours" (NOT grey "taken", not the + // blue "yoursChange" which would imply an available update). + await expectSprite(page, ownSeat, 'cell-yours'); + + // Release still works end-to-end for the non-exact own booking. + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'delete'); + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER3.login, ownSeat], + ); + expect(r.rows[0].cnt).toBe(0); + }); }); // --------------------------------------------------------------------------- diff --git a/js/views/html/plan.html b/js/views/html/plan.html index 74a7c493..2a693711 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -74,7 +74,7 @@
- The seat is booked by you exactly on the selected date and time. + The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.
diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index f71182cf..64e67a94 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -416,6 +416,11 @@ WarpSeat.prototype._updateState = function() { // apply), so the green override icon is book-for-only. Set in the // assignment block below; read by spriteFor to render cell-assignedOverride. this.assignedOverride = false; + // Set when a !bookable seat's own-booking CAN_CHANGE is demoted to CAN_DELETE + // (below): the action becomes release-only, but the icon stays blue "yours" + // so the user recognises their own booking — the grey "taken" icon would + // imply someone else's booking / not actionable. Read by spriteFor. + this.ownReleaseOnly = false; const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; if (Object.keys(this.assignments).length > 0) { @@ -549,7 +554,13 @@ WarpSeat.prototype._updateState = function() { if (this.state == WarpSeat.SeatStates.CAN_BOOK) { this.state = WarpSeat.SeatStates.VIEW_ONLY; } else if (this.state == WarpSeat.SeatStates.CAN_CHANGE) { + // Own booking that could normally be changed (extended/reduced) — in + // a view-only zone only release is allowed, so demote the action to + // CAN_DELETE (delete only). Keep the blue "yours" icon (not the grey + // "taken" one) so the user still recognises their own booking even + // when the selected time doesn't match exactly. this.state = WarpSeat.SeatStates.CAN_DELETE; + this.ownReleaseOnly = true; } } @@ -559,7 +570,7 @@ WarpSeat.prototype._updateState = function() { // Map a (final) seat state + assignedToMe flag to a #cell- sprite name // (PLAN_officemap.md §3). The state must already reflect the CAN_REBOOK / // VIEW_ONLY side-effects applied in _updateView below. -function spriteFor(state, assignedToMe, assignedOverride) { +function spriteFor(state, assignedToMe, assignedOverride, ownReleaseOnly) { switch (state) { case WarpSeat.SeatStates.CAN_BOOK: // Book-for override of an assignment (assigned to another, or beyond @@ -572,6 +583,11 @@ function spriteFor(state, assignedToMe, assignedOverride) { case WarpSeat.SeatStates.CAN_CHANGE: return 'yoursChange'; case WarpSeat.SeatStates.CAN_DELETE_EXACT: return 'yours'; case WarpSeat.SeatStates.CAN_DELETE: + // ownReleaseOnly: a view-only-zone own booking demoted from + // CAN_CHANGE — release-only, but still yours (blue head), not grey + // "taken". + if (ownReleaseOnly) return 'yours'; + return 'taken'; case WarpSeat.SeatStates.TAKEN: return 'taken'; case WarpSeat.SeatStates.ASSIGNED: return 'assigned'; case WarpSeat.SeatStates.VIEW_ONLY: @@ -602,7 +618,7 @@ WarpSeat.prototype._updateView = function() { // all other states are already final from _updateState } - this.sprite = spriteFor(this.state, assignedToMe, this.assignedOverride); + this.sprite = spriteFor(this.state, assignedToMe, this.assignedOverride, this.ownReleaseOnly); } WarpSeat.prototype._destroy = function() { diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index baef8e94..8708dd65 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "Der Sitzplatz ist Ihnen zugewiesen und kann gebucht werden.", "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Der Sitzplatz ist einer anderen Person zugewiesen, aber als Zonen-Administrator, der für einen Benutzer bucht, können Sie diese Zuweisung überschreiben und hier für ihn buchen.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist Ihnen zugewiesen und verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", - "The seat is booked by you exactly on the selected date and time.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht.", + "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht. In einer Nur-Ansicht-Zone wird dieses Symbol auch für Ihre Buchung gezeigt, wenn die gewählte Zeit nicht genau passt — Sie können sie freigeben, aber nicht ändern.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", "The seat is booked by someone else or not available for booking.": "Der Platz ist von einer anderen Person gebucht oder nicht zur Buchung verfügbar.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone. Belegte oder zugewiesene Plätze in einer Nur-Ansicht-Zone zeigen weiterhin ihr echtes Symbol.", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index cb8891f5..5862be86 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "The seat is assigned to you and available to be booked.", "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.", - "The seat is booked by you exactly on the selected date and time.": "The seat is booked by you exactly on the selected date and time.", + "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", "The seat is booked by someone else or not available for booking.": "The seat is booked by someone else or not available for booking.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index a9e950bb..6bd9aa88 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "El asiento está asignado a usted y disponible para reservar.", "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "El asiento está asignado a otra persona, pero como administrador de zona que reserva para un usuario, puede anular esa asignación y reservarlo para él aquí.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "El asiento está asignado a usted y disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", - "The seat is booked by you exactly on the selected date and time.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas.", + "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas. En una zona de solo lectura, este icono también se muestra para su reserva cuando la hora seleccionada no coincide exactamente — puede liberarla, pero no cambiarla.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", "The seat is booked by someone else or not available for booking.": "Este asiento está reservado por otra persona o no está disponible para reservar.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura. Los asientos ocupados o asignados en una zona de solo lectura siguen mostrando su icono real.", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index 9a83e916..ba098f9f 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "Le siège vous est assigné et disponible à la réservation.", "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Le siège est assigné à quelqu'un d'autre, mais en tant qu'administrateur de zone réservant pour un utilisateur, vous pouvez outrepasser cette assignation et le réserver pour lui ici.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Le siège vous est assigné et disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", - "The seat is booked by you exactly on the selected date and time.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés.", + "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés. Dans une zone en lecture seule, cette icône s'affiche aussi pour votre réservation lorsque l'horaire sélectionné ne correspond pas exactement — vous pouvez la libérer, mais pas la modifier.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", "The seat is booked by someone else or not available for booking.": "Le siège est réservé par quelqu'un d'autre ou non disponible à la réservation.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule. Les sièges occupés ou assignés dans une zone en lecture seule affichent toujours leur icône réelle.", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index 0cbbec0e..1f4e1d0c 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "Miejsce jest przypisane do Ciebie i dostępne do rezerwacji.", "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Miejsce jest przypisane do innej osoby, ale jako administrator strefy rezerwujący dla użytkownika możesz pominąć to przypisanie i zarezerwować to miejsce dla niego tutaj.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Miejsce jest przypisane do Ciebie i dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", - "The seat is booked by you exactly on the selected date and time.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie.", + "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie. W strefie tylko do odczytu ta ikona pokazuje się też dla Twojej rezerwacji, gdy wybrany czas nie pasuje dokładnie — możesz ją zwolnić, ale nie zmienić.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", "The seat is booked by someone else or not available for booking.": "Miejsce jest zarezerwowane przez kogoś innego lub niedostępne do rezerwacji.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu. Zajęte lub przypisane miejsca w strefie tylko do odczytu nadal pokazują swoją prawdziwą ikonę.", From dfecf0248c2104100fc5704348b0b87c42213d64 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 10:25:16 +0200 Subject: [PATCH 09/17] Let a zone admin release another user's booking from the plan map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zone admin could release someone else's booking from the bookings table (apply()'s remove requires per-seat zone-admin for foreign bookings, which an admin of the seat's zone satisfies) but not from the plan map: clicking a taken (foreign-booked) seat only offered seat-edit, never Release. The plan-view click handler had no action for the TAKEN state. Add a TAKEN case gated on isMyZoneAdmin(): it pushes 'delete' (Release) and flags a "foreign release". The delete action then targets the foreign booking's bid(s) on that seat (new WarpSeat.getForeignBookings, which walks the bookings overlapping the current selection and skips the acting user's own), not the acting user's own same-group conflicts (getMyConflictingBookings) that an own-booking delete/update removes. The "to be released" panel row names the foreign owner. Non-admins get no action on a taken seat (TAKEN stays informational) — isMyZoneAdmin() is false for them. apply() is unchanged: a foreign remove already passes when the actor administers the seat's zone (seatsReqZoneAdmin). The bookings-table path is unaffected (it never used the plan-view click handler). Update FEATURES.md §27 (zone admin: + Release on a taken seat) and add a sprite e2e: admin clicks a foreign-booked seat -> Release modal (delete offered, book not, the to-be-released row names the owner) -> booking gone; a non-admin clicking a foreign-booked seat gets no modal. Co-Authored-By: GLM 5.2 --- FEATURES.md | 4 +- e2e/tests/booking/zone-view-sprites.spec.ts | 57 +++++++++++++++++++++ js/views/modules/seat.js | 28 ++++++++++ js/views/plan.js | 37 +++++++++++-- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index dd0e303e..3c157d8e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -806,11 +806,13 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* | Seat State | No Dates | Green (Book) | Green (Rebook) | Blue (Update) | Blue (Conflict) | Blue (Exact) | Red (Taken) | Yellow (Assigned) | Gray (Disabled) | |-----------------------|----------|-----------------------|----------------------|---------------|-----------------|--------------|-------------|-----------------------------|-----------------------------| | **User actions** | — | Book | Book (replaces) | Update | Remove | Remove | — | — | — | -| **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Edit | + Edit | + Edit | +| **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Release, + Edit | + Edit | + Edit | | **Viewer actions** | — | — | — | — | — | — | — | — | — | A viewer (view-only access to every zone on the plan) can still **release their own booking** from the plan map — clicking it opens the action panel with only the Release button. Non-actionable seats (free, taken by others, assigned) open no panel. The auto-book button and book-for input stay hidden in viewer mode. +A zone admin can **release another user's booking** from the plan map by clicking a taken seat in a zone they administer — the action panel offers Release (apply() allows it via the per-seat zone-admin check for foreign removes). Non-admins get no action on a taken seat. + --- ## 28. Configuration Reference diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 081f1fc6..6caf3229 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -386,4 +386,61 @@ test.describe('book-for override of an assignment', () => { ); expect(r.rows[0].cnt).toBe(1); }); +}); + +// --------------------------------------------------------------------------- +// Zone admin releasing another user's booking from the plan map. The seat is +// TAKEN (foreign booking); a zone admin who administers the seat's zone may +// release it directly (apply()'s remove requires per-seat zone-admin for +// foreign bookings). Non-admins get no action (TAKEN stays informational). +// --------------------------------------------------------------------------- + +test.describe('zone admin releasing another user booking from the plan map', () => { + test('11. admin clicks a foreign-booked seat -> Release works; non-admin gets no modal', async ({ page }) => { + const pid = await createPlan('Sprite ForeignRelease Plan', 1); + const zid = await createZone('FR Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['FR.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + const { fromTS, toTS } = slot(); + await insertBooking(USER2.login, seat, fromTS, toTS); // foreign to user1 + + await logIn(page, USER1); // admin of the zone + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // The seat is TAKEN (foreign booking). + await expectSprite(page, seat, 'cell-taken'); + + // Admin -> Release modal (delete offered, book not). + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + // The to-be-released row names the foreign owner. + await expect(page.locator('#action_modal_msg2')).toContainText(USER2.name); + await clickActionBtn(page, 'delete'); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seat], + ); + expect(r.rows[0].cnt).toBe(0); + + // A non-admin clicking a foreign-booked seat gets no modal (TAKEN is + // informational for non-admins). + await logIn(page, USER2); // USER in the zone, but the booking is gone + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await insertBooking(USER1.login, seat, fromTS, toTS); // now booked by user1 (foreign to user2) + await page.reload(); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await page.locator(`#sprite-${seat}`).click(); + await page.waitForTimeout(300); + await expect(page.locator('#action_modal')).not.toHaveClass(/open/); + }); }); \ No newline at end of file diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 64e67a94..42d0dcc0 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -365,6 +365,34 @@ WarpSeat.prototype.getBookings = function() { return res; } +// Another user's bookings on this seat overlapping the current selection +// (login != the acting user). Used by the plan-view action modal to let a +// zone admin release someone else's booking on a seat they administer +// (apply()'s remove requires per-seat zone-admin for foreign bookings). With +// raw=true returns an array of bid's; otherwise display objects +// {seat_name, zone_name, username, datetime1, datetime2}. +WarpSeat.prototype.getForeignBookings = function(raw) { + + var res = []; + + for (let i of this._bookingsIterator()) { + if (i.book.login == this.factory.login) + continue; + if (raw) { + res.push(i.book.bid); + } else { + res.push( Object.assign({ + seat_name: this.getName(), + zone_name: this.getZoneName(), + username: i.book.username, + }, + WarpSeatFactory._formatDatePair(i.book)) ); + } + } + + return res; +} + /** * Iterates over relevant (by given selectedDates) seat bookings */ diff --git a/js/views/plan.js b/js/views/plan.js index 7e55b4a6..cc455355 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -645,6 +645,7 @@ export async function mount(ctx) { var bookMsg = false; var removeMsg = false; var blockedMsg = false; + var foreignRelease = false; switch (state) { case WarpSeat.SeatStates.CAN_BOOK: @@ -674,6 +675,17 @@ export async function mount(ctx) { actions.push('delete'); removeMsg = true; break; + case WarpSeat.SeatStates.TAKEN: + // A zone admin may release another user's booking on a seat + // in a zone they administer (apply()'s remove requires + // per-seat zone-admin for foreign bookings). Non-admins get + // no action — TAKEN stays informational. + if (this.isMyZoneAdmin()) { + actions.push('delete'); + removeMsg = true; + foreignRelease = true; + } + break; }; // Per-seat check: isZoneAdmin is plan-wide (true if the actor admins @@ -718,11 +730,22 @@ export async function mount(ctx) { if (removeMsg) { + // For a zone-admin release of someone else's booking on this + // seat, list the foreign booking(s); otherwise the acting + // user's own same-group conflicts (an update/delete). + var releaseList = foreignRelease + ? this.getForeignBookings() + : seatFactory.getMyConflictingBookings(this); + var myConflictsTable = document.createElement("table"); - for (let c of seatFactory.getMyConflictingBookings(this)) { + for (let c of releaseList) { let tr = myConflictsTable.appendChild(document.createElement("tr")); - tr.appendChild( document.createElement("td")).innerText = c.zone_name - tr.appendChild( document.createElement("td")).innerText = c.seat_name; + if (foreignRelease) { + tr.appendChild( document.createElement("td")).innerText = c.username; + } else { + tr.appendChild( document.createElement("td")).innerText = c.zone_name + tr.appendChild( document.createElement("td")).innerText = c.seat_name; + } tr.appendChild( document.createElement("td")).innerText = c.datetime1; tr.appendChild( document.createElement("td")).innerText = c.datetime2; } @@ -739,6 +762,7 @@ export async function mount(ctx) { } seat = this; + seat.foreignRelease = foreignRelease; actionModal.open(); }); @@ -787,7 +811,12 @@ export async function mount(ctx) { } if (this.dataset.action == 'delete' || this.dataset.action == 'update') { - applyData['remove'] = seatFactory.getMyConflictingBookings(seat, true); + // A zone-admin release of another user's booking (TAKEN seat) + // targets that foreign booking's bid, not the acting user's own + // same-group conflicts (which an update/own-release removes). + applyData['remove'] = (this.dataset.action == 'delete' && seat.foreignRelease) + ? seat.getForeignBookings(true) + : seatFactory.getMyConflictingBookings(seat, true); } // seat-edit-save with no net changes (toggle flipped back, assignments From 50a4aef6edb1252791d1ba32097a0223f66f6708 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 22:02:12 +0200 Subject: [PATCH 10/17] Remove the green assignedOverride seat icon (Phase 3A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Book-for onto a seat assigned to a third party renders plain green 'available'; a seat assigned to the target beyond its days-in-advance window renders blue 'availableAssigned' (falls out of assignedToMe once the flag is gone). The state-machine fall-through (book-for override -> CAN_BOOK) stays; only the flag/sprite plumbing goes. - seat.js: drop the assignedOverride flag init, the two assignment sites, the spriteFor parameter + CAN_BOOK branch, and the call-site argument. Keep the bookForOverride const and its fall-through logic. - seat_icons.svg: remove the cell-assignedOverride group + palette row, restore the 552 viewBox width. - plan.js: drop the bookAssignedOverride help-map entry. - plan.html: remove the assignedOverride legend row; trim the viewOnly legend row's edge-case clause (edge-case-free legend, Phase 3 guiding principle). - i18n (en/de/es/fr/pl): drop the assignedOverride key; shorten the viewOnly key+value in all five locales. - FEATURES.md §9: update the override note to plain green/availableAssigned wording. - e2e zone-view-sprites: test 8 now expects cell-available (third-party assignment); add test 8b pinning cell-availableAssigned for a seat assigned to the target beyond its window. Co-Authored-By: GLM 5.2 --- FEATURES.md | 2 +- e2e/tests/booking/zone-view-sprites.spec.ts | 41 +++++++++++++++++---- js/views/html/plan.html | 6 +-- js/views/modules/seat.js | 35 +++++++----------- js/views/plan.js | 1 - warp/static/i18n/de.json | 3 +- warp/static/i18n/en.json | 3 +- warp/static/i18n/es.json | 3 +- warp/static/i18n/fr.json | 3 +- warp/static/i18n/pl.json | 3 +- warp/static/images/seat_icons.svg | 10 +---- 11 files changed, 55 insertions(+), 55 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 3c157d8e..adc46cc2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -425,7 +425,7 @@ The old "Already booked in another zone" section is gone; an existing booking on - A "Book For" input field appears in the plan-view side panel (the booking map) for zone admins. - It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users, including viewers. -- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction and any seat-level assignment for that seat. A seat assigned to someone else (or beyond the target's days-in-advance window) that the admin can book onto this way shows a distinct green "assigned but bookable via override" icon (the assigned-to glyph in the book colour), so the admin sees the assignment and the override at once. +- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction and any seat-level assignment for that seat. A seat assigned to someone else renders plain green "available" under book-for; a seat assigned to the target beyond their days-in-advance window renders blue "availableAssigned". - When the admin books, updates, or removes a booking, it is performed **on behalf of the selected user**. - The admin can also auto-book for the selected user. - Clearing the "Book For" field (pressing Enter while empty) reverts to the admin's own view. diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 6caf3229..8027fb18 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -332,9 +332,9 @@ test.describe('assigned-to-me in a bookable ENABLED zone (risk #5 guard)', () => // --------------------------------------------------------------------------- // Book-for override of an assignment: admin books FOR a target onto a seat // assigned to someone else (apply() skips 106/110 under is_book_for). The -// seat is bookable despite the assignment, so it renders a distinct green -// "assigned + bookable via override" icon (head+tie in the available colour -// family), not the grey ASSIGNED one, and the book action is offered. +// seat is bookable despite the assignment, so it renders the plain green +// "available" icon (third-party assignment) or blue "availableAssigned" +// (assigned to the target, beyond its window) — no special override glyph. // --------------------------------------------------------------------------- /** Activate book-for for the given display label (e.g. "Bar [user2]"). */ @@ -350,7 +350,7 @@ async function activateBookFor(page: any, label: string): Promise { } test.describe('book-for override of an assignment', () => { - test('8. seat assigned to a third person, book-for target is a member → assignedOverride + book works', async ({ page }) => { + test('8. seat assigned to a third person, book-for target is a member → available + book works', async ({ page }) => { const pid = await createPlan('Sprite Override Plan', 1); const zid = await createZone('SO Zone', ZONE_TYPE_ENABLED); const [seat] = await addSeats(pid, zid, ['SO.1']); @@ -364,17 +364,18 @@ test.describe('book-for override of an assignment', () => { await page.goto(`/plan/${pid}`); await waitForSeatsLoaded(page); // Default self-view (admin): the seat is bookable at the zone level but - // self-book is blocked by 106 → grey ASSIGNED, NOT the override icon. + // self-book is blocked by 106 → grey ASSIGNED. await selectOnlyDates(page, [DAY]); await page.waitForTimeout(400); await expectSprite(page, seat, 'cell-assigned'); // Switch to book-for user2: the admin may now override the assignment → - // green assignedOverride icon, and the book action books for user2. + // plain green `available` (third-party assignment), and the book action + // books for user2. await activateBookFor(page, `${USER2.name} [${USER2.login}]`); await selectOnlyDates(page, [DAY]); await page.waitForTimeout(400); - await expectSprite(page, seat, 'cell-assignedOverride'); + await expectSprite(page, seat, 'cell-available'); await page.locator(`#sprite-${seat}`).click(); await expect(page.locator('#action_modal')).toHaveClass(/open/); @@ -386,6 +387,32 @@ test.describe('book-for override of an assignment', () => { ); expect(r.rows[0].cnt).toBe(1); }); + + test('8b. seat assigned to the target beyond its window, book-for → availableAssigned', async ({ page }) => { + // The seat is assigned to user2 (the book-for target) with days_in_advance + // = 0, so the default tomorrow slot is beyond the target's window. Self-view + // (admin user1) is not the assignee → grey ASSIGNED; under book-for user2 + // the window is overridden and the seat falls through to CAN_BOOK with + // assignedToMe → blue `availableAssigned`. + const pid = await createPlan('Sprite Override Target Plan', 1); + const zid = await createZone('SOT Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SOT.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await assignSeat(seat, USER2.login, 0); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assigned'); + + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-availableAssigned'); + }); }); // --------------------------------------------------------------------------- diff --git a/js/views/html/plan.html b/js/views/html/plan.html index 2a693711..5daf7227 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -64,10 +64,6 @@
The seat is assigned to you and available to be booked. - -
- The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here. -
The seat is assigned to you and available, but you have another booking at that time which will be automatically updated. @@ -90,7 +86,7 @@
- The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon. + The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.
diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 42d0dcc0..a0d5a324 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -435,20 +435,20 @@ WarpSeat.prototype._updateState = function() { } var assignedButNotForMe = false; + // Set when a !bookable seat's own-booking CAN_CHANGE is demoted to CAN_DELETE + // (below): the action becomes release-only, but the icon stays blue "yours" + // so the user recognises their own booking — the grey "taken" icon would + // imply someone else's booking / not actionable. Read by spriteFor. + this.ownReleaseOnly = false; // Book-for override of a seat-level assignment (see apply() skipping // 106/110 under is_book_for): a zone admin booking FOR a target may book // onto a seat assigned to someone else, or beyond the target's // days-in-advance window. Only under book-for (factory.login != the real // login) and where `bookable` holds — which under book-for already means // the actor administers the zone. Self-booking never overrides (106/110 - // apply), so the green override icon is book-for-only. Set in the - // assignment block below; read by spriteFor to render cell-assignedOverride. - this.assignedOverride = false; - // Set when a !bookable seat's own-booking CAN_CHANGE is demoted to CAN_DELETE - // (below): the action becomes release-only, but the icon stays blue "yours" - // so the user recognises their own booking — the grey "taken" icon would - // imply someone else's booking / not actionable. Read by spriteFor. - this.ownReleaseOnly = false; + // apply), so the fall-through is book-for-only. The seat then renders plain + // green `available` (third-party assignment) or blue `availableAssigned` + // (assigned to the target, beyond its window). const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; if (Object.keys(this.assignments).length > 0) { @@ -491,10 +491,8 @@ WarpSeat.prototype._updateState = function() { if (this.factory.selectedDates.some(d => d.fromTS >= cutoffTs)) { if (bookForOverride) { // Book-for overrides the days-in-advance window - // (apply() skips 110 under is_book_for): fall through to - // CAN_BOOK, flagged so the sprite renders the green - // override icon instead of the grey ASSIGNED one. - this.assignedOverride = true; + // (apply() skips 110 under is_book_for): fall through + // to CAN_BOOK (green available / availableAssigned). } else { this.state = WarpSeat.SeatStates.ASSIGNED; return this.state; @@ -506,9 +504,7 @@ WarpSeat.prototype._updateState = function() { // Specific assignment(s) exist, but not for this user and no everyone row. if (bookForOverride) { // Book-for overrides the assignment check (apply() skips 106 - // under is_book_for): fall through to CAN_BOOK, flagged for the - // green override icon. - this.assignedOverride = true; + // under is_book_for): fall through to CAN_BOOK (green available). } else { // The seat is effectively assigned to others; still show as booked if it is. assignedButNotForMe = true; @@ -598,14 +594,9 @@ WarpSeat.prototype._updateState = function() { // Map a (final) seat state + assignedToMe flag to a #cell- sprite name // (PLAN_officemap.md §3). The state must already reflect the CAN_REBOOK / // VIEW_ONLY side-effects applied in _updateView below. -function spriteFor(state, assignedToMe, assignedOverride, ownReleaseOnly) { +function spriteFor(state, assignedToMe, ownReleaseOnly) { switch (state) { case WarpSeat.SeatStates.CAN_BOOK: - // Book-for override of an assignment (assigned to another, or beyond - // the target's window) takes precedence over the "assigned to you" - // blue variant — the seat is only bookable because the admin is - // overriding, not because it's within the target's normal rights. - if (assignedOverride) return 'assignedOverride'; return assignedToMe ? 'availableAssigned' : 'available'; case WarpSeat.SeatStates.CAN_REBOOK: return assignedToMe ? 'rebookAssigned' : 'rebook'; case WarpSeat.SeatStates.CAN_CHANGE: return 'yoursChange'; @@ -646,7 +637,7 @@ WarpSeat.prototype._updateView = function() { // all other states are already final from _updateState } - this.sprite = spriteFor(this.state, assignedToMe, this.assignedOverride, this.ownReleaseOnly); + this.sprite = spriteFor(this.state, assignedToMe, this.ownReleaseOnly); } WarpSeat.prototype._destroy = function() { diff --git a/js/views/plan.js b/js/views/plan.js index cc455355..790562d6 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -928,7 +928,6 @@ export async function mount(ctx) { userRebook: 'yoursChange', userConflict: 'taken', bookAssigned: 'availableAssigned', - bookAssignedOverride: 'assignedOverride', rebookAssigned: 'rebookAssigned', disabled: 'unavailable', assigned: 'assigned' diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index 8708dd65..9ef2f517 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -167,12 +167,11 @@ "The seat is available to be booked on the selected dates and time.": "Der Sitzplatz kann zu den ausgewählten Daten und Uhrzeiten gebucht werden.", "The seat is available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is assigned to you and available to be booked.": "Der Sitzplatz ist Ihnen zugewiesen und kann gebucht werden.", - "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Der Sitzplatz ist einer anderen Person zugewiesen, aber als Zonen-Administrator, der für einen Benutzer bucht, können Sie diese Zuweisung überschreiben und hier für ihn buchen.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist Ihnen zugewiesen und verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht. In einer Nur-Ansicht-Zone wird dieses Symbol auch für Ihre Buchung gezeigt, wenn die gewählte Zeit nicht genau passt — Sie können sie freigeben, aber nicht ändern.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", "The seat is booked by someone else or not available for booking.": "Der Platz ist von einer anderen Person gebucht oder nicht zur Buchung verfügbar.", - "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone. Belegte oder zugewiesene Plätze in einer Nur-Ansicht-Zone zeigen weiterhin ihr echtes Symbol.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone.", "The seat is assigned to some people but currently not booked.": "Der Platz ist bestimmten Personen zugewiesen, aber derzeit nicht gebucht.", "Book for": "Buchen für", "Seat %{seat_name}": "Sitz %{seat_name}", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index 5862be86..5836509a 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -167,12 +167,11 @@ "The seat is available to be booked on the selected dates and time.": "The seat is available to be booked on the selected dates and time.", "The seat is available, but you have another booking at that time which will be automatically updated.": "The seat is available, but you have another booking at that time which will be automatically updated.", "The seat is assigned to you and available to be booked.": "The seat is assigned to you and available to be booked.", - "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.", "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", "The seat is booked by someone else or not available for booking.": "The seat is booked by someone else or not available for booking.", - "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.", "The seat is assigned to some people but currently not booked.": "The seat is assigned to some people but currently not booked.", "Book for": "Book for", "Seat %{seat_name}": "Seat %{seat_name}", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index 6bd9aa88..4d739a7a 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -167,12 +167,11 @@ "The seat is available to be booked on the selected dates and time.": "Este asiento está disponible para ser reservado en las fechas y horas seleccionadas.", "The seat is available, but you have another booking at that time which will be automatically updated.": "Este asiento está disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is assigned to you and available to be booked.": "El asiento está asignado a usted y disponible para reservar.", - "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "El asiento está asignado a otra persona, pero como administrador de zona que reserva para un usuario, puede anular esa asignación y reservarlo para él aquí.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "El asiento está asignado a usted y disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas. En una zona de solo lectura, este icono también se muestra para su reserva cuando la hora seleccionada no coincide exactamente — puede liberarla, pero no cambiarla.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", "The seat is booked by someone else or not available for booking.": "Este asiento está reservado por otra persona o no está disponible para reservar.", - "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura. Los asientos ocupados o asignados en una zona de solo lectura siguen mostrando su icono real.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura.", "The seat is assigned to some people but currently not booked.": "El asiento está asignado a algunas personas pero actualmente no está reservado.", "Book for": "Reservar para", "Seat %{seat_name}": "Asiento %{seat_name}", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index ba098f9f..88d959ef 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -167,12 +167,11 @@ "The seat is available to be booked on the selected dates and time.": "Le siège est disponible à la réservation aux dates et horaires sélectionnés.", "The seat is available, but you have another booking at that time which will be automatically updated.": "Le siège est disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is assigned to you and available to be booked.": "Le siège vous est assigné et disponible à la réservation.", - "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Le siège est assigné à quelqu'un d'autre, mais en tant qu'administrateur de zone réservant pour un utilisateur, vous pouvez outrepasser cette assignation et le réserver pour lui ici.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Le siège vous est assigné et disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés. Dans une zone en lecture seule, cette icône s'affiche aussi pour votre réservation lorsque l'horaire sélectionné ne correspond pas exactement — vous pouvez la libérer, mais pas la modifier.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", "The seat is booked by someone else or not available for booking.": "Le siège est réservé par quelqu'un d'autre ou non disponible à la réservation.", - "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule. Les sièges occupés ou assignés dans une zone en lecture seule affichent toujours leur icône réelle.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule.", "The seat is assigned to some people but currently not booked.": "Le siège est assigné à certaines personnes mais actuellement non réservé.", "Book for": "Réserver pour", "Seat %{seat_name}": "Siège %{seat_name}", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index 1f4e1d0c..6709d79a 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -167,12 +167,11 @@ "The seat is available to be booked on the selected dates and time.": "Miejsce jest dostępne do zarezerwowania w wybranym czasie", "The seat is available, but you have another booking at that time which will be automatically updated.": "Miejsce jest dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is assigned to you and available to be booked.": "Miejsce jest przypisane do Ciebie i dostępne do rezerwacji.", - "The seat is assigned to someone else, but as a zone admin booking for a user you may override that assignment and book it for them here.": "Miejsce jest przypisane do innej osoby, ale jako administrator strefy rezerwujący dla użytkownika możesz pominąć to przypisanie i zarezerwować to miejsce dla niego tutaj.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Miejsce jest przypisane do Ciebie i dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie. W strefie tylko do odczytu ta ikona pokazuje się też dla Twojej rezerwacji, gdy wybrany czas nie pasuje dokładnie — możesz ją zwolnić, ale nie zmienić.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", "The seat is booked by someone else or not available for booking.": "Miejsce jest zarezerwowane przez kogoś innego lub niedostępne do rezerwacji.", - "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. Occupied or assigned seats in a view-only zone still show their real icon.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu. Zajęte lub przypisane miejsca w strefie tylko do odczytu nadal pokazują swoją prawdziwą ikonę.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu.", "The seat is assigned to some people but currently not booked.": "Miejsce jest przypisane do niektórych osób, ale obecnie nie jest zarezerwowane.", "Book for": "Zarezerwuj dla", "Seat %{seat_name}": "Miejsce %{seat_name}", diff --git a/warp/static/images/seat_icons.svg b/warp/static/images/seat_icons.svg index 7de5e556..ad811555 100644 --- a/warp/static/images/seat_icons.svg +++ b/warp/static/images/seat_icons.svg @@ -1,4 +1,4 @@ - + - - - - - - @@ -109,5 +102,4 @@ - From 2038b04bea6f98eda5ba45e9124316aff33599e4 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 22:15:26 +0200 Subject: [PATCH 11/17] Non-exact own booking in a !bookable zone stays yoursChange + pure-shrink bypass (Phase 3B+3C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3B — reverts the display half of the ownReleaseOnly approach (commit dd38364): in view-only / DISABLED zones a non-exact own booking stays CAN_CHANGE -> 'yoursChange', same as bookable zones. The !bookable demotion map shrinks to a single line (CAN_BOOK -> VIEW_ONLY). - seat.js: delete the CAN_CHANGE -> CAN_DELETE demotion branch and the ownReleaseOnly flag init; revert spriteFor's CAN_DELETE special case to plain 'taken' and drop the parameter + call-site argument. Add WarpSeat.isSelectionShrinkOfMine(): true iff every selected range is fully contained within one of the acting user's OWN bookings on this seat. Uses window.warpGlobals.login (the real actor), not factory.login, so a book-for target's booking in a non-administered (!bookable) zone does NOT count -- changing/releasing it would 403 (release confinement / seatsReqZoneAdmin), so Update must not be offered there. This is a correctness refinement of the plan's literal 'book.login == factory.login' wording, which would have offered a doomed Update in that book-for edge case. - plan.js click handler, case CAN_CHANGE: push 'delete' always; push 'update' iff this.bookable OR isSelectionShrinkOfMine(). - plan.html + i18n (en/de/es/fr/pl): revert dd38364's added view-only note on the 'yours' legend row (edge-case-free legend). - e2e zone-view-sprites: flip test 10 to cell-yoursChange (and assert Update is not offered when the slider extends beyond the booking); add test 12 -- pure viewer shrinks an own booking through the modal (Update offered + succeeds, booking narrows; widening the slider back offers Release only). 3C -- the pure-shrink bypass in apply(). A self update (no book.login) whose every booked range is fully covered by one of the actor's own bookings being removed on that seat only narrows an existing own booking; a shrink never increases exposure, so it skips every booking check (role 104, DISABLED zone 104, seat-disabled 105, assignment 106/110, horizon 103). Book-for is never a shrink (is_book_for excluded). Remove-bid ownership is verified server-side by the login filter. - warp/xhr/plan.py: compute is_pure_shrink once at the top of the book block; guard each check with 'and not is_pure_shrink'. - e2e zone-shrink-bypass.spec.ts: new API-level spec -- viewer-zone shrink -> 200 (replaced) / extend -> 403 (104) / no-remove -> 403 (104); DISABLED-zone shrink -> 200; assigned-to-another shrink -> 200 / no-remove -> 403 (106). Co-Authored-By: GLM 5.2 --- e2e/tests/booking/zone-shrink-bypass.spec.ts | 171 +++++++++++++++++++ e2e/tests/booking/zone-view-sprites.spec.ts | 81 ++++++++- js/views/html/plan.html | 2 +- js/views/modules/seat.js | 67 ++++---- js/views/plan.js | 11 +- warp/static/i18n/de.json | 2 +- warp/static/i18n/en.json | 2 +- warp/static/i18n/es.json | 2 +- warp/static/i18n/fr.json | 2 +- warp/static/i18n/pl.json | 2 +- warp/xhr/plan.py | 37 +++- 11 files changed, 325 insertions(+), 54 deletions(-) create mode 100644 e2e/tests/booking/zone-shrink-bypass.spec.ts diff --git a/e2e/tests/booking/zone-shrink-bypass.spec.ts b/e2e/tests/booking/zone-shrink-bypass.spec.ts new file mode 100644 index 00000000..b130ed94 --- /dev/null +++ b/e2e/tests/booking/zone-shrink-bypass.spec.ts @@ -0,0 +1,171 @@ +/** + * Phase 3C: the pure-shrink bypass in apply() (PLAN_VIEW_ONLY_SEATS.md). + * + * Invariant (PERMISSIONS.md §8): any operation that strictly shrinks the + * actor's own bookings is always allowed, everywhere — release is the + * shrink-to-zero case (already ungated); this generalises it to a shrink to a + * subset. A self update (no book.login) whose every booked range is fully + * covered by one of the actor's own bookings being removed on that seat skips + * every booking check (role 104, DISABLED zone 104, seat-disabled 105, + * assignment 106/110, horizon 103). Book-for is never a shrink. + * + * These are apply()-code-level tests (apiApply), matching the style of + * zone-permissions-bookfor.spec.ts: the bypass is a server-side predicate on + * the book+remove payload, so the assertions are on HTTP status + code and on + * the persisted booking row seeded/read via the DB backchannel. + */ +import { test, expect } from '../../fixtures'; +import { logIn } from '../../helpers/auth'; +import { USER1, USER3 } from '../../helpers/users'; +import { querySql } from '../../helpers/db'; +import { futureDayTs, apiApply } from '../../helpers/booking'; +import { + ZONE_TYPE_ENABLED, + ZONE_TYPE_PUBLIC_VIEW, + ZONE_ROLE_USER, + createPlan, + createZone, + addSeats, + assignZoneRole, + assignSeat, + insertBooking, +} from '../../helpers/zone-setup'; + +const DAY = futureDayTs(1); +const FULL = { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 }; +const NARROW = { fromTS: DAY + 10 * 3600, toTS: DAY + 16 * 3600 }; +const EXTEND = { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 + 3600 }; + +/** The booking id for a (login, sid, fromts) triple — the remove bid. */ +async function bookingBid(login: string, sid: number, fromTS: number): Promise { + const r = await querySql( + 'SELECT id::int AS id FROM book WHERE login = $1 AND sid = $2 AND fromts = $3', + [login, sid, fromTS], + ); + expect(r.rows.length).toBe(1); + return r.rows[0].id; +} + +// --------------------------------------------------------------------------- +// Viewer zone (PUBLIC_VIEW): pure shrink allowed, extend still 104 +// --------------------------------------------------------------------------- + +test.describe('pure-shrink bypass: viewer zone', () => { + test('shrink own booking -> 200, booking replaced', async ({ page }) => { + const pid = await createPlan('Shrink Viewer Plan', 1); + const zid = await createZone('Shrink Viewer', ZONE_TYPE_PUBLIC_VIEW); + const [seat] = await addSeats(pid, zid, ['SV.1']); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + const bid = await bookingBid(USER3.login, seat, FULL.fromTS); + + await logIn(page, USER3); // pure viewer (no explicit role) + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] }, remove: [bid] }); + expect(resp.status()).toBe(200); + + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, seat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(NARROW.fromTS); + expect(after.rows[0].t).toBe(NARROW.toTS); + }); + + test('extend own booking (not a shrink) -> 403 / 104', async ({ page }) => { + const pid = await createPlan('Extend Viewer Plan', 1); + const zid = await createZone('Extend Viewer', ZONE_TYPE_PUBLIC_VIEW); + const [seat] = await addSeats(pid, zid, ['EV.1']); + // Booking narrower than the requested range so the request extends it. + await insertBooking(USER3.login, seat, NARROW.fromTS, NARROW.toTS); + const bid = await bookingBid(USER3.login, seat, NARROW.fromTS); + + await logIn(page, USER3); + const resp = await apiApply(page, { book: { sid: seat, dates: [EXTEND] }, remove: [bid] }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(104); + }); + + test('shrink without the matching remove bid -> 403 / 104', async ({ page }) => { + const pid = await createPlan('ShrinkNoRemove Viewer Plan', 1); + const zid = await createZone('ShrinkNoRemove Viewer', ZONE_TYPE_PUBLIC_VIEW); + const [seat] = await addSeats(pid, zid, ['SNR.1']); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + + await logIn(page, USER3); + // No remove bid -> is_pure_shrink is false -> the viewer role check fires. + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] } }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(104); + }); +}); + +// --------------------------------------------------------------------------- +// DISABLED zone: a shrink of an own booking is still allowed +// --------------------------------------------------------------------------- + +test.describe('pure-shrink bypass: DISABLED zone', () => { + test('shrink own booking in a DISABLED zone -> 200', async ({ page }) => { + const pid = await createPlan('Shrink Disabled Plan', 1); + const zid = await createZone('Shrink Disabled', 10 /* DISABLED */); + const [seat] = await addSeats(pid, zid, ['SD.1']); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + const bid = await bookingBid(USER3.login, seat, FULL.fromTS); + + await logIn(page, USER3); + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] }, remove: [bid] }); + expect(resp.status()).toBe(200); + + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, seat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(NARROW.fromTS); + expect(after.rows[0].t).toBe(NARROW.toTS); + }); +}); + +// --------------------------------------------------------------------------- +// Seat assigned to someone else: the assignment checks (106/110) are bypassed +// --------------------------------------------------------------------------- + +test.describe('pure-shrink bypass: assigned-to-another seat', () => { + test('shrink own booking on a seat assigned to another -> 200', async ({ page }) => { + const pid = await createPlan('Shrink Assigned Plan', 1); + const zid = await createZone('Shrink Assigned', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SA.1']); + await assignZoneRole(zid, USER3.login, ZONE_ROLE_USER); + // Seat assigned to user1; user3 nonetheless holds a booking (seeded) — + // shrinking it must bypass the assignment check (106). + await assignSeat(seat, USER1.login, null); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + const bid = await bookingBid(USER3.login, seat, FULL.fromTS); + + await logIn(page, USER3); + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] }, remove: [bid] }); + expect(resp.status()).toBe(200); + + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, seat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(NARROW.fromTS); + expect(after.rows[0].t).toBe(NARROW.toTS); + }); + + test('shrink without remove on an assigned-to-another seat -> 403 / 106', async ({ page }) => { + const pid = await createPlan('ShrinkNoRemove Assigned Plan', 1); + const zid = await createZone('ShrinkNoRemove Assigned', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SNA.1']); + await assignZoneRole(zid, USER3.login, ZONE_ROLE_USER); + await assignSeat(seat, USER1.login, null); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + + await logIn(page, USER3); + // No remove -> not a pure shrink -> the assignment check (106) fires. + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] } }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(106); + }); +}); \ No newline at end of file diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 8027fb18..ea2d1245 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -78,6 +78,21 @@ function slot() { return { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 }; } +/** Set the plan time-slider range via the HH:MM edit boxes (fires `change`, + * which the app maps to a noUiSlider.set). Used to test pure-shrink vs extend + * selections against a seeded own booking. */ +async function setSliderTimes(page: any, lo: string, hi: string) { + const minInput = page.locator('#timeslider-min'); + const maxInput = page.locator('#timeslider-max'); + await minInput.fill(''); + await minInput.fill(lo); + await minInput.dispatchEvent('change'); + await maxInput.fill(''); + await maxInput.fill(hi); + await maxInput.dispatchEvent('change'); + await page.waitForTimeout(200); +} + // --------------------------------------------------------------------------- // Viewer zone (PUBLIC_VIEW): informational icons survive, action icon demoted // --------------------------------------------------------------------------- @@ -237,12 +252,12 @@ test.describe('pure viewer releasing own booking from the plan map', () => { expect(r.rows[0].cnt).toBe(0); }); - test('10. pure viewer: own booking with non-matching time stays blue (yours), release works', async ({ page }) => { + test('10. pure viewer: own booking with non-matching time stays blue (yoursChange), release works', async ({ page }) => { // user3's booking is seeded at 10:00-16:00; the default slider selects - // 09:00-17:00, so the booking overlaps but is NOT an exact match. Previously - // this demoted CAN_CHANGE -> CAN_DELETE -> grey "taken"; the own-booking - // release-only demotion now keeps the blue "yours" icon (release still - // offered, change is not). + // 09:00-17:00, so the booking overlaps but is NOT an exact match. CAN_CHANGE + // is no longer demoted for !bookable seats (Phase 3B): the seat shows the + // blue "yoursChange" icon. The selection extends beyond the booking on both + // sides, so it is NOT a pure shrink → Update is not offered, only Release. const pid = await createPlan('Sprite NonExact Plan', 1); const zid = await createZone('SNE Zone', ZONE_TYPE_PUBLIC_VIEW); const [ownSeat] = await addSeats(pid, zid, ['SNE.1']); @@ -254,13 +269,15 @@ test.describe('pure viewer releasing own booking from the plan map', () => { await selectOnlyDates(page, [DAY]); // default slider 09:00-17:00 await page.waitForTimeout(400); - // Non-exact own booking -> still blue "yours" (NOT grey "taken", not the - // blue "yoursChange" which would imply an available update). - await expectSprite(page, ownSeat, 'cell-yours'); + // Non-exact own booking -> blue "yoursChange" (NOT grey "taken", not plain + // "yours" — the arrows signal the booking can change, and a shrink would). + await expectSprite(page, ownSeat, 'cell-yoursChange'); - // Release still works end-to-end for the non-exact own booking. + // Update is NOT offered (selection extends beyond the booking -> not a pure + // shrink); Release still works end-to-end. await page.locator(`#sprite-${ownSeat}`).click(); await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); await clickActionBtn(page, 'delete'); const r = await querySql( 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', @@ -268,6 +285,52 @@ test.describe('pure viewer releasing own booking from the plan map', () => { ); expect(r.rows[0].cnt).toBe(0); }); + + test('12. pure viewer: shrink own booking through the modal (Update offered + works; extend offers Release only)', async ({ page }) => { + // Phase 3B/3C: a pure shrink of an own booking is always allowed, even in a + // view-only zone. user3's booking is seeded at 09:00-17:00; narrowing the + // slider to 10:00-16:00 (fully contained) -> cell-yoursChange, modal offers + // Release AND Update, Update succeeds (apply() is_pure_shrink bypass) and + // the booking becomes 10:00-16:00. Then widening the slider back to + // 09:00-17:00 (extends beyond the now-10:00-16:00 booking) -> Release only. + const pid = await createPlan('Sprite Shrink Plan', 1); + const zid = await createZone('SS Zone', ZONE_TYPE_PUBLIC_VIEW); + const [ownSeat] = await addSeats(pid, zid, ['SS.1']); + await insertBooking(USER3.login, ownSeat, DAY + 9 * 3600, DAY + 17 * 3600); + + await logIn(page, USER3); // pure viewer + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await setSliderTimes(page, '10:00', '16:00'); + await page.waitForTimeout(400); + + // Contained selection -> yoursChange, and Update is offered (pure shrink). + await expectSprite(page, ownSeat, 'cell-yoursChange'); + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).toBeVisible(); + await clickActionBtn(page, 'update'); + + // Booking replaced: 10:00-16:00 now. + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, ownSeat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(DAY + 10 * 3600); + expect(after.rows[0].t).toBe(DAY + 16 * 3600); + + // Now widen the slider beyond the booking -> not a shrink -> Release only. + await setSliderTimes(page, '09:00', '17:00'); + await page.waitForTimeout(400); + await expectSprite(page, ownSeat, 'cell-yoursChange'); + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); + }); }); // --------------------------------------------------------------------------- diff --git a/js/views/html/plan.html b/js/views/html/plan.html index 5daf7227..a5560de8 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -70,7 +70,7 @@
- The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it. + The seat is booked by you exactly on the selected date and time.
diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index a0d5a324..282c5c7a 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -333,6 +333,28 @@ WarpSeat.prototype.isMyZoneAdmin = function() { return !!this.factory.zoneAdmin[this.zid]; } +// True iff every selected date range is fully contained within one of the +// acting user's OWN bookings on this seat — a "pure shrink" selection: an +// update would only narrow an existing own booking, which is always allowed +// (apply()'s is_pure_shrink bypass, even in view-only / DISABLED zones). Uses +// window.warpGlobals.login (the real actor), not factory.login, so a book-for +// target's booking in a non-administered (!bookable) zone does NOT count — +// changing/releasing it would 403 (release confinement / seatsReqZoneAdmin). +WarpSeat.prototype.isSelectionShrinkOfMine = function() { + const me = window.warpGlobals.login; + for (const d of this.factory.selectedDates) { + let covered = false; + for (const b of this.book) { + if (b.login === me && b.fromTS <= d.fromTS && b.toTS >= d.toTS) { + covered = true; + break; + } + } + if (!covered) return false; + } + return this.factory.selectedDates.length > 0; +}; + WarpSeat.prototype.getSid = function() { return parseInt(this.sid); //TODO: convert this.sid to int in constructor @@ -435,11 +457,6 @@ WarpSeat.prototype._updateState = function() { } var assignedButNotForMe = false; - // Set when a !bookable seat's own-booking CAN_CHANGE is demoted to CAN_DELETE - // (below): the action becomes release-only, but the icon stays blue "yours" - // so the user recognises their own booking — the grey "taken" icon would - // imply someone else's booking / not actionable. Read by spriteFor. - this.ownReleaseOnly = false; // Book-for override of a seat-level assignment (see apply() skipping // 106/110 under is_book_for): a zone admin booking FOR a target may book // onto a seat assigned to someone else, or beyond the target's @@ -568,24 +585,17 @@ WarpSeat.prototype._updateState = function() { else this.state = WarpSeat.SeatStates.TAKEN; - // Demote action states to their informational equivalent for !bookable - // seats (view-only zones, or book-for into a zone the actor doesn't - // administer): occupancy/assignment states are permission-independent, - // `bookable` only demotes the action states. CAN_REBOOK doesn't exist yet - // here — it's set in _updateView only for CAN_BOOK, which is already - // demoted below, so it can never fire for a !bookable seat. - if (!this.bookable) { - if (this.state == WarpSeat.SeatStates.CAN_BOOK) { - this.state = WarpSeat.SeatStates.VIEW_ONLY; - } else if (this.state == WarpSeat.SeatStates.CAN_CHANGE) { - // Own booking that could normally be changed (extended/reduced) — in - // a view-only zone only release is allowed, so demote the action to - // CAN_DELETE (delete only). Keep the blue "yours" icon (not the grey - // "taken" one) so the user still recognises their own booking even - // when the selected time doesn't match exactly. - this.state = WarpSeat.SeatStates.CAN_DELETE; - this.ownReleaseOnly = true; - } + // Demote the one action state for !bookable seats (view-only zones, or + // book-for into a zone the actor doesn't administer): occupancy/assignment + // states are permission-independent, `bookable` only demotes CAN_BOOK. + // CAN_CHANGE (own booking, non-exact) is NOT demoted — a pure shrink is + // always allowed (apply()'s is_pure_shrink bypass + plan.js + // isSelectionShrinkOfMine), so the blue "yoursChange" icon and the Update + // action stay even in a view-only zone. CAN_REBOOK doesn't exist yet here + // — it's set in _updateView only for CAN_BOOK, which is already demoted + // below, so it can never fire for a !bookable seat. + if (!this.bookable && this.state == WarpSeat.SeatStates.CAN_BOOK) { + this.state = WarpSeat.SeatStates.VIEW_ONLY; } return this.state; @@ -594,19 +604,14 @@ WarpSeat.prototype._updateState = function() { // Map a (final) seat state + assignedToMe flag to a #cell- sprite name // (PLAN_officemap.md §3). The state must already reflect the CAN_REBOOK / // VIEW_ONLY side-effects applied in _updateView below. -function spriteFor(state, assignedToMe, ownReleaseOnly) { +function spriteFor(state, assignedToMe) { switch (state) { case WarpSeat.SeatStates.CAN_BOOK: return assignedToMe ? 'availableAssigned' : 'available'; case WarpSeat.SeatStates.CAN_REBOOK: return assignedToMe ? 'rebookAssigned' : 'rebook'; case WarpSeat.SeatStates.CAN_CHANGE: return 'yoursChange'; case WarpSeat.SeatStates.CAN_DELETE_EXACT: return 'yours'; - case WarpSeat.SeatStates.CAN_DELETE: - // ownReleaseOnly: a view-only-zone own booking demoted from - // CAN_CHANGE — release-only, but still yours (blue head), not grey - // "taken". - if (ownReleaseOnly) return 'yours'; - return 'taken'; + case WarpSeat.SeatStates.CAN_DELETE: return 'taken'; case WarpSeat.SeatStates.TAKEN: return 'taken'; case WarpSeat.SeatStates.ASSIGNED: return 'assigned'; case WarpSeat.SeatStates.VIEW_ONLY: @@ -637,7 +642,7 @@ WarpSeat.prototype._updateView = function() { // all other states are already final from _updateState } - this.sprite = spriteFor(this.state, assignedToMe, this.ownReleaseOnly); + this.sprite = spriteFor(this.state, assignedToMe); } WarpSeat.prototype._destroy = function() { diff --git a/js/views/plan.js b/js/views/plan.js index 790562d6..6744e1e9 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -654,8 +654,15 @@ export async function mount(ctx) { break; case WarpSeat.SeatStates.CAN_CHANGE: actions.push('delete'); - actions.push('update'); - bookMsg = removeMsg = true; + removeMsg = true; + // Update extends/changes the booking — only offer it where + // the actor may book (this.bookable) or the selection is a + // pure shrink of an own booking (always allowed, even in a + // view-only / DISABLED zone — apply()'s is_pure_shrink). + if (this.bookable || this.isSelectionShrinkOfMine()) { + actions.push('update'); + bookMsg = true; + } break; case WarpSeat.SeatStates.CAN_REBOOK: // Under book-for, updating this free seat would release the diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index 9ef2f517..0e791e25 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -168,7 +168,7 @@ "The seat is available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is assigned to you and available to be booked.": "Der Sitzplatz ist Ihnen zugewiesen und kann gebucht werden.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist Ihnen zugewiesen und verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", - "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht. In einer Nur-Ansicht-Zone wird dieses Symbol auch für Ihre Buchung gezeigt, wenn die gewählte Zeit nicht genau passt — Sie können sie freigeben, aber nicht ändern.", + "The seat is booked by you exactly on the selected date and time.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", "The seat is booked by someone else or not available for booking.": "Der Platz ist von einer anderen Person gebucht oder nicht zur Buchung verfügbar.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone.", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index 5836509a..b65dab69 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -168,7 +168,7 @@ "The seat is available, but you have another booking at that time which will be automatically updated.": "The seat is available, but you have another booking at that time which will be automatically updated.", "The seat is assigned to you and available to be booked.": "The seat is assigned to you and available to be booked.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.", - "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.", + "The seat is booked by you exactly on the selected date and time.": "The seat is booked by you exactly on the selected date and time.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", "The seat is booked by someone else or not available for booking.": "The seat is booked by someone else or not available for booking.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index 4d739a7a..dfa223a2 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -168,7 +168,7 @@ "The seat is available, but you have another booking at that time which will be automatically updated.": "Este asiento está disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is assigned to you and available to be booked.": "El asiento está asignado a usted y disponible para reservar.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "El asiento está asignado a usted y disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", - "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas. En una zona de solo lectura, este icono también se muestra para su reserva cuando la hora seleccionada no coincide exactamente — puede liberarla, pero no cambiarla.", + "The seat is booked by you exactly on the selected date and time.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", "The seat is booked by someone else or not available for booking.": "Este asiento está reservado por otra persona o no está disponible para reservar.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura.", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index 88d959ef..058a7e7f 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -168,7 +168,7 @@ "The seat is available, but you have another booking at that time which will be automatically updated.": "Le siège est disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is assigned to you and available to be booked.": "Le siège vous est assigné et disponible à la réservation.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Le siège vous est assigné et disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", - "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés. Dans une zone en lecture seule, cette icône s'affiche aussi pour votre réservation lorsque l'horaire sélectionné ne correspond pas exactement — vous pouvez la libérer, mais pas la modifier.", + "The seat is booked by you exactly on the selected date and time.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", "The seat is booked by someone else or not available for booking.": "Le siège est réservé par quelqu'un d'autre ou non disponible à la réservation.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule.", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index 6709d79a..7b77fb7b 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -168,7 +168,7 @@ "The seat is available, but you have another booking at that time which will be automatically updated.": "Miejsce jest dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is assigned to you and available to be booked.": "Miejsce jest przypisane do Ciebie i dostępne do rezerwacji.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Miejsce jest przypisane do Ciebie i dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", - "The seat is booked by you exactly on the selected date and time. In a view-only zone, this icon is also shown for your booking when the selected time doesn't match exactly — you can release it, but not change it.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie. W strefie tylko do odczytu ta ikona pokazuje się też dla Twojej rezerwacji, gdy wybrany czas nie pasuje dokładnie — możesz ją zwolnić, ale nie zmienić.", + "The seat is booked by you exactly on the selected date and time.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie.", "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", "The seat is booked by someone else or not available for booking.": "Miejsce jest zarezerwowane przez kogoś innego lub niedostępne do rezerwacji.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu.", diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index e65eb301..c992d1df 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -542,7 +542,31 @@ def apply(): if 'book' in apply_data: - if not flask.g.isAdmin: + # Pure-shrink bypass (PLAN_VIEW_ONLY_SEATS.md Phase 3C): a self update + # (no book.login) whose every booked range is fully covered by one of + # the actor's own bookings being removed on this seat only narrows an + # existing own booking. A shrink never increases exposure, so it skips + # every booking check below (role 104, DISABLED zone 104, seat-disabled + # 105, assignment 106/110, horizon 103) — release is the shrink-to-zero + # case and is already ungated; this generalises it. Book-for is never a + # shrink (is_book_for excluded). The remove bids' ownership is verified + # server-side by the login filter (do not trust the client). + is_pure_shrink = False + if 'remove' in apply_data and 'login' not in apply_data['book']: + # Book.fromts/tots are seconds-of-day-anchored epochs; the JSON + # payload uses fromTS/toTS. r = (id, fromts, tots). + own_rows = list(Book.select(Book.id, Book.fromts, Book.tots) + .where(Book.id.in_(apply_data['remove'])) + .where(Book.login == flask.g.login) + .where(Book.sid == apply_data['book']['sid']) + .tuples()) + if own_rows: + def _covered(d): + return any(r[1] <= d['fromTS'] and r[2] >= d['toTS'] for r in own_rows) + if all(_covered(d) for d in apply_data['book']['dates']): + is_pure_shrink = True + + if not flask.g.isAdmin and not is_pure_shrink: for b in apply_data['book']['dates']: if b['fromTS'] < ts["fromTS"] or b['fromTS'] > ts["toTS"] \ or b['toTS'] < ts["fromTS"] or b['toTS'] > ts["toTS"]: @@ -572,7 +596,7 @@ def apply(): # bookerRole from the view IS the effective role. isSelfAdminBooking = flask.g.isAdmin and login == flask.g.login - if not isSelfAdminBooking: + if not isSelfAdminBooking and not is_pure_shrink: if is_book_for: if bookerRole is None: return {"msg": "Forbidden", "code": 104}, 403 @@ -580,14 +604,15 @@ def apply(): return {"msg": "Forbidden", "code": 104}, 403 # Disabled zones cannot be booked at all — even by admins. - # Enable the zone first, then book. - if seatZone['zone_type'] == ZONE_TYPE_DISABLED: + # Enable the zone first, then book. (A pure shrink is still allowed — + # the seat was bookable when the user booked it; see 3C.) + if seatZone['zone_type'] == ZONE_TYPE_DISABLED and not is_pure_shrink: return {"msg": "Forbidden", "code": 104}, 403 - if not seatZone['enabled']: + if not seatZone['enabled'] and not is_pure_shrink: return {"msg": "Forbidden", "code": 105}, 403 - assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) if not is_book_for else None + assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) if not is_book_for and not is_pure_shrink else None if assignedQ is not None and assignedQ.scalar() is not None: myAssignments = list(SeatAssign.select(SeatAssign.days_in_advance) .where((SeatAssign.sid == sid) & From 7a490216dac4a8d6f25ca02273e02bb44a206eb8 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 22:22:12 +0200 Subject: [PATCH 12/17] Book-for onto a disabled seat (zone-admin override) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seat-disabled (enabled=false, code 105) is a seat-level restriction like assignments — a zone admin controls it and may override it under book-for (they could re-enable the seat anyway). The zone-type DISABLED block (code 104, zone-level, "enable the zone first") stays, per the rule that book-for never targets a DISABLED zone. - warp/xhr/plan.py: skip the seat-enabled check (105) when is_book_for (the zone-admin gate already ran via seatsReqZoneAdmin — an explicit book.login puts the sid in that set). - js/views/modules/seat.js _updateState(): hoist the bookForOverride const above the !this.enabled early return so a disabled seat under book-for skips it and falls through to the normal pipeline -> CAN_BOOK -> green available / availableAssigned. Self-booking never overrides (the early return still fires). - e2e zone-permissions-bookfor: D1 book-for onto a disabled seat -> 200, self-book onto it -> 403 / 105; D2 auto-book-for on a plan whose only free seat is disabled books nothing there (auto-book keeps excluding disabled seats). - e2e zone-view-sprites: 8c pins the display -- self-view of a disabled seat -> grey unavailable; under book-for -> green available, book works. Also drops now-stale plan-phase citations from comments in plan.py and the zone-view-sprites / zone-shrink-bypass / zone-permissions-bookfor specs (the implementation plan is ephemeral; permanent files stand on their own). Co-Authored-By: GLM 5.2 --- .../booking/zone-permissions-bookfor.spec.ts | 55 ++++++++++++++ e2e/tests/booking/zone-shrink-bypass.spec.ts | 2 +- e2e/tests/booking/zone-view-sprites.spec.ts | 74 ++++++++++++++----- js/views/modules/seat.js | 28 ++++--- warp/xhr/plan.py | 10 ++- 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/e2e/tests/booking/zone-permissions-bookfor.spec.ts b/e2e/tests/booking/zone-permissions-bookfor.spec.ts index 306f45e5..cbcc0b53 100644 --- a/e2e/tests/booking/zone-permissions-bookfor.spec.ts +++ b/e2e/tests/booking/zone-permissions-bookfor.spec.ts @@ -719,3 +719,58 @@ test.describe('cross-zone book-for release confinement', () => { expect(await countBookings('user2', seat1)).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// D. Book-for onto a disabled seat: a zone admin may override a +// seat-level disable (they could re-enable it anyway) when booking FOR a +// target. The zone-type DISABLED block (104) still applies — only the +// seat-enabled check (105) is skipped under is_book_for. Self-booking onto a +// disabled seat stays 105, and auto-book-for never picks a disabled seat. +// --------------------------------------------------------------------------- + +async function disableSeat(seatId: number): Promise { + await querySql('UPDATE seat SET enabled = false WHERE id = $1', [seatId]); +} + +test.describe('book-for onto a disabled seat (zone-admin override)', () => { + test('D1: book-for onto a disabled seat -> 200; self-book onto it -> 403 / 105', async ({ page }) => { + const pid = await createPlan('BookFor Disabled Seat Plan'); + const zid = await createZone('BFD Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['BFD.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await disableSeat(seat); + + await logIn(page, USER1); + // Book-for user2 onto the disabled seat -> the admin overrides the disable. + const ok = await apiApply(page, { book: { sid: seat, login: 'user2', dates: [slot(1)] } }); + expect(ok.status()).toBe(200); + expect(await countBookings('user2', seat)).toBe(1); + + // Clear it and try a self-book onto the same disabled seat -> 105. + await querySql('DELETE FROM book WHERE sid = $1', [seat]); + const denied = await apiApply(page, { book: { sid: seat, dates: [slot(1)] } }); + expect(denied.status()).toBe(403); + expect((await denied.json()).code).toBe(105); + expect(await countBookings('user1', seat)).toBe(0); + }); + + test('D2: auto-book-for never picks a disabled seat (only free seat -> booked empty)', async ({ page }) => { + const pid = await createPlan('AutoBookFor Disabled Plan'); + const zid = await createZone('ABD Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['ABD.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await disableSeat(seat); // the only seat, and it is disabled + + await logIn(page, USER1); + const resp = await page.request.post(`/xhr/plan/autoBook/${pid}`, { + data: { dates: [slot(1)], login: 'user2' }, + headers: { 'Content-Type': 'application/json' }, + maxRedirects: 0, + }); + expect(resp.status()).toBe(200); + expect((await resp.json()).booked).toEqual([]); + expect(await countBookings('user2', seat)).toBe(0); + }); +}); diff --git a/e2e/tests/booking/zone-shrink-bypass.spec.ts b/e2e/tests/booking/zone-shrink-bypass.spec.ts index b130ed94..854ce419 100644 --- a/e2e/tests/booking/zone-shrink-bypass.spec.ts +++ b/e2e/tests/booking/zone-shrink-bypass.spec.ts @@ -1,5 +1,5 @@ /** - * Phase 3C: the pure-shrink bypass in apply() (PLAN_VIEW_ONLY_SEATS.md). + * The pure-shrink bypass in apply(). * * Invariant (PERMISSIONS.md §8): any operation that strictly shrinks the * actor's own bookings is always allowed, everywhere — release is the diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index ea2d1245..590ea8ea 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -1,12 +1,11 @@ /** - * Phase 2 display-fix sprite assertions (PLAN_VIEW_ONLY_SEATS.md "Phase 2 test - * plan"). These lock down the core invariant of the view-only-zone fix: in a - * view-only zone the user sees real occupancy/assignment icons (taken / - * assigned / yours), and `bookable: false` only demotes the *action* states - * (book → unavailable). Without these, a future refactor could revert - * viewer-zone seats to a uniform `unavailable` and no e2e would catch it — the - * bookings/permission specs only assert action-button visibility, never the - * rendered sprite. + * View-only-zone seat display sprite assertions. These lock down the core + * invariant of the view-only-zone fix: in a view-only zone the user sees real + * occupancy/assignment icons (taken / assigned / yours), and `bookable: false` + * only demotes the *action* states (book → unavailable). Without these, a + * future refactor could revert viewer-zone seats to a uniform `unavailable` + * and no e2e would catch it — the bookings/permission specs only assert + * action-button visibility, never the rendered sprite. * * Sprite cell names come from js/views/modules/seat.js `spriteFor`: * taken, assigned, unavailable, yours, available, rebook, availableAssigned @@ -78,6 +77,11 @@ function slot() { return { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 }; } +/** Disable a seat directly (no helper in zone-setup yet). */ +async function disableSeat(seatId: number) { + await querySql('UPDATE seat SET enabled = false WHERE id = $1', [seatId]); +} + /** Set the plan time-slider range via the HH:MM edit boxes (fires `change`, * which the app maps to a noUiSlider.set). Used to test pure-shrink vs extend * selections against a seeded own booking. */ @@ -255,9 +259,9 @@ test.describe('pure viewer releasing own booking from the plan map', () => { test('10. pure viewer: own booking with non-matching time stays blue (yoursChange), release works', async ({ page }) => { // user3's booking is seeded at 10:00-16:00; the default slider selects // 09:00-17:00, so the booking overlaps but is NOT an exact match. CAN_CHANGE - // is no longer demoted for !bookable seats (Phase 3B): the seat shows the - // blue "yoursChange" icon. The selection extends beyond the booking on both - // sides, so it is NOT a pure shrink → Update is not offered, only Release. + // is not demoted for !bookable seats: the seat shows the blue "yoursChange" + // icon. The selection extends beyond the booking on both sides, so it is + // NOT a pure shrink → Update is not offered, only Release. const pid = await createPlan('Sprite NonExact Plan', 1); const zid = await createZone('SNE Zone', ZONE_TYPE_PUBLIC_VIEW); const [ownSeat] = await addSeats(pid, zid, ['SNE.1']); @@ -287,12 +291,12 @@ test.describe('pure viewer releasing own booking from the plan map', () => { }); test('12. pure viewer: shrink own booking through the modal (Update offered + works; extend offers Release only)', async ({ page }) => { - // Phase 3B/3C: a pure shrink of an own booking is always allowed, even in a - // view-only zone. user3's booking is seeded at 09:00-17:00; narrowing the - // slider to 10:00-16:00 (fully contained) -> cell-yoursChange, modal offers - // Release AND Update, Update succeeds (apply() is_pure_shrink bypass) and - // the booking becomes 10:00-16:00. Then widening the slider back to - // 09:00-17:00 (extends beyond the now-10:00-16:00 booking) -> Release only. + // A pure shrink of an own booking is always allowed, even in a view-only + // zone. user3's booking is seeded at 09:00-17:00; narrowing the slider to + // 10:00-16:00 (fully contained) -> cell-yoursChange, modal offers Release + // AND Update, Update succeeds (apply() pure-shrink bypass) and the booking + // becomes 10:00-16:00. Then widening the slider back to 09:00-17:00 + // (extends beyond the now-10:00-16:00 booking) -> Release only. const pid = await createPlan('Sprite Shrink Plan', 1); const zid = await createZone('SS Zone', ZONE_TYPE_PUBLIC_VIEW); const [ownSeat] = await addSeats(pid, zid, ['SS.1']); @@ -476,6 +480,42 @@ test.describe('book-for override of an assignment', () => { await page.waitForTimeout(400); await expectSprite(page, seat, 'cell-availableAssigned'); }); + + test('8c. disabled seat under book-for -> available (green); self-view stays unavailable', async ({ page }) => { + // A seat the admin has disabled shows the grey X in self-view (DISABLED + // early return). Under book-for the admin may override the seat-level + // disable (apply() skips 105 under is_book_for), so the seat falls through + // to CAN_BOOK -> green `available` and the book action works for the target. + const pid = await createPlan('Sprite Override Disabled Plan', 1); + const zid = await createZone('SOD Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SOD.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await disableSeat(seat); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // Self-view: disabled seat -> grey unavailable. + await expectSprite(page, seat, 'cell-unavailable'); + + // Book-for user2: the disable is overridden -> green available, book works. + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-available'); + + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'book'); + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seat], + ); + expect(r.rows[0].cnt).toBe(1); + }); }); // --------------------------------------------------------------------------- diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 282c5c7a..6a792d04 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -451,22 +451,28 @@ WarpSeat.prototype._updateState = function() { return this.state; } - if (!this.enabled) { + // Book-for override (see apply() skipping 105/106/110 under is_book_for): + // a zone admin booking FOR a target may book onto a seat assigned to + // someone else, beyond the target's days-in-advance window, OR a seat the + // admin has disabled — fall through to the normal pipeline -> CAN_BOOK + // instead of the early DISABLED / ASSIGNED return. Only under book-for + // (factory.login != the real login) and where `bookable` holds — which + // under book-for already means the actor administers the zone. Self-booking + // never overrides (105/106/110 apply). Hoisted above the !this.enabled + // check so a disabled seat under book-for skips that early return. + const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; + + if (!this.enabled && !bookForOverride) { this.state = WarpSeat.SeatStates.DISABLED; return this.state; } var assignedButNotForMe = false; - // Book-for override of a seat-level assignment (see apply() skipping - // 106/110 under is_book_for): a zone admin booking FOR a target may book - // onto a seat assigned to someone else, or beyond the target's - // days-in-advance window. Only under book-for (factory.login != the real - // login) and where `bookable` holds — which under book-for already means - // the actor administers the zone. Self-booking never overrides (106/110 - // apply), so the fall-through is book-for-only. The seat then renders plain - // green `available` (third-party assignment) or blue `availableAssigned` - // (assigned to the target, beyond its window). - const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; + // (bookForOverride is hoisted above — see the comment near the !this.enabled + // check.) Under book-for a seat assigned to someone else, or beyond the + // target's days-in-advance window, falls through to CAN_BOOK: the seat then + // renders plain green `available` (third-party assignment) or blue + // `availableAssigned` (assigned to the target, beyond its window). if (Object.keys(this.assignments).length > 0) { diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index c992d1df..50ce5e43 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -542,7 +542,7 @@ def apply(): if 'book' in apply_data: - # Pure-shrink bypass (PLAN_VIEW_ONLY_SEATS.md Phase 3C): a self update + # Pure-shrink bypass: a self update # (no book.login) whose every booked range is fully covered by one of # the actor's own bookings being removed on this seat only narrows an # existing own booking. A shrink never increases exposure, so it skips @@ -605,11 +605,15 @@ def _covered(d): # Disabled zones cannot be booked at all — even by admins. # Enable the zone first, then book. (A pure shrink is still allowed — - # the seat was bookable when the user booked it; see 3C.) + # the seat was bookable when the user booked it.) if seatZone['zone_type'] == ZONE_TYPE_DISABLED and not is_pure_shrink: return {"msg": "Forbidden", "code": 104}, 403 - if not seatZone['enabled'] and not is_pure_shrink: + if not seatZone['enabled'] and not is_book_for and not is_pure_shrink: + # A zone admin may book-for onto a seat they have disabled (they + # could re-enable it anyway) — the zone-admin gate already ran via + # seatsReqZoneAdmin. Self-booking and pure shrinks are handled by + # the is_pure_shrink guard above; this skip is book-for-only. return {"msg": "Forbidden", "code": 105}, 403 assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) if not is_book_for and not is_pure_shrink else None From 234fb6204696aa7cdf7bad6ba0563a52bbec46b5 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 22:24:42 +0200 Subject: [PATCH 13/17] Document the seat-icon/action invariants + book-for self-asymmetry (Phase 3E/3F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3E -- book-for with self selected is normal mode (already the shipped behaviour, zero code change). Documented in PERMISSIONS.md §8: a zone admin who selects their own login in the book-for picker gets regular-user treatment for that selection -- no book.login on the wire, no green override, assignments/windows apply. The picker keeps the admin's own login as the exit from book-for mode. 3F -- state the four design invariants verbatim in PERMISSIONS.md as the new §11 "Seat-icon & action invariants": icons state facts / colours carry meaning; the click reveals permitted actions (the icon never enumerates them); any strict shrink of the actor's own bookings is always allowed everywhere; a zone admin is unrestricted for seat-level matters inside their zone and powerless across its boundary. Any behaviour not derivable from these is a bug -- or requires amending them first. FEATURES.md updates for the user-facing Phase 3 behaviour changes: - §9 "Book For": book-for also overrides a seat-level disable (the zone-type DISABLED block still rejects it outright). - §27 viewer release note: a viewer can also shorten (Update) a non-exact own booking via a pure shrink, even in a view-only / disabled zone. e2e pin (3E): zone-view-sprites 8d -- admin selects themselves in book-for -> a seat assigned to a third person stays grey `assigned` (not green) and the book action is not offered (self-book there would be 106), confirming no book.login is sent. Co-Authored-By: GLM 5.2 --- FEATURES.md | 4 +-- PERMISSIONS.md | 40 +++++++++++++++++++++ e2e/tests/booking/zone-view-sprites.spec.ts | 34 ++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index adc46cc2..b7e5c8f5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -425,7 +425,7 @@ The old "Already booked in another zone" section is gone; an existing booking on - A "Book For" input field appears in the plan-view side panel (the booking map) for zone admins. - It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users, including viewers. -- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction and any seat-level assignment for that seat. A seat assigned to someone else renders plain green "available" under book-for; a seat assigned to the target beyond their days-in-advance window renders blue "availableAssigned". +- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction, any seat-level assignment, and a seat-level disable for that seat. A seat assigned to someone else renders plain green "available" under book-for; a seat assigned to the target beyond their days-in-advance window renders blue "availableAssigned"; a seat the admin has disabled likewise becomes bookable under book-for (the admin could re-enable it anyway). The zone-type DISABLED block still rejects book-for outright — override the seat, not the zone. - When the admin books, updates, or removes a booking, it is performed **on behalf of the selected user**. - The admin can also auto-book for the selected user. - Clearing the "Book For" field (pressing Enter while empty) reverts to the admin's own view. @@ -809,7 +809,7 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* | **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Release, + Edit | + Edit | + Edit | | **Viewer actions** | — | — | — | — | — | — | — | — | — | -A viewer (view-only access to every zone on the plan) can still **release their own booking** from the plan map — clicking it opens the action panel with only the Release button. Non-actionable seats (free, taken by others, assigned) open no panel. The auto-book button and book-for input stay hidden in viewer mode. +A viewer (view-only access to every zone on the plan) can still **release their own booking** from the plan map, and **shorten** it (Update) when the selected time is fully contained within the booking — a pure shrink is always allowed, even in a view-only or disabled zone. Clicking a non-exact own booking opens the action panel with Release, plus Update when the selection is a pure shrink; non-actionable seats (free, taken by others, assigned) open no panel. The auto-book button and book-for input stay hidden in viewer mode. A zone admin can **release another user's booking** from the plan map by clicking a taken seat in a zone they administer — the action panel offers Release (apply() allows it via the per-seat zone-admin check for foreign removes). Non-admins get no action on a taken seat. diff --git a/PERMISSIONS.md b/PERMISSIONS.md index 8c6ad7a7..f0141b45 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -283,6 +283,17 @@ zones reject booking outright, for everyone, book-for included. See [AUTOBOOK.md](AUTOBOOK.md) for the full auto-book selection priority. +### Acting for yourself is normal mode + +A zone admin who selects **their own login** in the book-for picker is, for +that selection, a regular user: no `book.login` is sent on the wire, so the +backend never enters the book-for path, and the plan renders the admin's own +normal-mode icons (assignments and days-in-advance windows apply; no green +override). The picker keeps the admin's own login in the list precisely as +the **exit** from book-for mode — book-for is "acting for *another* user"; +acting for yourself is just booking. (The backend tolerates an explicit +`login = ` in `apply()` — harmless, but unreachable from the UI.) + --- ## 9. Decision flow: "can user **U** book seat **S**?" @@ -364,3 +375,32 @@ Book-for on this plan: excluded too (disabled). So the seat is picked from Quiet only. - Site admin books **for Dana** in **Open** → ✅ (super-user admins Open; Dana is a member there). + +--- + +## 11. Seat-icon & action invariants + +The plan-map display and the click-to-act modal follow four invariants. Any +behaviour not derivable from them is a bug — or requires amending them first. + +1. **Icons state facts; colours carry the meaning.** Green = the actor (or + their book-for target) can book here; blue = the actor's/target's own + (booking or assignment); grey = not bookable/updatable. Arrows = acting + here changes existing bookings. +2. **The click reveals the permitted actions; the icon never enumerates them.** + A grey seat may still offer *Release* — an own overlap, or a foreign + booking for a zone admin. The icon is a scan-friendly summary, not an + action list. +3. **Any operation that strictly shrinks the actor's own bookings is always + allowed, everywhere.** Release is the shrink-to-nothing case (already + ungated); shortening a booking to a contained sub-range is the same + invariant generalised — it bypasses the role, zone-disabled, + seat-disabled, assignment, and booking-horizon checks. Book-for is never a + shrink. +4. **A zone admin is unrestricted for seat-level matters inside their zone** + (book-for members; override assignments, days-in-advance windows, and + disabled seats; release anyone) **and powerless across its boundary** + (release confinement — a conflicting booking in a zone the actor does not + administer is not silently deleted; the zone-type DISABLED block still + rejects book-for outright). Acting for themselves, an admin is a regular + user (see §8, "Acting for yourself is normal mode"). diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 590ea8ea..225598c5 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -516,6 +516,40 @@ test.describe('book-for override of an assignment', () => { ); expect(r.rows[0].cnt).toBe(1); }); + + test('8d. admin selects themselves in book-for -> normal mode (assigned-to-other stays grey, no book offered)', async ({ page }) => { + // Selecting your own login in the book-for picker is the exit from + // book-for mode: no book.login is sent, so the admin is a regular user + // for that selection -- assignments/windows apply, no green override. A + // seat assigned to a third person stays grey `assigned` (not green), and + // the book action is not offered (self-book there would be 106). + const pid = await createPlan('Sprite SelfInBookFor Plan', 1); + const zid = await createZone('SSB Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SSB.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await assignSeat(seat, USER3.login, null); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // Default self-view: grey assigned (self-book blocked by 106). + await expectSprite(page, seat, 'cell-assigned'); + + // Select the admin's OWN login in the book-for picker -> still normal mode. + await activateBookFor(page, `${USER1.name} [${USER1.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assigned'); + + // No book action offered (an actual book-for target would show green + + // book; self-selection does not). + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + }); }); // --------------------------------------------------------------------------- From af00256cae99baacfceec5342c48e056d02b7922 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 22:34:25 +0200 Subject: [PATCH 14/17] Fix stale "Blue (conflict)" icon rows in FEATURES.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The own+foreign overlap seat state (CAN_DELETE) renders the same red "taken" icon as a pure foreign booking, not a blue icon — the in-app legend has never had a separate blue conflict glyph (helpSpriteMap maps userConflict to 'taken', and no plan.html legend row references it). FEATURES.md §7.2 and §27 still carried a stale "Blue (conflict)" label from an older design. - §7.2 icon table: drop the "Blue (conflict)" row; fold its meaning into "Red (taken)" (booked by someone else, or your own booking overlaps theirs — same icon either way; action: Release your own overlap, or a zone-admin release of the foreign booking). Also fix the "Gray (view-only)" row to match the in-app legend wording (no "free vs. taken" — occupied/assigned seats in view-only zones show their real icon). - §27 interaction summary: rename the "Blue (Conflict)" column to "Red (Taken, own overlap)" and "Red (Taken)" to "Red (Taken, foreign)" — same icon, different action (Remove own overlap vs zone-admin Release of the foreign booking). Actions unchanged. Co-Authored-By: GLM 5.2 --- FEATURES.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index b7e5c8f5..2a330456 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -332,10 +332,9 @@ Accessible via the user icon on the Zones management page. | Yellow (assigned) | Seat is assigned to others, you cannot book (visible only to non-admins) | — | | Blue | You already booked this seat exactly for the selected time | Remove | | Blue (rebook) | You booked this seat, but for a different time range — can update | Update | -| Blue (conflict) | You booked this seat, but another user has a conflicting booking — cannot update, only remove | Remove | -| Red (taken) | Booked by someone else or unavailable | — | +| Red (taken) | Booked by someone else, or your own booking overlaps theirs on this seat (same icon either way) | — (Release your own overlap; zone admins can also release the foreign booking) | | Gray (disabled) | Seat is disabled (visible only to zone admins) | Edit (toggle enabled) | -| Gray circle / gray person (view-only) | Seat is in a view-only or disabled zone you cannot book in (free vs. taken) | — | +| Gray circle / gray person (view-only) | Not available for you to book: no dates selected, disabled, or a free seat in a view-only zone | — | | No icon | No date/time selected | — | ### 7.3 Booking a Seat @@ -803,7 +802,7 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* ## 27. Plan View Interaction Summary -| Seat State | No Dates | Green (Book) | Green (Rebook) | Blue (Update) | Blue (Conflict) | Blue (Exact) | Red (Taken) | Yellow (Assigned) | Gray (Disabled) | +| Seat State | No Dates | Green (Book) | Green (Rebook) | Blue (Update) | Red (Taken, own overlap) | Blue (Exact) | Red (Taken, foreign) | Yellow (Assigned) | Gray (Disabled) | |-----------------------|----------|-----------------------|----------------------|---------------|-----------------|--------------|-------------|-----------------------------|-----------------------------| | **User actions** | — | Book | Book (replaces) | Update | Remove | Remove | — | — | — | | **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Release, + Edit | + Edit | + Edit | From f8187b843551eb7c5778588f06a0668a6b4619d8 Mon Sep 17 00:00:00 2001 From: sebo Date: Sat, 4 Jul 2026 23:14:56 +0200 Subject: [PATCH 15/17] Phase 3 review fixes: yoursChange legend, doomed book-for Release, disabled-icon cue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rigid post-implementation review of Phase 3 found two user-facing inconsistencies and one missing visual cue. All resolved here; the agreements are recorded in PLAN_VIEW_ONLY_SEATS.md ("Phase 3 review agreements") for the reviewer, and the permanent docs reflect them. 1. yoursChange legend contradicted invariant #2 (PERMISSIONS.md §11: "the icon never enumerates actions"). The userRebook legend said "You can update; …" — but 3B made yoursChange appear in view-only zones for a non-exact own booking where the selection extends beyond the booking, and there the modal offers Release only. Reworded plan.html + all 5 i18n to describe the click, not a promised action: "… click to release it or update it to the selected time." 2. Doomed Release under book-for in a non-administered zone. The target's "own" booking (CAN_CHANGE/CAN_DELETE/CAN_DELETE_EXACT) there offered Release, but releasing the target's booking is a foreign release apply() rejects with 403/102 (seatsReqZoneAdmin). hasUnmanageableConflict guarded the doomed *update* (CAN_REBOOK) but not the doomed *release*. plan.js click handler now computes bookForForeignRelease = factory.login !== window.warpGlobals.login && !this.bookable and skips the Release push for those three states (no modal opens). e2e zone-view-sprites 8e pins it. 3. Disabled seat under book-for keeps the disabled icon, but still acts. The original 3D rendered it plain green `available` (no cue the seat is off). Revised: under book-for (administered zone) the disabled seat runs the normal pipeline (so its state drives the click actions: Book, or Update via CAN_REBOOK when the target has a conflicting booking in the zone group — book-alone would fail the DB book_overlap trigger — or Release when the target already holds it), but _updateView forces the sprite to 'unavailable' (grey X) as the visual cue. Reuses the existing CAN_REBOOK conflict machinery; the earlier DISABLED click case is removed (no longer needed). e2e 8c (book) and 8f (update-on-conflict) pin it. 4. Documented the intentional self-vs-book-for asymmetry for disabled seats: an admin cannot book *themselves* onto a seat they disabled (re-enable first; self-booking is the regular flow), but book-for onto it for another member is offered. FEATURES.md §9 and PERMISSIONS.md §8 ("Acting for yourself is normal mode"). Backend apply() 105 stays for self (the frontend simply doesn't expose it). Co-Authored-By: GLM 5.2 --- FEATURES.md | 2 +- PERMISSIONS.md | 7 ++ e2e/tests/booking/zone-view-sprites.spec.ts | 108 ++++++++++++++++++-- js/views/html/plan.html | 2 +- js/views/modules/seat.js | 31 ++++-- js/views/plan.js | 9 ++ warp/static/i18n/de.json | 2 +- warp/static/i18n/en.json | 2 +- warp/static/i18n/es.json | 2 +- warp/static/i18n/fr.json | 2 +- warp/static/i18n/pl.json | 2 +- 11 files changed, 144 insertions(+), 25 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 2a330456..8a4963fe 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -424,7 +424,7 @@ The old "Already booked in another zone" section is gone; an existing booking on - A "Book For" input field appears in the plan-view side panel (the booking map) for zone admins. - It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users, including viewers. -- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction, any seat-level assignment, and a seat-level disable for that seat. A seat assigned to someone else renders plain green "available" under book-for; a seat assigned to the target beyond their days-in-advance window renders blue "availableAssigned"; a seat the admin has disabled likewise becomes bookable under book-for (the admin could re-enable it anyway). The zone-type DISABLED block still rejects book-for outright — override the seat, not the zone. +- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction, any seat-level assignment, and a seat-level disable for that seat. A seat assigned to someone else renders plain green "available" under book-for; a seat assigned to the target beyond their days-in-advance window renders blue "availableAssigned"; a seat the admin has disabled keeps its disabled icon (grey X) as a visual cue but is bookable under book-for — click offers Book, or Update when the target already has a conflicting booking in the zone group. This override is book-for-only: an admin booking **for themselves** sees a disabled seat as not bookable and must re-enable it first (self-booking is the regular user flow — the picker's own-login entry is the exit from book-for). The zone-type DISABLED block still rejects book-for outright — override the seat, not the zone. - When the admin books, updates, or removes a booking, it is performed **on behalf of the selected user**. - The admin can also auto-book for the selected user. - Clearing the "Book For" field (pressing Enter while empty) reverts to the admin's own view. diff --git a/PERMISSIONS.md b/PERMISSIONS.md index f0141b45..8d856149 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -294,6 +294,13 @@ the **exit** from book-for mode — book-for is "acting for *another* user"; acting for yourself is just booking. (The backend tolerates an explicit `login = ` in `apply()` — harmless, but unreachable from the UI.) +Notably, this means an admin **cannot book themselves onto a seat they have +disabled**: under self-view the disabled seat stays grey (no Book action +offered — the admin must re-enable it first). Booking **for another member** +onto that same disabled seat *is* offered (book-for overrides the seat-level +disable; see §11 invariant #4). The asymmetry is intentional — for themselves, +an admin is a regular user, and a disabled seat is disabled for regular users. + --- ## 9. Decision flow: "can user **U** book seat **S**?" diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 225598c5..8700b4e3 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -481,11 +481,11 @@ test.describe('book-for override of an assignment', () => { await expectSprite(page, seat, 'cell-availableAssigned'); }); - test('8c. disabled seat under book-for -> available (green); self-view stays unavailable', async ({ page }) => { - // A seat the admin has disabled shows the grey X in self-view (DISABLED - // early return). Under book-for the admin may override the seat-level - // disable (apply() skips 105 under is_book_for), so the seat falls through - // to CAN_BOOK -> green `available` and the book action works for the target. + test('8c. disabled seat under book-for -> disabled icon; click offers book-for', async ({ page }) => { + // A seat the admin has disabled keeps the grey X icon even under book-for + // (visual cue the seat is off), but clicking it offers the book-for action + // (apply() skips 105 under is_book_for) — the admin books for the target + // without re-enabling the seat. Self-view offers no book (re-enable first). const pid = await createPlan('Sprite Override Disabled Plan', 1); const zid = await createZone('SOD Zone', ZONE_TYPE_ENABLED); const [seat] = await addSeats(pid, zid, ['SOD.1']); @@ -498,17 +498,22 @@ test.describe('book-for override of an assignment', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [DAY]); await page.waitForTimeout(400); - // Self-view: disabled seat -> grey unavailable. + // Self-view: disabled seat -> grey X, no book offered (seat-edit only). await expectSprite(page, seat, 'cell-unavailable'); + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + await page.keyboard.press('Escape'); // close the action modal before opening book-for + await page.waitForTimeout(200); - // Book-for user2: the disable is overridden -> green available, book works. + // Book-for user2: icon stays grey X (visual cue), but book is offered + works. await activateBookFor(page, `${USER2.name} [${USER2.login}]`); await selectOnlyDates(page, [DAY]); await page.waitForTimeout(400); - await expectSprite(page, seat, 'cell-available'); - + await expectSprite(page, seat, 'cell-unavailable'); await page.locator(`#sprite-${seat}`).click(); await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).toBeVisible(); await clickActionBtn(page, 'book'); const r = await querySql( 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', @@ -517,6 +522,91 @@ test.describe('book-for override of an assignment', () => { expect(r.rows[0].cnt).toBe(1); }); + test('8e. book-for: target booking in a non-administered zone -> no doomed Release', async ({ page }) => { + // user1 admins zoneA; user2 (the book-for target) has a non-exact booking in + // zoneB (PUBLIC_VIEW), which user1 does NOT administer. Under book-for + // user2 that booking is user2's "own" (CAN_CHANGE -> yoursChange icon), but + // releasing it is a foreign release apply() would reject (403/102) — so no + // action is offered and no modal opens (the doomed Release button is + // suppressed, mirroring the CAN_REBOOK hasUnmanageableConflict guard). + const pid = await createPlan('Sprite DoomedRelease Plan', 1); + const zidA = await createZone('DR Admin', ZONE_TYPE_ENABLED); + const zidB = await createZone('DR View', ZONE_TYPE_PUBLIC_VIEW); + const [seatA] = await addSeats(pid, zidA, ['DR.A']); + const [seatB] = await addSeats(pid, zidB, ['DR.B']); + await assignZoneRole(zidA, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zidA, USER2.login, ZONE_ROLE_USER); + await insertBooking(USER2.login, seatB, DAY + 10 * 3600, DAY + 16 * 3600); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // Self-view: seatB is a foreign booking to user1 -> taken. + await expectSprite(page, seatB, 'cell-taken'); + + // Book-for user2: seatB is user2's own non-exact booking -> yoursChange, but + // in a zone user1 doesn't administer -> no Release offered (doomed). + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seatB, 'cell-yoursChange'); + await page.locator(`#sprite-${seatB}`).click(); + await page.waitForTimeout(300); + const modal = page.locator('#action_modal'); + if (await modal.count() > 0) { + await expect(modal).not.toHaveClass(/open/); + } + // The administered zoneA seat still behaves normally under book-for. + await expectSprite(page, seatA, 'cell-available'); + }); + + test('8f. disabled seat under book-for with a target conflict -> update (relocate target)', async ({ page }) => { + // Target user2 already holds enabled seat E in zoneA (same zone_group as the + // disabled seat D). Under book-for user2, D runs the normal pipeline -> + // CAN_BOOK, then _updateView promotes it to CAN_REBOOK (the target's booking + // on E is a same-group conflict). The icon is forced to grey X (disabled + // cue), but clicking offers Update (book D + release E) — NOT just book, + // which the DB book_overlap trigger would reject. Update succeeds and user2 + // is relocated from E to D. + const pid = await createPlan('Sprite DisabledRebook Plan', 1); + const zid = await createZone('DRA Zone', ZONE_TYPE_ENABLED, 'draGrp'); + const [seatD, seatE] = await addSeats(pid, zid, ['DRA.D', 'DRA.E']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await disableSeat(seatD); + const { fromTS, toTS } = slot(); + await insertBooking(USER2.login, seatE, fromTS, toTS); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // D shows the disabled grey X (forced), not green rebook. + await expectSprite(page, seatD, 'cell-unavailable'); + + // Clicking D offers Update (relocate user2 from E to D), not book. + await page.locator(`#sprite-${seatD}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="update"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + await clickActionBtn(page, 'update'); + + const onD = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seatD], + ); + const onE = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seatE], + ); + expect(onD.rows[0].cnt).toBe(1); + expect(onE.rows[0].cnt).toBe(0); + }); + test('8d. admin selects themselves in book-for -> normal mode (assigned-to-other stays grey, no book offered)', async ({ page }) => { // Selecting your own login in the book-for picker is the exit from // book-for mode: no book.login is sent, so the admin is a regular user diff --git a/js/views/html/plan.html b/js/views/html/plan.html index a5560de8..4b24edfc 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -74,7 +74,7 @@
- The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated. + The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.
diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 6a792d04..692b9586 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -451,16 +451,22 @@ WarpSeat.prototype._updateState = function() { return this.state; } - // Book-for override (see apply() skipping 105/106/110 under is_book_for): - // a zone admin booking FOR a target may book onto a seat assigned to - // someone else, beyond the target's days-in-advance window, OR a seat the - // admin has disabled — fall through to the normal pipeline -> CAN_BOOK - // instead of the early DISABLED / ASSIGNED return. Only under book-for - // (factory.login != the real login) and where `bookable` holds — which - // under book-for already means the actor administers the zone. Self-booking - // never overrides (105/106/110 apply). Hoisted above the !this.enabled - // check so a disabled seat under book-for skips that early return. + // Book-for override of seat-level restrictions (see apply() skipping + // 105/106/110 under is_book_for): a zone admin booking FOR a target may + // book onto a seat assigned to someone else, beyond the target's + // days-in-advance window, OR a seat the admin has disabled — so under + // book-for a disabled seat does NOT take the DISABLED early return; it + // runs the normal pipeline (CAN_BOOK / CAN_REBOOK / CAN_CHANGE / CAN_DELETE_*) + // so the click handler offers the right action (book, update when the target + // has a conflicting booking in the zone group, release when the target + // already holds it). Only under book-for (factory.login != the real login) + // and where `bookable` holds — which under book-for already means the actor + // administers the zone. Self-booking never overrides (105/106/110 apply). + // The disabled ICON is kept as a visual cue: _updateView forces the sprite + // to 'unavailable' for a disabled seat under book-for (the state still + // drives the actions; the icon just signals "this seat is off"). const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; + this.bookForOverride = bookForOverride; if (!this.enabled && !bookForOverride) { this.state = WarpSeat.SeatStates.DISABLED; @@ -649,6 +655,13 @@ WarpSeat.prototype._updateView = function() { } this.sprite = spriteFor(this.state, assignedToMe); + + // Book-for override of a seat-level disable: the disabled seat ran the + // normal pipeline (so its state drives the click actions — book / update / + // release) but the ICON stays the grey "unavailable" X as a visual cue that + // the seat is off. Only under book-for in an administered zone. + if (!this.enabled && this.bookForOverride) + this.sprite = 'unavailable'; } WarpSeat.prototype._destroy = function() { diff --git a/js/views/plan.js b/js/views/plan.js index 6744e1e9..2e29a0b5 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -646,6 +646,13 @@ export async function mount(ctx) { var removeMsg = false; var blockedMsg = false; var foreignRelease = false; + // Under book-for, the "own" states (CAN_CHANGE / CAN_DELETE / + // CAN_DELETE_EXACT) are the TARGET's bookings. Releasing one is a + // foreign release that apply() only allows in a zone the actor + // administers (seatsReqZoneAdmin). In a non-administered (!bookable) + // zone the release would 403 — don't offer a doomed action (mirrors + // the CAN_REBOOK hasUnmanageableConflict guard). + var bookForForeignRelease = this.factory.login !== window.warpGlobals.login && !this.bookable; switch (state) { case WarpSeat.SeatStates.CAN_BOOK: @@ -653,6 +660,7 @@ export async function mount(ctx) { bookMsg = true; break; case WarpSeat.SeatStates.CAN_CHANGE: + if (bookForForeignRelease) break; // target's booking in a non-administered zone — release would 403 actions.push('delete'); removeMsg = true; // Update extends/changes the booking — only offer it where @@ -679,6 +687,7 @@ export async function mount(ctx) { break; case WarpSeat.SeatStates.CAN_DELETE: case WarpSeat.SeatStates.CAN_DELETE_EXACT: + if (bookForForeignRelease) break; // target's booking in a non-administered zone — release would 403 actions.push('delete'); removeMsg = true; break; diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index 0e791e25..cdd1d260 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "Der Sitzplatz ist Ihnen zugewiesen und kann gebucht werden.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist Ihnen zugewiesen und verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is booked by you exactly on the selected date and time.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit — klicken Sie, um ihn freizugeben oder auf die gewählte Zeit zu aktualisieren.", "The seat is booked by someone else or not available for booking.": "Der Platz ist von einer anderen Person gebucht oder nicht zur Buchung verfügbar.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone.", "The seat is assigned to some people but currently not booked.": "Der Platz ist bestimmten Personen zugewiesen, aber derzeit nicht gebucht.", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index b65dab69..a210afc7 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "The seat is assigned to you and available to be booked.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.", "The seat is booked by you exactly on the selected date and time.": "The seat is booked by you exactly on the selected date and time.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.", "The seat is booked by someone else or not available for booking.": "The seat is booked by someone else or not available for booking.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.", "The seat is assigned to some people but currently not booked.": "The seat is assigned to some people but currently not booked.", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index dfa223a2..cad27dc7 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "El asiento está asignado a usted y disponible para reservar.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "El asiento está asignado a usted y disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is booked by you exactly on the selected date and time.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada — haga clic para liberarlo o actualizarlo al tiempo seleccionado.", "The seat is booked by someone else or not available for booking.": "Este asiento está reservado por otra persona o no está disponible para reservar.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura.", "The seat is assigned to some people but currently not booked.": "El asiento está asignado a algunas personas pero actualmente no está reservado.", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index 058a7e7f..2bf48e09 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "Le siège vous est assigné et disponible à la réservation.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Le siège vous est assigné et disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is booked by you exactly on the selected date and time.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné — cliquez pour le libérer ou le mettre à jour à l'horaire sélectionné.", "The seat is booked by someone else or not available for booking.": "Le siège est réservé par quelqu'un d'autre ou non disponible à la réservation.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule.", "The seat is assigned to some people but currently not booked.": "Le siège est assigné à certaines personnes mais actuellement non réservé.", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index 7b77fb7b..f3a613ec 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -169,7 +169,7 @@ "The seat is assigned to you and available to be booked.": "Miejsce jest przypisane do Ciebie i dostępne do rezerwacji.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Miejsce jest przypisane do Ciebie i dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is booked by you exactly on the selected date and time.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie — kliknij, aby je zwolnić lub uaktualnić do wybranego czasu.", "The seat is booked by someone else or not available for booking.": "Miejsce jest zarezerwowane przez kogoś innego lub niedostępne do rezerwacji.", "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu.", "The seat is assigned to some people but currently not booked.": "Miejsce jest przypisane do niektórych osób, ale obecnie nie jest zarezerwowane.", From 878ee8cb604d86b34834055d83fba4f8592f7be0 Mon Sep 17 00:00:00 2001 From: sebo Date: Sun, 5 Jul 2026 08:58:11 +0200 Subject: [PATCH 16/17] Phase 3 review follow-ups + stale viewer action-modal assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - e2e: admin/zones.spec.ts viewer-role test now expects #action_modal present in the DOM (the click handler gates viewer actions), matching the equivalent flip in zone-permissions.spec.ts that 58d7981 missed. Suite was 20/21 since. - PERMISSIONS.md §11 invariant #1: document the exception that the disabled-seat cue under book-for stays grey while actionable. - GLOSSARY.md: book-for's seat-level overrides now list disabled seats alongside assignments. - e2e: replace literal `10 /* DISABLED */` with the exported ZONE_TYPE_DISABLED in zone-shrink-bypass.spec.ts, and the same literal in zone-permissions-bookfor.spec.ts (consistency). - .gitignore: ignore a stray root-level test-results/ (playwright once ran from the repo root; e2e/.gitignore only covers its own). - seat.js: reset this.bookForOverride=false before the NOT_AVAILABLE early return so a stale book-for cue can't survive a no-dates re-render. Co-Authored-By: GLM 5.2 --- .gitignore | 1 + GLOSSARY.md | 2 +- PERMISSIONS.md | 5 +++-- e2e/tests/admin/zones.spec.ts | 6 +++++- e2e/tests/booking/zone-permissions-bookfor.spec.ts | 3 ++- e2e/tests/booking/zone-shrink-bypass.spec.ts | 3 ++- js/views/modules/seat.js | 4 ++++ 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 8268a844..439547d0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # local scratch / generated artifacts /graphify-out/ PLAN_*.md +test-results/ .claude/ .opencode/ .pi/ diff --git a/GLOSSARY.md b/GLOSSARY.md index 422e9b41..92262222 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -177,7 +177,7 @@ A convenience for zone (and site) admins: book (or auto-book) a seat **on behalf of another user**, exercising the admin's own zone-admin authority rather than the target's. The booking is recorded under the target user, who only needs to be a **member** of the zone (any role, viewers included) — the admin's standing -overrides seat-level restrictions (assignments) there. Both manual and +overrides seat-level restrictions (assignments and disabled seats) there. Both manual and auto-book-for require the admin to administer the zone in question — see [PERMISSIONS.md](PERMISSIONS.md) §8. diff --git a/PERMISSIONS.md b/PERMISSIONS.md index 8d856149..025d3f6a 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -392,8 +392,9 @@ behaviour not derivable from them is a bug — or requires amending them first. 1. **Icons state facts; colours carry the meaning.** Green = the actor (or their book-for target) can book here; blue = the actor's/target's own - (booking or assignment); grey = not bookable/updatable. Arrows = acting - here changes existing bookings. + (booking or assignment); grey = not bookable/updatable — except the + disabled-seat cue under book-for, which stays grey while actionable. + Arrows = acting here changes existing bookings. 2. **The click reveals the permitted actions; the icon never enumerates them.** A grey seat may still offer *Release* — an own overlap, or a foreign booking for a zone admin. The icon is a scan-friendly summary, not an diff --git a/e2e/tests/admin/zones.spec.ts b/e2e/tests/admin/zones.spec.ts index 521b03b9..8b28b347 100644 --- a/e2e/tests/admin/zones.spec.ts +++ b/e2e/tests/admin/zones.spec.ts @@ -74,7 +74,11 @@ test.describe('zone assignment', () => { await page.waitForLoadState('networkidle'); await expect(page.locator('#planmap')).toBeVisible(); await expect(page.locator('#auto_book_btn')).toHaveCount(0); - await expect(page.locator('#action_modal')).toHaveCount(0); + // The action modal HTML stays in the DOM for viewers so a pure viewer can + // still release their OWN booking from the plan map; the click handler + // opens it only for actionable seats (same rationale as the equivalent + // assertion in booking/zone-permissions.spec.ts). + await expect(page.locator('#action_modal')).toHaveCount(1); }); test('viewer-role user cannot book via API (code 104)', async ({ page }) => { diff --git a/e2e/tests/booking/zone-permissions-bookfor.spec.ts b/e2e/tests/booking/zone-permissions-bookfor.spec.ts index cbcc0b53..aeb43b4e 100644 --- a/e2e/tests/booking/zone-permissions-bookfor.spec.ts +++ b/e2e/tests/booking/zone-permissions-bookfor.spec.ts @@ -43,6 +43,7 @@ import { querySql } from '../../helpers/db'; import { futureDayTs, apiApply, waitForSeatsLoaded } from '../../helpers/booking'; import { pickFirstDate } from '../../helpers/zone-admin'; import { + ZONE_TYPE_DISABLED, ZONE_TYPE_ENABLED, ZONE_TYPE_PUBLIC_VIEW, ZONE_TYPE_PUBLIC_BOOK, @@ -117,7 +118,7 @@ test.describe('site admin is a super-user over all zones', () => { test('A4: still cannot book a DISABLED-zone seat even as super-user', async ({ page }) => { const pid = await createPlan('Superuser Disabled Plan'); - const zid = await createZone('Disabled Z', 10 /* DISABLED */); + const zid = await createZone('Disabled Z', ZONE_TYPE_DISABLED); const [seatId] = await addSeats(pid, zid, ['Z.1']); await clearZoneRoles('admin'); diff --git a/e2e/tests/booking/zone-shrink-bypass.spec.ts b/e2e/tests/booking/zone-shrink-bypass.spec.ts index 854ce419..c70c8eb2 100644 --- a/e2e/tests/booking/zone-shrink-bypass.spec.ts +++ b/e2e/tests/booking/zone-shrink-bypass.spec.ts @@ -20,6 +20,7 @@ import { USER1, USER3 } from '../../helpers/users'; import { querySql } from '../../helpers/db'; import { futureDayTs, apiApply } from '../../helpers/booking'; import { + ZONE_TYPE_DISABLED, ZONE_TYPE_ENABLED, ZONE_TYPE_PUBLIC_VIEW, ZONE_ROLE_USER, @@ -106,7 +107,7 @@ test.describe('pure-shrink bypass: viewer zone', () => { test.describe('pure-shrink bypass: DISABLED zone', () => { test('shrink own booking in a DISABLED zone -> 200', async ({ page }) => { const pid = await createPlan('Shrink Disabled Plan', 1); - const zid = await createZone('Shrink Disabled', 10 /* DISABLED */); + const zid = await createZone('Shrink Disabled', ZONE_TYPE_DISABLED); const [seat] = await addSeats(pid, zid, ['SD.1']); await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); const bid = await bookingBid(USER3.login, seat, FULL.fromTS); diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 692b9586..61a5168d 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -446,6 +446,10 @@ WarpSeat.prototype._bookingsIterator = function*() { WarpSeat.prototype._updateState = function() { + // ponytail: reset before the early returns so a NOT_AVAILABLE re-render + // (no dates selected) doesn't leave a stale book-for cue for _updateView. + this.bookForOverride = false; + if (!this.factory.selectedDates.length) { this.state = WarpSeat.SeatStates.NOT_AVAILABLE; return this.state; From 4dad811ef4e6c41fdcf674a90a6d2241121ba8d7 Mon Sep 17 00:00:00 2001 From: sebo Date: Sun, 5 Jul 2026 18:03:35 +0200 Subject: [PATCH 17/17] Second-pass review fixes: doomed-action guard on CAN_CHANGE/CAN_DELETE + 5 cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one real bug from the view-only-seats code review: the "don't offer a doomed action" guard lived only on CAN_REBOOK. With a multi-day book-for selection where the target also holds a same-zone-group booking in a zone the admin doesn't administer, the modal offered an Update/Release that apply() guaranteed to 403 (code 102) and roll the whole request back — a UX bug, not a safety one (the server-side invariant held). The fix mirrors the guard already written two cases down. - plan.js: extend seatFactory.hasUnmanageableConflict() from CAN_REBOOK to CAN_CHANGE/CAN_DELETE/CAN_DELETE_EXACT so book-for update/release that would 403 on a cross-zone same-group conflict is explained ("zone you don't administer") instead of offered. Update stays gated on CAN_CHANGE; the guard returns false outside book-for. - seat.js: replace the stored bookForOverride flag with an on-demand _isBookForOverride() so it can't go stale across _updateState early returns. - plan.py getSeats: reuse effective_roles for admin_zids (the materialized view has a unique (login,zid) row with MIN(zone_role), so the dict lookup is equivalent to the old role-gated query) and query only the cross-plan same-group zones absent from it — no query in the common case. - plan.py apply(): collapse the book-for role check (nested if/elif) and the assignment check (assignedQ two-step) to one line each. - e2e: hoist activateBookFor/clearBookFor to helpers/booking.ts; drop two local copies and three inline duplications; add a CAN_CHANGE blocked-action test mirroring the existing CAN_REBOOK one. Co-Authored-By: Claude Fable 5 --- e2e/helpers/booking.ts | 23 +++++++ e2e/tests/booking/bookfor-zone-group.spec.ts | 64 ++++++++++++------- e2e/tests/booking/zone-group.spec.ts | 10 +-- .../booking/zone-permissions-bookfor.spec.ts | 10 +-- e2e/tests/booking/zone-view-sprites.spec.ts | 13 +--- e2e/tests/zone-admin/book-for.spec.ts | 9 +-- js/views/modules/seat.js | 21 +++--- js/views/plan.js | 22 +++++-- warp/xhr/plan.py | 38 ++++++----- 9 files changed, 122 insertions(+), 88 deletions(-) diff --git a/e2e/helpers/booking.ts b/e2e/helpers/booking.ts index dd754bac..bdff5624 100644 --- a/e2e/helpers/booking.ts +++ b/e2e/helpers/booking.ts @@ -229,6 +229,29 @@ export async function clickActionBtn( await page.waitForTimeout(200); } +/** Activate book-for for the given display label (e.g. "Bar [user2]"). */ +export async function activateBookFor(page: Page, label: string): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially(label.split(' ')[0], { delay: 50 }); + const item = page.locator('ul.autocomplete-content li', { hasText: label }); + await expect(item).toBeVisible({ timeout: 5000 }); + await item.click(); + // book-for fires a full getSeats?login= refresh; wait for it to settle. + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(400); +} + +/** Clear book-for (Enter on an empty input resets to the admin's own login). */ +export async function clearBookFor(page: Page): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.fill(''); + await bookForInput.press('Enter'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(400); +} + /** Direct XHR to /xhr/plan/apply using the current page session (cookies). * Redirects are not followed: an expired session answers with a 302 to /login, * and following it would turn that into a misleading 200 (the login page). */ diff --git a/e2e/tests/booking/bookfor-zone-group.spec.ts b/e2e/tests/booking/bookfor-zone-group.spec.ts index cbd93da1..73df66df 100644 --- a/e2e/tests/booking/bookfor-zone-group.spec.ts +++ b/e2e/tests/booking/bookfor-zone-group.spec.ts @@ -30,31 +30,11 @@ import { clickActionBtn, waitForSeatsLoaded, apiApply, + activateBookFor, + clearBookFor, + getSelectableDates, } from '../../helpers/booking'; -/** Activate book-for for the given display label (e.g. "Bar [user2]"). */ -async function activateBookFor(page: any, label: string): Promise { - const bookForInput = page.locator('#book-for'); - await bookForInput.click(); - await bookForInput.pressSequentially(label.split(' ')[0], { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: label }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - // book-for fires a full getSeats?login= refresh; wait for it to settle. - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(400); -} - -/** Clear book-for (Enter on an empty input resets to the admin's own login). */ -async function clearBookFor(page: any): Promise { - const bookForInput = page.locator('#book-for'); - await bookForInput.click(); - await bookForInput.fill(''); - await bookForInput.press('Enter'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(400); -} - /** Open the action modal for a seat and return the set of visible action labels. */ async function seatActions(page: any, seat: any): Promise { await clickZoneSeat(page, seat); @@ -275,6 +255,44 @@ test.describe('book-for + zone group', () => { await expect(page.locator('#action_modal_msg1')).toContainText("don't administer"); }); + test('book-for CAN_CHANGE: update/release blocked when a same-group conflict lies in a non-administered zone', async ({ page }) => { + // Same confinement as the rebook test above, but the clicked seat holds + // the TARGET's own booking (CAN_CHANGE, not CAN_REBOOK): user2 holds the + // administered Zone 1A seat on day 1 and the inaccessible Parking seat + // (same group) on day 2. With both days selected, update/release from the + // Zone 1A seat would also have to remove the Parking booking — apply() + // would 403 (code 102) and roll everything back — so the modal offers + // neither action and shows the explanatory message instead. + const zone1Seat = (await getZoneSeats(1))[0]; + const parkingSeat = (await getZoneSeats(3))[0]; + + await querySql("UPDATE zone SET zone_group = 'floor-1' WHERE id IN (1, 3)"); + await querySql('UPDATE seat SET pid = 1 WHERE id = $1', [parkingSeat.id]); + await logIn(page, ADMIN); + await adminPost(page, '/xhr/zones/assign', { zid: 1, change: [{ login: USER1.login, role: 10 }] }); + + await logIn(page, USER1); + await page.goto('/plan/1'); + await waitForSeatsLoaded(page); + // Two selectable days straight from the rendered calendar (futureDayTs + // could land on an omitted weekday); disjoint days keep the two seeded + // same-group bookings clear of the book_overlap trigger. + const [day1, day2] = await getSelectableDates(page); + await querySql( + 'INSERT INTO book (login, sid, fromts, tots) VALUES ($1, $2, $3, $4), ($5, $6, $7, $8)', + [USER2.login, zone1Seat.id, day1 + 9 * 3600, day1 + 17 * 3600, + USER2.login, parkingSeat.id, day2 + 9 * 3600, day2 + 17 * 3600]); + await selectOnlyDates(page, [day1, day2]); + await page.waitForTimeout(400); + await activateBookFor(page, 'Bar [user2]'); // full refresh picks up the seeded bookings + + await clickZoneSeat(page, zone1Seat); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).not.toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); + await expect(page.locator('#action_modal_msg1')).toContainText("don't administer"); + }); + test('book-for cannot release a target\'s booking in a non-administered same-group zone', async ({ page }) => { // user1 administers Zone 1A but is only a regular USER in Parking (Zone 3), // same group. user2 already holds the Parking seat. Booking user2 into Zone diff --git a/e2e/tests/booking/zone-group.spec.ts b/e2e/tests/booking/zone-group.spec.ts index cb8b8150..e0f00992 100644 --- a/e2e/tests/booking/zone-group.spec.ts +++ b/e2e/tests/booking/zone-group.spec.ts @@ -25,6 +25,7 @@ import { clickActionBtn, waitForSeatsLoaded, apiApply, + activateBookFor, } from '../../helpers/booking'; test.describe('per-zone booking constraint', () => { @@ -382,14 +383,7 @@ test.describe('zone group constraint (non-null zone_group)', () => { await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - // Activate book-for for user2 - const bookForInput = page.locator('#book-for'); - await bookForInput.click(); - await bookForInput.pressSequentially('Bar', { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - await page.waitForTimeout(400); + await activateBookFor(page, 'Bar [user2]'); // Click Zone 1B's seat — should show "update" (rebook) because user2 // already has a same-group booking on plan 1 diff --git a/e2e/tests/booking/zone-permissions-bookfor.spec.ts b/e2e/tests/booking/zone-permissions-bookfor.spec.ts index aeb43b4e..339c2a90 100644 --- a/e2e/tests/booking/zone-permissions-bookfor.spec.ts +++ b/e2e/tests/booking/zone-permissions-bookfor.spec.ts @@ -40,7 +40,7 @@ import { test, expect } from '../../fixtures'; import { logIn } from '../../helpers/auth'; import { ADMIN, USER1, USER2, USER3 } from '../../helpers/users'; import { querySql } from '../../helpers/db'; -import { futureDayTs, apiApply, waitForSeatsLoaded } from '../../helpers/booking'; +import { futureDayTs, apiApply, waitForSeatsLoaded, activateBookFor } from '../../helpers/booking'; import { pickFirstDate } from '../../helpers/zone-admin'; import { ZONE_TYPE_DISABLED, @@ -349,13 +349,7 @@ test.describe('auto-book for another user', () => { await pickFirstDate(page); await page.waitForTimeout(400); - const bookForInput = page.locator('#book-for'); - await bookForInput.click(); - await bookForInput.pressSequentially('Bar', { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - await page.waitForTimeout(200); + await activateBookFor(page, 'Bar [user2]'); const [resp] = await Promise.all([ page.waitForResponse(r => r.url().includes('/xhr/plan/autoBook')), diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts index 8700b4e3..23526a6d 100644 --- a/e2e/tests/booking/zone-view-sprites.spec.ts +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -26,6 +26,7 @@ import { selectOnlyDates, waitForSeatsLoaded, clickActionBtn, + activateBookFor, } from '../../helpers/booking'; import { ZONE_TYPE_ENABLED, @@ -404,18 +405,6 @@ test.describe('assigned-to-me in a bookable ENABLED zone (risk #5 guard)', () => // (assigned to the target, beyond its window) — no special override glyph. // --------------------------------------------------------------------------- -/** Activate book-for for the given display label (e.g. "Bar [user2]"). */ -async function activateBookFor(page: any, label: string): Promise { - const bookForInput = page.locator('#book-for'); - await bookForInput.click(); - await bookForInput.pressSequentially(label.split(' ')[0], { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: label }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(400); -} - test.describe('book-for override of an assignment', () => { test('8. seat assigned to a third person, book-for target is a member → available + book works', async ({ page }) => { const pid = await createPlan('Sprite Override Plan', 1); diff --git a/e2e/tests/zone-admin/book-for.spec.ts b/e2e/tests/zone-admin/book-for.spec.ts index 59a535ad..cbbd9971 100644 --- a/e2e/tests/zone-admin/book-for.spec.ts +++ b/e2e/tests/zone-admin/book-for.spec.ts @@ -8,6 +8,7 @@ import { clickZoneSeat, waitForSeatsLoaded, apiApply, + activateBookFor, } from '../../helpers/booking'; import { pickFirstDate } from '../../helpers/zone-admin'; @@ -52,13 +53,7 @@ test.describe('booking for another user', () => { await pickFirstDate(page); await page.waitForTimeout(400); - const bookForInput = page.locator('#book-for'); - await bookForInput.click(); - await bookForInput.pressSequentially('Bar', { delay: 50 }); - const dropdownItem = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); - await expect(dropdownItem).toBeVisible({ timeout: 5000 }); - await dropdownItem.click(); - await page.waitForTimeout(200); + await activateBookFor(page, 'Bar [user2]'); const [seat] = await getZoneSeats(1); await clickZoneSeat(page, seat); diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 61a5168d..9e1afb39 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -444,11 +444,15 @@ WarpSeat.prototype._bookingsIterator = function*() { } } -WarpSeat.prototype._updateState = function() { +// Book-for override of seat-level restrictions is in force for this seat: +// the actor is booking FOR someone else and `bookable` holds — which under +// book-for already means the actor administers the zone. Derived on demand +// (never stored) so it can't go stale across _updateState early returns. +WarpSeat.prototype._isBookForOverride = function() { + return this.bookable && this.factory.login !== window.warpGlobals.login; +} - // ponytail: reset before the early returns so a NOT_AVAILABLE re-render - // (no dates selected) doesn't leave a stale book-for cue for _updateView. - this.bookForOverride = false; +WarpSeat.prototype._updateState = function() { if (!this.factory.selectedDates.length) { this.state = WarpSeat.SeatStates.NOT_AVAILABLE; @@ -469,8 +473,7 @@ WarpSeat.prototype._updateState = function() { // The disabled ICON is kept as a visual cue: _updateView forces the sprite // to 'unavailable' for a disabled seat under book-for (the state still // drives the actions; the icon just signals "this seat is off"). - const bookForOverride = this.bookable && this.factory.login !== window.warpGlobals.login; - this.bookForOverride = bookForOverride; + const bookForOverride = this._isBookForOverride(); if (!this.enabled && !bookForOverride) { this.state = WarpSeat.SeatStates.DISABLED; @@ -663,8 +666,10 @@ WarpSeat.prototype._updateView = function() { // Book-for override of a seat-level disable: the disabled seat ran the // normal pipeline (so its state drives the click actions — book / update / // release) but the ICON stays the grey "unavailable" X as a visual cue that - // the seat is off. Only under book-for in an administered zone. - if (!this.enabled && this.bookForOverride) + // the seat is off. Only under book-for in an administered zone. (With no + // dates selected the state is NOT_AVAILABLE, whose sprite is already + // 'unavailable', so deriving the override here is safe for every state.) + if (!this.enabled && this._isBookForOverride()) this.sprite = 'unavailable'; } diff --git a/js/views/plan.js b/js/views/plan.js index 2e29a0b5..f1ecb724 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -660,14 +660,28 @@ export async function mount(ctx) { bookMsg = true; break; case WarpSeat.SeatStates.CAN_CHANGE: + case WarpSeat.SeatStates.CAN_DELETE: + case WarpSeat.SeatStates.CAN_DELETE_EXACT: if (bookForForeignRelease) break; // target's booking in a non-administered zone — release would 403 + // Delete/update remove the acting user's conflicting + // bookings across the whole zone group. Under book-for + // that set can include the target's booking in a zone the + // actor doesn't administer — apply() 403s that foreign + // remove (code 102) and rolls the whole request back, so + // explain instead of offering a doomed action (same guard + // as CAN_REBOOK below; false outside book-for). + if (seatFactory.hasUnmanageableConflict(this)) { + blockedMsg = true; + break; + } actions.push('delete'); removeMsg = true; // Update extends/changes the booking — only offer it where // the actor may book (this.bookable) or the selection is a // pure shrink of an own booking (always allowed, even in a // view-only / DISABLED zone — apply()'s is_pure_shrink). - if (this.bookable || this.isSelectionShrinkOfMine()) { + if (state == WarpSeat.SeatStates.CAN_CHANGE && + (this.bookable || this.isSelectionShrinkOfMine())) { actions.push('update'); bookMsg = true; } @@ -685,12 +699,6 @@ export async function mount(ctx) { bookMsg = removeMsg = true; } break; - case WarpSeat.SeatStates.CAN_DELETE: - case WarpSeat.SeatStates.CAN_DELETE_EXACT: - if (bookForForeignRelease) break; // target's booking in a non-administered zone — release would 403 - actions.push('delete'); - removeMsg = true; - break; case WarpSeat.SeatStates.TAKEN: // A zone admin may release another user's booking on a seat // in a zone they administer (apply()'s remove requires diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index 50ce5e43..c4566362 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -417,16 +417,23 @@ def getSeats(pid): # admins administer every zone. if flask.g.isAdmin: admin_zids = set(usedZids) - elif usedZids: - admin_zids = { - r['zid'] for r in UserToZoneRoles.select(UserToZoneRoles.zid) - .where(UserToZoneRoles.zid.in_(list(usedZids))) - .where(UserToZoneRoles.login == flask.g.login) - .where(UserToZoneRoles.zone_role <= ZONE_ROLE_ADMIN) - .iterator() - } else: - admin_zids = set() + # effective_roles already holds the actor's effective role for every + # zone on this plan; only zones pulled in by conflict bookings and + # absent from it (cross-plan same-group zones) need a fresh role + # lookup — in the common case there are none and no query runs. + admin_zids = {zid for zid in usedZids + if zid in effective_roles + and effective_roles[zid] <= ZONE_ROLE_ADMIN} + crossplan_zids = [zid for zid in usedZids if zid not in effective_roles] + if crossplan_zids: + admin_zids |= { + r['zid'] for r in UserToZoneRoles.select(UserToZoneRoles.zid) + .where(UserToZoneRoles.zid.in_(crossplan_zids)) + .where(UserToZoneRoles.login == flask.g.login) + .where(UserToZoneRoles.zone_role <= ZONE_ROLE_ADMIN) + .iterator() + } res['zoneAdmin'] = {str(zid): (zid in admin_zids) for zid in usedZids} usedUsersQuery = Users.select(Users.login, Users.name).where(Users.login.in_(usedUsers)).tuples() @@ -597,10 +604,9 @@ def _covered(d): isSelfAdminBooking = flask.g.isAdmin and login == flask.g.login if not isSelfAdminBooking and not is_pure_shrink: - if is_book_for: - if bookerRole is None: - return {"msg": "Forbidden", "code": 104}, 403 - elif bookerRole is None or bookerRole > ZONE_ROLE_USER: + # Book-for needs only target membership (any role row); self-book + # needs role <= USER. + if bookerRole is None or (not is_book_for and bookerRole > ZONE_ROLE_USER): return {"msg": "Forbidden", "code": 104}, 403 # Disabled zones cannot be booked at all — even by admins. @@ -616,8 +622,10 @@ def _covered(d): # the is_pure_shrink guard above; this skip is book-for-only. return {"msg": "Forbidden", "code": 105}, 403 - assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) if not is_book_for and not is_pure_shrink else None - if assignedQ is not None and assignedQ.scalar() is not None: + # Assignment checks (106/110) are skipped for book-for (a zone admin + # may override them) and for pure shrinks (always allowed). + if not is_book_for and not is_pure_shrink \ + and SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid).scalar() is not None: myAssignments = list(SeatAssign.select(SeatAssign.days_in_advance) .where((SeatAssign.sid == sid) & ((SeatAssign.login == login) | SeatAssign.login.is_null()))