diff --git a/ADMIN_SURFACE_PLAN.md b/ADMIN_SURFACE_PLAN.md new file mode 100644 index 0000000000..84b5844185 --- /dev/null +++ b/ADMIN_SURFACE_PLAN.md @@ -0,0 +1,383 @@ +# Admin surface unification plan + +## Status + +Approved. Slice 1 is built, and so is the backwards check that slice 3 was going +to add. Slice 2, and the policy builders in slice 3, remain. See +[What is built](#what-is-built) for the map from this plan to the code, and +[The shortlist](#the-shortlist--other-rooms-the-survey-found) for the survey +behind it. + +The built code is the authority for the parts that shipped. This document now +points at them; it does not describe them a second time. + +## What is built + +| Piece | Where it lives now | +| ------------------------ | -------------------------------------------- | +| The one declaration | `src/shared/admin-surface/areas.ts` | +| Types and the fold | `src/shared/admin-surface/definitions.ts` | +| Ids and path parameters | `src/shared/admin-surface/ids.ts` | +| Navigation and its order | `src/shared/admin-surface/sections.ts` | +| The derived surface | `src/shared/admin-surface.ts` | +| Navigation model | `src/shared/admin-pages.ts` | +| The backwards check | `test/integration/admin-role-matrix.test.ts` | + +Deleted with slice 1: `admin-surface/nav-routes.ts`, +`admin-surface/write-routes-a-m.ts`, `admin-surface/write-routes-n-z.ts`, and +the hand-written `ADMIN_SURFACE_AREAS` map. + +Two things differ from what this plan first proposed: + +- **Sections own their navigation order.** The plan grouped routes by area but + did not say what happens to navigation order, which used to be the position of + an entry in a 383-line file. Grouping by area destroys that order, because one + section draws its links from up to sixteen areas. Each section now names its + own links in order, so the order is a stated fact rather than a side effect of + where a line sits. +- **A route no longer carries a section.** All 111 routes declared one, but only + the 42 with a navigation link ever read it. It is gone from the other 69. + +### What the backwards check found + +`deliveries` declared `STAFF_ADMIN_LEVELS`, but `GET /admin/deliveries` is gated +by `deliveryPage`, which admits agents — and its own comment calls the run sheet +"their only page". An agent could open the page while the surface said they +could not, so `adminDestinationAllowed` gave the wrong answer for every link to +it. + +The declaration was corrected to `DELIVERY_ADMIN_LEVELS`, which is the one place +that fact now lives. This is the "stricter side wins" default being overruled on +purpose: the strict reading would take the run sheet away from the agents it was +built for. No access changed — only the answer the surface gives about it. + +## The search that led here + +The job was to find one sweeping change that removes repetitive structure and +adds to the longevity of the system. jscpd already holds token duplication at +0%, so the repetition that remains is shape-level: one fact declared more than +once, in different words. Five sweeps covered the route layer, the database +layer, the templates, the payment providers, and the other shared modules. + +The winner is the admin surface. It is the one place where the system keeps a +complete, hand-made map of itself — and nothing ties the map to the territory. + +## Current-system value + +Today one admin route's path, role, and area are declared in up to six files, +and only a test polices their agreement. After this change, each fact is +declared once, and every consumer derives from that declaration. The production +consumers are: + +- `/admin/*` route dispatch (`src/features/admin/index.ts`); +- the admin navigation and links (`src/shared/admin-pages.ts`, + `src/shared/admin-surface.ts`); +- read-only gates (`readOnlyGetRoutePatterns`). + +## The duplication this replaced + +This is the state slice 1 replaced, kept as the evidence for the change. Before +it, `src/shared/admin-surface/` was a shadow map of the admin surface: 111 +destinations, each with a path pattern, a role audience, an area, a section, and +a read/write intent, while the real route tables in `src/features/admin/*` +repeated the same facts. + +| File | Lines | What it restates | +| ---------------------------------------------- | ----- | -------------------------------------------------- | +| `src/shared/admin-surface/definitions.ts` | 177 | 43 areas → segments, plus the destination builders | +| `src/shared/admin-surface/nav-routes.ts` | 383 | 42 view destinations: pattern + audience + nav | +| `src/shared/admin-surface/write-routes-a-m.ts` | 344 | 48 write destinations: pattern + audience | +| `src/shared/admin-surface/write-routes-n-z.ts` | 155 | 21 write destinations: pattern + audience | +| `src/features/admin/area-loaders.ts` | 319 | the same 43 areas again → lazy import + messages | +| `src/features/admin/*` route tables | — | the same patterns again, as route keys | +| handler guards (`withAuth`, `requireOwnerOr`…) | — | the same roles again, as auth policies | + +Three measured symptoms: + +1. **The role is declared twice, with no check.** The surface tables state an + audience 121 times (54 `OWNER_AUDIENCE`, 33 `STAFF_ADMIN_LEVELS`, 18 + `SITE_ADMIN_LEVELS`, 16 `CONTENT_ADMIN_LEVELS`). Each handler states its + policy again (`OWNER_FORM`, `AUTH_FORM`, …). The `OWNER_API` policy carries a + comment that admits the agreement is manual: "Keeps the JSON API + authorization aligned with the UI so a manager cannot perform via the API + what the dashboard denies them" (`src/features/auth.ts`). Nothing enforces + that promise for any of the 111 destinations. +2. **A 125-line test exists only to police the drift.** + `test/integration/admin-route-manifest.test.ts` loads every area and checks + that segments, routes, and destinations still agree. The test is a direct + measurement of the risk: without the duplication, most of it has nothing to + police. +3. **One worked example — `/admin/holidays` declares its facts in six places.** + The paths: `crudRoutes("/admin/holidays", …)` and + `entityTabRoutes("/admin/holidays", …)` (`src/features/admin/holidays.ts`), + `basePath` and `navActive` (`src/features/admin/holiday-page.ts`), and the + patterns again in `nav-routes.ts` and `write-routes-a-m.ts`. The owner role: + once in `createOwnerCrudHandlers`, once in `requireOwnerOr`, and three times + as `OWNER_AUDIENCE`. The area: once in `ADMIN_SURFACE_AREAS`, once in + `ADMIN_AREA_LOADERS`. + +The two 43-entry area maps (`ADMIN_SURFACE_AREAS` and `ADMIN_AREA_LOADERS`) +exist apart for one good reason: the surface must be readable without an import +of any handler module, because handlers load lazily per segment. The design +below keeps that property. + +## Behavior contract + +### Trusted facts + +- The route tables in `src/features/admin/*` are the observed authority for + which method/path pairs exist. The manifest test loads them to know. +- The auth policy at each handler is the observed authority for who can act. +- The surface tables are the expected map of both. Today nothing makes the + expected map match the observed facts except the manifest test and care. + +This plan turns the expected map into the single source, so the observed facts +derive from it and cannot drift. + +### Valid states + +Compile-time only, because no stored data changes. Every route must carry an id, +an area, a pattern, an audience, and an intent; a route reached from the +navigation also carries its link. The loader map stays an exhaustive +`Record`, so an area without a loader entry, or a loader without +an area, does not compile. + +### Commands and events + +None. The change adds no runtime command. Route dispatch, authentication, and +responses keep their current behaviour, except where an audience and a policy +disagree today (see Security and privacy). + +### Failure table + +None at runtime. The failures this plan targets move to compile time or to +module wiring: a destination without a handler, a route without a declared +segment, an area without a loader. + +### Retry and replay table + +None. The change adds no write and no external call. + +### Concurrency table + +None. The declarations are immutable module-load data, as the current tables +are. + +### Owner choices + +None for operators. For the human reviewer: every audience↔policy disagreement +the migration surfaces is listed in its pull request for an explicit decision. +The stricter side is the proposed default. No disagreement is resolved silently. + +### Security and privacy + +- Who can perform each action does not change by design. Where the declared + audience and the enforced policy disagree today, that disagreement is exactly + the bug class this plan removes. Each found case gets an explicit fix and a + regression test. +- The navigation already shows a destination only when the viewer's level is in + the audience. The role matrix proves that the declared audience and the gate + the handler enforces agree for the 48 pages whose pattern takes no parameter, + so a rendered link to one of those cannot outrun its target. The 63 pages for + one record are not covered: a missing record answers 404 whatever the role is, + so each needs a fixture, which is the first step of Slice 3. After Slice 3 the + declared audience builds the gate as well, which makes the "never render a + dead or forbidden link" rule mechanical for the whole admin surface rather + than checked for part of it. +- No secret or personal field moves. No new untrusted input reaches the + database. + +## The shared contract — target design + +One declaration per area, in one eager, pure-data module: + +```typescript +// src/shared/admin-surface/areas.ts +export const ADMIN_AREAS = defineAdminAreas({ + holidays: { + section: "settings", + audience: OWNER_AUDIENCE, + segments: ["holidays"], + destinations: { + holidays: { pattern: "/admin/holidays", nav: link("nav.holidays") }, + holidayNew: { pattern: "/admin/holidays/new", intent: "write-form" }, + holidayEdit: { + pattern: "/admin/holidays/:id/edit", + intent: "write-form", + }, + holidayDelete: { + pattern: "/admin/holidays/:id/delete", + intent: "write-form", + }, + }, + }, + // … 42 more areas +}); +``` + +Rules of the shape: + +- `audience` and `section` are per-area defaults. A destination can override + them, because some areas mix audiences today (`dashboard` holds a staff home + and a content-level listings landing). +- `segments` stays explicit only where an area serves routes with no UI + destination (`markdownPreview`, `debug`). Everywhere else the segments derive + from the destination patterns. +- Everything currently in `ADMIN_SURFACE` derives from this one table: + destinations, area segments, nav model, `adminPath`, read-only patterns. +- `src/features/admin/area-loaders.ts` shrinks to the one fact that must live in + the features layer: `Record import(…)>`, with literal + import specifiers so esbuild can bundle each target. The shared `AdminAreaId` + key type makes an absent loader a compile error. +- Route tables consume the declaration instead of a repeated string: + `crudRoutes` and `entityTabRoutes` take the declared destination, and + `defineRoutes` keys derive from the declared patterns. +- Auth policies derive from the declared audience: `formPolicy(destination)`, + `multipartPolicy(destination)`, `apiPolicy(destination, { allowApiKey })` + build the `AuthPolicy` with `roles` from the declaration. The body kind and + CSRF options stay facts of the handler. The named presets (`OWNER_FORM`, + `CONTENT_FORM`, …) remain only for routes outside the admin surface. + +Cold-start note: the declaration stays cheap module-load data, exactly like the +current tables. The eager path still imports no handler module, and no top-level +await appears. + +## Challenge — questions asked and answered + +- _What if an audience and a policy disagree today?_ The migration surfaces each + case. Each one is triaged in review, fixed on the stricter side unless the + human decides otherwise, and pinned with a regression test. +- _What if two areas share one segment?_ `groups` and `bulkActions` do. The + segment router already merges the maps, and the derivation keeps that. +- _What if an area has routes but no destinations?_ `markdownPreview` and + `debug` keep an explicit `segments` list. The manifest test keeps its proof + that segments and routes agree. +- _What if a nav entry must hide behind a feature flag?_ Nav `visible` stays a + navigation-only fact. The audience gates access. Both already exist and do not + merge. +- _What about POST handlers with no destination of their own?_ They take their + policy from the destination they belong to via `formPolicy(destination)`, so + the tie is by construction. A handler that genuinely needs a different role + set declares a destination-level override, which makes the difference visible + in one place. +- _What breaks if a derivation is wrong?_ The manifest test, the nav tests, the + new role-matrix test, and every admin integration test that exercises real + dispatch. +- _Replay, retries, money, races?_ Not applicable. The change adds no write. + +## Slices + +Three stacked pull requests, bottom up. Each is complete and green on its own. +The database and provider call budget is zero for all three: the change is +module-load data, not runtime IO. + +1. **One declaration per area.** — **Built.** See + [What is built](#what-is-built). +2. **The path becomes one fact.** `crudRoutes`, `entityTabRoutes`, + `defineEditEntityPage`, and the per-area route tables consume the declared + destinations. Delete every repeated path literal in `src/features/admin/*`. + Budget: ~400 changed source lines. + + Measured, now that slice 1 has landed: 14 base paths passed to + `crudRoutes`/`entityTabRoutes` across nine files, and about twelve more in + `basePath`, `navActive`, and `listPath` on the page definitions. The + primitive to add is `adminPattern(id)`, returning the declared pattern with + its literal type kept, so `crudRoutes(adminPattern("holidays"), crud)` still + builds typed route keys. One route resists it: `entityTabRoutes` for listings + uses `/admin/listing`, which no destination declares — the list page is + `/admin/listings` and the detail base only appears inside `detailPath`. + Decide whether the entity base becomes a declared fact before starting. +3. **The role becomes one fact.** The check half is **built**: the role matrix + proves declaration and enforcement agree for every parameter-free page, in + both directions. What remains is deriving the policies rather than checking + them — `formPolicy(destination)`, `multipartPolicy(destination)`, + `apiPolicy(destination)` in `src/features/auth.ts`, and migrating the admin + handlers to them. Budget: ~500 changed source lines. If churn approaches the + limit, split this slice alphabetically the way the write-route files split + today. + + The matrix covers pages whose pattern takes no parameter, because a page for + one record answers 404 when the record is absent, which says nothing about + permission. Extending it to the parameterised pages needs a fixture per + record kind, and is the natural first step of this slice. + +A later, separate candidate: the app layer keeps four parallel prefix-keyed +tables (`PREFIX_LOADERS`, `PREFIX_MESSAGE_GROUPS`, `PREFIX_GATES` in +`src/features/app/routes.ts`, `PREFIX_SETTINGS` in +`src/features/settings-bundles.ts`). The same fold applies one level up, and is +out of scope here. + +## Tests that prove the contract + +- Slice 1: **done.** `test/integration/admin-route-manifest.test.ts` and the nav + tests stay green. `test/shared/admin-surface/definitions.test.ts` covers the + fold directly (area default audience, per-route override, intent by group, + segments derived from patterns, extra segments, an area with no routes). + `test/shared/admin-pages.test.ts` and `test/features/admin/index.test.ts` now + mirror the sources they test, so each is mutation-tested against its own file. + The migration was also proved faithful once, by comparing every derived route, + segment list, and navigation order against the deleted tables. +- Slice 2: the manifest test's "every destination has a GET route" check + tightens — a declared destination without a wired handler fails at area + wiring, loudly. Unit tests cover the generators. +- Slice 3: **the backwards check is done.** + `test/integration/admin-role-matrix.test.ts` walks every parameter-free page + and asks it as all four roles: a role outside the audience is never served the + page, and a role inside it is never forbidden. It is written about being + served (200) and being forbidden (403) rather than one exact status, because a + page whose feature is switched off answers 404 to everybody, which is still a + refusal. It found the `deliveries` fault above, and fails with + `deliveries (/admin/deliveries) served agent` if that fix is reverted. +- Every slice runs `deno task precommit` and `deno task precommit:mutation`. + +## Open questions for the reviewer + +1. Is overruling "the stricter side wins" for `deliveries` the outcome you want? + The alternative is to take the run sheet away from delivery agents, which the + handler and its comment both argue against. +2. Slice 2 must decide whether an entity's detail base (`/admin/listing`) + becomes a declared fact, or stays a literal in the one route table that needs + it. Declaring it is tidier; it also adds a field only one area uses. +3. Slice 3's remaining half derives handler policies from the declaration. The + role matrix already proves the two agree, so this is now a simplification + rather than a safety fix. Is it worth the churn across the admin handlers? + +## The shortlist — other rooms the survey found + +Ranked runners-up, kept here so they are not lost. Each can become its own plan. +None of them blocks, or is blocked by, the admin surface work. + +1. **One client for outbound HTTP.** Twelve modules re-implement base URL + auth + header + status check + error parse + JSON parse (`bunny-cdn.ts` 670 lines, + `bunny-db.ts`, `turso-api.ts`, `deno-deploy-api.ts`, `sms/gateway.ts`, + `address-lookup/easypostcodes.ts`, `botpoison.ts`, `storage.ts`, `ntfy.ts`, + and the three payment transports). There is measured type-level drift: + `bunny-db.ts` returns `bunny-cdn.ts`'s foreign error shape through + `Result`-typed functions, and `bunny-cdn.ts:63,474` cast untrusted API + bodies with a bare `as T`. A `defineApiClient` that returns `Result` and + validates bodies through a supplied valibot schema removes an estimated + 400–600 lines. Deferred here because it grazes the payment transports while + PLAN.md M6–M11 is in flight. +2. **One admin page opener.** `src/ui/templates/admin/admin-page.tsx` holds 15 + opener variants (seven differ only in the flash argument shape), plus five + bespoke shells and three page-set factories — 23 ways to open a page over one + 23-line `AdminPage`. Fourteen list pages hand-compose the same empty-check → + table → guide footer → action row body. An `AdminPageSpec` schema plus one + renderer removes an estimated 500 lines and, more important, the choice that + regenerates the drift. +3. **`defineStatement` for hand-ordered SQL parameters.** The sweep answers the + open question in `TODO.md` ("Numbered SQL parameters", from PR #2040): 33 + statements across 26 files bind at least one value more than once, in two + dialects (repeated positional args, and `?N` kept honest by prose comments). + The largest are money-adjacent: 16 bound values in + `provider-refund-authority.ts:221`, 10 in `payment-anchor/attendee.ts:71`. A + schema-first statement definition removes the silent argument-order bug + class. +4. **A per-provider transport-error descriptor.** The three payment providers + classify the same three transport error classes twice each (once for + reads/refunds, once for checkout) — six hand-kept mappings, ~170 lines, + already drifted in three measurable ways. Two cheap adjacent fixes need no + design: Square's settings form re-implements `ProviderKeyBlock` inline + (`ui/templates/admin/settings/payment.tsx:254-278`), and Square's checkout + bypass of `makeCreateCheckoutSession` is recorded in `TODO.md` as a behaviour + divergence. diff --git a/TODO.md b/TODO.md index a6d4d2ef8c..3c04e98ef4 100644 --- a/TODO.md +++ b/TODO.md @@ -2639,3 +2639,30 @@ state nothing can currently produce. If a real schema change ever rebuilds `processed_payments` anyway, add this check in the same rebuild: the DDL belongs on the last column via the `alsoAbout` pattern in `src/shared/db/migrations/schema/payments/columns.ts`. + +--- + +## Square treats a malformed payment link as "provider not configured" + +_Origin: the 2026-08 refactor survey (ADMIN_SURFACE_PLAN.md)._ + +Stripe and SumUp build their checkout through `makeCreateCheckoutSession` +(`src/shared/payment-helpers.ts:483`). Its `requiredCheckoutResult` throws when +a non-null provider response lacks its session id or URL. Square opted out: +`squarePaymentProvider.createCheckoutSession` +(`src/shared/square-provider.ts:241-246`) reads the created payment link with +`toCheckoutResult(link?.orderId, link?.url, "Square")`, which logs and returns +`null` for the same condition. A `null` checkout result means "provider not +configured" to callers. So a Square payment link that arrives without its +`orderId` or `url` is reported as an unconfigured provider, where the identical +Stripe/SumUp condition raises loudly. That breaks the offensive-programming +rule: an absent expected field from structured external data must fail at its +boundary, not become a quiet default. The fix is to route Square through +`makeCreateCheckoutSession` (create = `squareApi.createPaymentLink`, readResult += `link => ({ id: link.orderId, url: link.url })`), keep null-link = +not-configured, and add a regression test in which a payment link arrives +without its URL and the checkout throws instead of a "not configured" answer. +This is a payment behaviour change, so it needs its own small PR with the test. +Starting points: `src/shared/square-provider.ts:241`, +`src/shared/payment-helpers.ts:455-501`, the Square checkout tests under +`test/`. diff --git a/scripts/mutation/equivalent-mutants/features.txt b/scripts/mutation/equivalent-mutants/features.txt index e511fb6d2a..ffe101e3ef 100644 --- a/scripts/mutation/equivalent-mutants/features.txt +++ b/scripts/mutation/equivalent-mutants/features.txt @@ -72,9 +72,8 @@ src/features/admin/attendees-merge.ts::toBookingChoice~033wh8z skip_source → src/shared/db/contact-tokens.ts::loadTokenBlob~0hgbweg ?? → || # loadTokenBlob's `row?.attendee_tokens_blob ?? null`: attendee_tokens_blob is string|undefined; the only falsy-non-null is "" and tokenLinesFrom("") === tokenLinesFrom(null) === [], so ?? and || agree src/shared/db/contact-tokens.ts::syncChannelToken~07hz79m ?? → || # `removedSource ?? sync.source`: removedSource is BookingSource|null, and both BookingSource values ("admin","public") are non-empty truthy strings, so it is never falsy-but-non-null; ?? and || agree -# Admin segment dispatch (admin/index.ts) — two provably-equivalent survivors -# from the routeAdmin/adminPathSegment run over the manifest + footer suites. -src/features/admin/index.ts::adminPathSegment~1hrcxzo ?? → || # `path.split("/")[2] ?? ""`: the RHS is "" and the only falsy string is "", so `x ?? ""` and `x || ""` agree on every string|undefined input +# Admin segment dispatch (admin/index.ts) — one provably-equivalent survivor +# from the routeAdmin run over the manifest + footer suites. src/features/admin/index.ts::buildSegmentRouters.areas~1hp6gg8 ?? → || # areasBySegment values are arrays, so every present value is truthy and a miss is undefined # Admin refund waves (refunds/waves.ts) — one provably-equivalent survivor from diff --git a/scripts/mutation/equivalent-mutants/shared-a-l.txt b/scripts/mutation/equivalent-mutants/shared-a-l.txt index e1536564ca..29c2545329 100644 --- a/scripts/mutation/equivalent-mutants/shared-a-l.txt +++ b/scripts/mutation/equivalent-mutants/shared-a-l.txt @@ -68,6 +68,9 @@ src/shared/bunny-cdn.ts::listEdgeScriptSecretsImpl.secrets~0cl5i4z ?? → || src/shared/subrequest-budget.ts::getSubrequestUsage~13c9nif ?? → || # scoped counts is an object when present, and every object is truthy src/shared/accounting/queries.ts::transferActivityBounds~0ioze8t || → && # transferActivityBounds: MIN(occurred_at) and MAX(occurred_at) over one table are NULL together (both iff the table is empty), so either-null and both-null coincide src/shared/admin-features.ts::featureBySlug~1rzgri8 ?? → || # find(): AdminFeatureDefinition|undefined; a feature object is always truthy +src/shared/admin-surface/definitions.ts::adminPathSegment~1hrcxzo ?? → || # `path.split("/")[2] ?? ""`: the RHS is "" and the only falsy string is "", so `x ?? ""` and `x || ""` agree on every string|undefined input +src/shared/admin-surface/definitions.ts::groupDestinations~1i8n5di ?? → || # `group ?? {}`: group is AdminRouteGroup|undefined, and an object is always truthy, so only undefined reaches the fallback +src/shared/admin-surface/definitions.ts::foldAdminAreas.declaredSegments~08mzyyu ?? → || # `area.segments ?? []`: segments is a readonly array when present, and arrays are always truthy, so only undefined reaches the fallback src/shared/db/admin-features.ts::requireSettingCondition.args~1i6rv6b __required_setting_condition__ → "" # the placeholder key is never stored: a failed condition always aborts on its NULL value src/shared/db/admin-features.ts::requireSettingCondition.args~1i6rv6b __required_setting_condition__ → "__required_setting_condition__ mutated" # same as above; no successful statement exposes this key src/shared/db/admin-features.ts::usageJsonEntry~0041kmq ?? → || # inUseSql: a non-empty SQL string|undefined, so only undefined reaches the fallback diff --git a/src/features/admin/area-loaders.ts b/src/features/admin/area-loaders.ts index 71de2e54a5..ac47324d6e 100644 --- a/src/features/admin/area-loaders.ts +++ b/src/features/admin/area-loaders.ts @@ -1,6 +1,6 @@ import { ADMIN_API_MESSAGE_GROUPS } from "#locales/groups.ts"; import { GUIDE_MESSAGE_GROUPS, type MessageGroup } from "#locales/manifest.ts"; -import type { AdminAreaId } from "#shared/admin-surface/definitions.ts"; +import type { AdminAreaId } from "#shared/admin-surface/ids.ts"; type HandlerMap = Record unknown>; diff --git a/src/features/admin/index.ts b/src/features/admin/index.ts index bc20c043ed..04510bdfe1 100644 --- a/src/features/admin/index.ts +++ b/src/features/admin/index.ts @@ -17,15 +17,12 @@ import { import { isJsonApiPath } from "#routes/middleware.ts"; import { createRouter } from "#routes/router.ts"; import type { PathMethodRoute } from "#routes/types.ts"; -import type { AdminAreaId } from "#shared/admin-surface/definitions.ts"; +import { adminPathSegment } from "#shared/admin-surface/definitions.ts"; +import type { AdminAreaId } from "#shared/admin-surface/ids.ts"; import { ADMIN_SURFACE } from "#shared/admin-surface.ts"; import { enableFooterDebug } from "#shared/db/query-log.ts"; import { isStaffRole } from "#shared/types.ts"; -/** The `/admin/` part of a path, or "" for `/admin`. */ -export const adminPathSegment = (path: string): string => - path.split("/")[2] ?? ""; - type AdminSegment = { load: () => Promise; messageGroups: readonly MessageGroup[]; diff --git a/src/shared/admin-pages.ts b/src/shared/admin-pages.ts index 76df904d69..4eef3c59c2 100644 --- a/src/shared/admin-pages.ts +++ b/src/shared/admin-pages.ts @@ -1,12 +1,9 @@ +import type { AdminSurfaceContext } from "#shared/admin-surface/definitions.ts"; import type { - AdminSectionId, - AdminSurfaceContext, -} from "#shared/admin-surface/definitions.ts"; -import { - ADMIN_SURFACE, - type AdminDestinationId, - adminDestination, -} from "#shared/admin-surface.ts"; + AdminNavEntry, + AdminSectionDef, +} from "#shared/admin-surface/sections.ts"; +import { ADMIN_SURFACE, adminDestination } from "#shared/admin-surface.ts"; import type { AdminLevel } from "#shared/types.ts"; export interface NavLink { @@ -20,36 +17,31 @@ export interface NavSection { readonly topHref: string; } -const landingPattern = ( - section: Pick<(typeof ADMIN_SURFACE.sections)[number], "landing">, -): string => adminDestination(section.landing as AdminDestinationId).pattern; - -const navRoutesFor = (section: AdminSectionId) => - ADMIN_SURFACE.destinations.filter( - (route) => route.section === section && route.nav !== undefined, - ); +const landingPattern = (section: AdminSectionDef): string => + adminDestination(section.landing).pattern; const sectionVisible = ( - section: (typeof ADMIN_SURFACE.sections)[number], + section: AdminSectionDef, + ctx: AdminSurfaceContext, +): boolean => + adminDestination(section.landing).audience.includes(ctx.adminLevel) && + (section.visible === undefined || section.visible(ctx)); + +const navEntryVisible = ( + entry: AdminNavEntry, ctx: AdminSurfaceContext, ): boolean => { - const landing = adminDestination(section.landing as AdminDestinationId); + const route = adminDestination(entry.id); return ( - landing.audience.includes(ctx.adminLevel) && - (!("visible" in section) || section.visible(ctx)) + route.audience.includes(ctx.adminLevel) && + !(ctx.isReadOnly && route.intent === "write-form") && + (entry.visible === undefined || entry.visible(ctx)) ); }; -const routeVisible = ( - route: (typeof ADMIN_SURFACE.destinations)[number], +const visibleAdminSections = ( ctx: AdminSurfaceContext, -): boolean => - route.nav !== undefined && - route.audience.includes(ctx.adminLevel) && - !(ctx.isReadOnly && route.intent === "write-form") && - (!("visible" in route.nav) || route.nav.visible(ctx)); - -const visibleAdminSections = (ctx: AdminSurfaceContext) => +): readonly AdminSectionDef[] => ADMIN_SURFACE.sections.filter((section) => sectionVisible(section, ctx)); export const visibleTopLevel = (ctx: AdminSurfaceContext): NavLink[] => @@ -60,15 +52,14 @@ export const visibleTopLevel = (ctx: AdminSurfaceContext): NavLink[] => export const visibleSections = (ctx: AdminSurfaceContext): NavSection[] => visibleAdminSections(ctx) - .map((section) => ({ routes: navRoutesFor(section.id), section })) - .filter(({ routes }) => routes.length > 1) - .map(({ routes, section }) => ({ - items: routes - .filter((route) => routeVisible(route, ctx)) - .map((route) => ({ - href: route.pattern, - // navRoutesFor keeps only destinations with navigation metadata. - labelKey: route.nav!.labelKey, + // A section with one link needs no sub-navigation of its own. + .filter((section) => section.nav.length > 1) + .map((section) => ({ + items: section.nav + .filter((entry) => navEntryVisible(entry, ctx)) + .map((entry) => ({ + href: adminDestination(entry.id).pattern, + labelKey: entry.labelKey, })), labelKey: section.labelKey, topHref: landingPattern(section), @@ -82,7 +73,7 @@ export const entityReturnPath = ( const section = ADMIN_SURFACE.sections.find( (candidate) => landingPattern(candidate) === sectionPath, ); - if (!section || !("detailPath" in section)) return sectionPath; + if (!section?.detailPath) return sectionPath; const detail = section.detailPath.replace(":id", String(id)); return section.staffOnlyDetail && adminLevel === "editor" ? `${detail}/edit` @@ -90,6 +81,6 @@ export const entityReturnPath = ( }; export const readOnlyGetRoutePatterns = (): readonly string[] => - ADMIN_SURFACE.destinations + Object.values(ADMIN_SURFACE.destinations) .filter((route) => route.intent === "write-form") .map((route) => route.pattern); diff --git a/src/shared/admin-surface.ts b/src/shared/admin-surface.ts index c14e472b63..d0796cc41f 100644 --- a/src/shared/admin-surface.ts +++ b/src/shared/admin-surface.ts @@ -1,33 +1,28 @@ +/** + * The admin surface every consumer reads: each route by id, and the segments + * each area serves. Both derive from the one declaration in + * `admin-surface/areas.ts`. + */ + +import { ADMIN_AREAS } from "#shared/admin-surface/areas.ts"; import { - ADMIN_SECTIONS, - ADMIN_SURFACE_AREAS, + type AdminDestinationDef, + foldAdminAreas, } from "#shared/admin-surface/definitions.ts"; -import { ADMIN_NAV_ROUTES } from "#shared/admin-surface/nav-routes.ts"; -import { ADMIN_WRITE_ROUTES_A_M } from "#shared/admin-surface/write-routes-a-m.ts"; -import { ADMIN_WRITE_ROUTES_N_Z } from "#shared/admin-surface/write-routes-n-z.ts"; -import type { RouteParamNames } from "#shared/route-pattern.ts"; +import type { + AdminDestinationId, + AdminPathParams, +} from "#shared/admin-surface/ids.ts"; +import { ADMIN_SECTIONS } from "#shared/admin-surface/sections.ts"; import type { AdminLevel } from "#shared/types.ts"; -const ADMIN_DESTINATIONS = [ - ...ADMIN_NAV_ROUTES, - ...ADMIN_WRITE_ROUTES_A_M, - ...ADMIN_WRITE_ROUTES_N_Z, -] as const; - -export type AdminDestinationId = (typeof ADMIN_DESTINATIONS)[number]["id"]; +export type { AdminDestinationId, AdminPathParams }; -type PathFor = Extract< - (typeof ADMIN_DESTINATIONS)[number], - { readonly id: Id } ->; -export type AdminPathParams = Record< - RouteParamNames["pattern"]>, - string | number ->; +const folded = foldAdminAreas(ADMIN_AREAS); -// AdminDestinationId is derived from this list, so the lookup cannot miss. -export const adminDestination = (id: AdminDestinationId) => - ADMIN_DESTINATIONS.find((candidate) => candidate.id === id)!; +/** The id type comes from the same table, so the lookup cannot miss. */ +export const adminDestination = (id: AdminDestinationId): AdminDestinationDef => + folded.destinations[id]!; export const adminPath = ( id: Id, @@ -35,7 +30,7 @@ export const adminPath = ( ): string => adminDestination(id).pattern.replace( /:(\w+)/g, - (_, name: RouteParamNames["pattern"]>) => String(params[name]), + (_, name: keyof AdminPathParams) => String(params[name]), ); export const adminDestinationAllowed = ( @@ -51,7 +46,7 @@ export const adminDestinationAllowed = ( }; export const ADMIN_SURFACE = { - areas: ADMIN_SURFACE_AREAS, - destinations: ADMIN_DESTINATIONS, + areas: folded.areas, + destinations: folded.destinations, sections: ADMIN_SECTIONS, } as const; diff --git a/src/shared/admin-surface/areas.ts b/src/shared/admin-surface/areas.ts new file mode 100644 index 0000000000..e315f9457e --- /dev/null +++ b/src/shared/admin-surface/areas.ts @@ -0,0 +1,397 @@ +/** + * Every admin area, and the routes it serves. + * + * This is the one place an admin route is declared. The nav that links to a + * route lives in `sections.ts`; the module that serves it lives in + * `src/features/admin/area-loaders.ts`, keyed by the same area names. + * + * An area names the role that reaches it once. A route names a role only when + * it differs from its area. `segments` lists a URL segment the area serves + * without a page of its own, such as a POST-only endpoint. + */ + +import type { AdminAreasSpec } from "#shared/admin-surface/definitions.ts"; +import { OWNER_AUDIENCE } from "#shared/admin-surface/definitions.ts"; +import { + CONTENT_ADMIN_LEVELS, + DELIVERY_ADMIN_LEVELS, + SITE_ADMIN_LEVELS, + STAFF_ADMIN_LEVELS, +} from "#shared/types.ts"; + +export const ADMIN_AREAS = { + apiKeys: { + audience: OWNER_AUDIENCE, + view: { + apiKeys: "/admin/api-keys", + }, + write: { + apiKeyDelete: "/admin/api-keys/:apiKeyId/delete", + }, + }, + attendeeNotes: { + audience: STAFF_ADMIN_LEVELS, + write: { + attendeeNote: "/admin/attendee/:attendeeId/note", + attendeeNoteDelete: "/admin/attendee/:attendeeId/note/:noteId/delete", + }, + }, + attendeeRefunds: { + audience: OWNER_AUDIENCE, + write: { + attendeeRefund: "/admin/attendees/:attendeeId/refund", + listingRefundAll: "/admin/listing/:id/refund-all", + }, + }, + attendees: { + audience: STAFF_ADMIN_LEVELS, + segments: ["listing"], + view: { + attendees: "/admin/attendees", + }, + write: { + attendeeActions: "/admin/attendees/:attendeeId/actions", + attendeeDelete: "/admin/attendees/:attendeeId/delete", + attendeeEdit: "/admin/attendees/:attendeeId/edit", + attendeeLogistics: "/admin/attendees/:attendeeId/logistics", + attendeeNew: "/admin/attendees/new", + attendeePaymentReview: { + audience: OWNER_AUDIENCE, + pattern: "/admin/attendees/:attendeeId/payment-review", + }, + attendeeResend: "/admin/attendees/:attendeeId/resend-notification", + }, + }, + attributes: { + audience: OWNER_AUDIENCE, + segments: ["listing"], + view: { + attributes: "/admin/attributes", + }, + write: { + attributeDelete: "/admin/attributes/:id/delete", + attributeOptionDelete: "/admin/attributes/:id/options/:optionId/delete", + attributeOptionEdit: "/admin/attributes/:id/options/:optionId/edit", + }, + }, + auth: { + segments: ["login", "logout"], + }, + backup: { + audience: OWNER_AUDIENCE, + view: { + backup: "/admin/backup", + }, + }, + builder: { + segments: ["builder"], + }, + builtSites: { + audience: OWNER_AUDIENCE, + view: { + builtSites: "/admin/built-sites", + }, + write: { + builtSiteDelete: "/admin/built-sites/:id/delete", + builtSiteEdit: "/admin/built-sites/:id/edit", + builtSiteNew: "/admin/built-sites/new", + }, + }, + bulkActions: { + audience: STAFF_ADMIN_LEVELS, + write: { + bulkActions: "/admin/groups/:id/bulk-actions", + bulkDeactivate: "/admin/groups/:id/bulk-actions/deactivate", + bulkDuplicate: "/admin/groups/:id/bulk-actions/duplicate", + bulkReactivate: "/admin/groups/:id/bulk-actions/reactivate", + }, + }, + bulkEmail: { + audience: OWNER_AUDIENCE, + view: { + emails: "/admin/emails", + }, + write: { + emailTemplateDelete: "/admin/emails/templates/:id/delete", + }, + }, + calendar: { + audience: STAFF_ADMIN_LEVELS, + view: { + calendar: "/admin/calendar", + }, + }, + catalogTransfer: { + audience: CONTENT_ADMIN_LEVELS, + segments: ["groups", "listing"], + write: { + catalogImport: "/admin/catalog/import", + }, + }, + contactHistory: { + segments: ["history"], + }, + dashboard: { + audience: STAFF_ADMIN_LEVELS, + segments: ["log"], + view: { + home: "/admin/", + listings: { audience: CONTENT_ADMIN_LEVELS, pattern: "/admin/listings" }, + }, + }, + debug: { + audience: OWNER_AUDIENCE, + view: { + debug: "/admin/debug", + }, + }, + deliveries: { + // The run sheet is a delivery agent's only page, so it admits agents as + // well as staff — the same roles `deliveryPage` lets through. + audience: DELIVERY_ADMIN_LEVELS, + view: { + deliveries: "/admin/deliveries", + }, + }, + groups: { + audience: CONTENT_ADMIN_LEVELS, + view: { + groups: "/admin/groups", + }, + write: { + groupDelete: { + audience: STAFF_ADMIN_LEVELS, + pattern: "/admin/groups/:id/delete", + }, + groupEdit: "/admin/groups/:id/edit", + groupImages: "/admin/groups/:id/images", + groupNew: "/admin/groups/new", + }, + }, + guide: { + segments: ["formatting", "guide"], + }, + holidays: { + audience: OWNER_AUDIENCE, + view: { + holidays: "/admin/holidays", + }, + write: { + holidayDelete: "/admin/holidays/:id/delete", + holidayEdit: "/admin/holidays/:id/edit", + holidayNew: "/admin/holidays/new", + }, + }, + images: { + audience: CONTENT_ADMIN_LEVELS, + view: { + images: "/admin/images", + }, + write: { + imageDelete: "/admin/images/:id/delete", + imageEdit: "/admin/images/:id/edit", + imageNew: "/admin/images/new", + }, + }, + ledger: { + audience: OWNER_AUDIENCE, + view: { + ledger: "/admin/ledger", + }, + write: { + ledgerAdd: "/admin/ledger/:type/:ref/add", + ledgerEdit: "/admin/ledger/entries/:id/edit", + }, + }, + listingQr: { + segments: ["listing"], + }, + listings: { + audience: STAFF_ADMIN_LEVELS, + write: { + listingAttributes: { + audience: OWNER_AUDIENCE, + pattern: "/admin/listing/:id/attributes", + }, + listingDeactivate: "/admin/listing/:id/deactivate", + listingDelete: "/admin/listing/:id/delete", + listingDuplicate: { + audience: CONTENT_ADMIN_LEVELS, + pattern: "/admin/listing/:id/duplicate", + }, + listingEdit: { + audience: CONTENT_ADMIN_LEVELS, + pattern: "/admin/listing/:id/edit", + }, + listingImages: { + audience: CONTENT_ADMIN_LEVELS, + pattern: "/admin/listing/:id/images", + }, + listingNew: { + audience: CONTENT_ADMIN_LEVELS, + pattern: "/admin/listing/new", + }, + listingQr: "/admin/listing/:id/qr", + listingQuestions: { + audience: OWNER_AUDIENCE, + pattern: "/admin/listing/:id/questions", + }, + listingReactivate: "/admin/listing/:id/reactivate", + listingRecalculate: "/admin/listings/recalculate/:listingId", + }, + }, + markdownPreview: { + segments: ["markdown-preview"], + }, + modifiers: { + audience: STAFF_ADMIN_LEVELS, + view: { + modifiers: "/admin/modifiers", + }, + write: { + modifierDelete: "/admin/modifiers/:id/delete", + modifierEdit: "/admin/modifiers/:id/edit", + modifierNew: "/admin/modifiers/new", + modifierRecalculate: "/admin/modifiers/recalculate/:modifierId", + }, + }, + news: { + audience: SITE_ADMIN_LEVELS, + view: { + news: "/admin/site/news", + }, + write: { + newsActions: "/admin/site/news/:id/actions", + newsDelete: "/admin/site/news/:id/delete", + newsEdit: "/admin/site/news/:id/edit", + newsImages: "/admin/site/news/:id/images", + newsNew: "/admin/site/news/new", + }, + }, + privacy: { + audience: OWNER_AUDIENCE, + view: { + privacy: "/admin/privacy", + }, + }, + questions: { + audience: OWNER_AUDIENCE, + segments: ["listing"], + view: { + questions: "/admin/questions", + }, + write: { + answerDelete: "/admin/questions/:id/answers/:answerId/delete", + answerEdit: "/admin/questions/:id/answers/:answerId/edit", + answerRecalculate: "/admin/questions/:id/answers/:answerId/recalculate", + questionDelete: "/admin/questions/:id/delete", + }, + }, + scanner: { + segments: ["listing"], + }, + schemaAtlas: { + audience: OWNER_AUDIENCE, + view: { + schemaAtlas: "/admin/schema", + }, + }, + seeds: { + segments: ["seeds"], + }, + servicing: { + audience: STAFF_ADMIN_LEVELS, + view: { + servicing: "/admin/servicing", + }, + write: { + servicingEdit: "/admin/servicing/:id", + servicingNew: "/admin/servicing/new", + }, + }, + sessions: { + audience: OWNER_AUDIENCE, + view: { + sessions: "/admin/sessions", + }, + }, + settings: { + audience: OWNER_AUDIENCE, + segments: ["features"], + view: { + listingDefaults: "/admin/listing-defaults", + settings: "/admin/settings", + settingsAdvanced: "/admin/settings-advanced", + }, + }, + settingsLogistics: { + audience: OWNER_AUDIENCE, + view: { + logistics: "/admin/logistics", + }, + write: { + logisticsDelete: "/admin/logistics/:id/delete", + logisticsEdit: "/admin/logistics/:id/edit", + logisticsNew: "/admin/logistics/new", + }, + }, + settingsStatuses: { + audience: OWNER_AUDIENCE, + view: { + statuses: "/admin/settings/statuses", + }, + write: { + statusDelete: "/admin/settings/statuses/:id/delete", + statusEdit: "/admin/settings/statuses/:id/edit", + statusNew: "/admin/settings/statuses/new", + }, + }, + site: { + audience: SITE_ADMIN_LEVELS, + view: { + site: "/admin/site", + siteContact: "/admin/site/contact", + siteOrder: "/admin/site/order", + }, + }, + sitePages: { + audience: SITE_ADMIN_LEVELS, + view: { + sitePages: "/admin/site/pages", + }, + write: { + sitePageActions: "/admin/site/pages/:id/actions", + sitePageDelete: "/admin/site/pages/:id/delete", + sitePageEdit: "/admin/site/pages/:id/edit", + sitePageImages: "/admin/site/pages/:id/images", + sitePageItems: "/admin/site/pages/:id/items", + sitePageNew: "/admin/site/pages/new", + }, + }, + sms: { + segments: ["sms"], + }, + support: { + audience: OWNER_AUDIENCE, + view: { + support: "/admin/support", + }, + }, + update: { + audience: OWNER_AUDIENCE, + view: { + update: "/admin/update", + }, + }, + users: { + audience: OWNER_AUDIENCE, + view: { + users: "/admin/users", + }, + write: { + userAgents: "/admin/users/:id/agents", + userDelete: "/admin/users/:id/delete", + userNew: "/admin/user/new", + }, + }, +} as const satisfies AdminAreasSpec; diff --git a/src/shared/admin-surface/definitions.ts b/src/shared/admin-surface/definitions.ts index 46d8320ad0..c66beb9956 100644 --- a/src/shared/admin-surface/definitions.ts +++ b/src/shared/admin-surface/definitions.ts @@ -1,3 +1,13 @@ +/** + * The shape of the admin surface declaration, and the fold that turns it into + * the flat destination and segment maps every consumer reads. + * + * One area declares its own routes once. Its audience is the area default, so + * a route only names a role when it differs from the rest of its area. The + * segments an area serves derive from the patterns it declares; `segments` + * lists only the extra ones an area serves without a page of its own. + */ + import type { EnabledFeatures } from "#shared/admin-features.ts"; import type { AdminLevel } from "#shared/types.ts"; @@ -11,53 +21,6 @@ export interface AdminSurfaceContext { readonly support: boolean; } -export const ADMIN_SURFACE_AREAS = { - apiKeys: ["api-keys"], - attendeeNotes: ["attendee"], - attendeeRefunds: ["attendees", "listing"], - attendees: ["attendees", "listing"], - attributes: ["attributes", "listing"], - auth: ["login", "logout"], - backup: ["backup"], - builder: ["builder"], - builtSites: ["built-sites"], - bulkActions: ["groups"], - bulkEmail: ["emails"], - calendar: ["calendar"], - catalogTransfer: ["catalog", "groups", "listing"], - contactHistory: ["history"], - dashboard: ["", "listings", "log"], - debug: ["debug"], - deliveries: ["deliveries"], - groups: ["groups"], - guide: ["formatting", "guide"], - holidays: ["holidays"], - images: ["images"], - ledger: ["ledger"], - listingQr: ["listing"], - listings: ["listing", "listings"], - markdownPreview: ["markdown-preview"], - modifiers: ["modifiers"], - news: ["site"], - privacy: ["privacy"], - questions: ["listing", "questions"], - scanner: ["listing"], - schemaAtlas: ["schema"], - seeds: ["seeds"], - servicing: ["servicing"], - sessions: ["sessions"], - settings: ["features", "listing-defaults", "settings", "settings-advanced"], - settingsLogistics: ["logistics"], - settingsStatuses: ["settings"], - site: ["site"], - sitePages: ["site"], - sms: ["sms"], - support: ["support"], - update: ["update"], - users: ["user", "users"], -} as const; - -export type AdminAreaId = keyof typeof ADMIN_SURFACE_AREAS; export type AdminAudience = readonly AdminLevel[]; export type AdminRouteIntent = "view" | "write-form"; export type AdminNavKind = "landing" | "link" | "create" | "import"; @@ -69,109 +32,95 @@ export const featureVisible = (ctx: AdminSurfaceContext): boolean => ctx.enabledFeatures[feature]; -export const ADMIN_SECTIONS = [ - { id: "home", labelKey: "nav.public.home", landing: "home" }, - { - detailPath: "/admin/listing/:id", - id: "listings", - labelKey: "terms.listings", - landing: "listings", - staffOnlyDetail: true, - }, - { id: "calendar", labelKey: "nav.calendar", landing: "calendar" }, - { - id: "servicing", - labelKey: "nav.servicing", - landing: "servicing", - visible: featureVisible("servicing"), - }, - { id: "attendees", labelKey: "terms.attendees", landing: "attendees" }, - { id: "users", labelKey: "terms.users", landing: "users" }, - { - detailPath: "/admin/groups/:id", - id: "groups", - labelKey: "terms.groups", - landing: "groups", - staffOnlyDetail: true, - }, - { - id: "images", - labelKey: "terms.images", - landing: "images", - visible: (ctx: AdminSurfaceContext) => ctx.storage, - }, - { - id: "modifiers", - labelKey: "terms.modifiers", - landing: "modifiers", - visible: featureVisible("modifiers"), - }, - { - id: "ledger", - labelKey: "nav.ledger", - landing: "ledger", - visible: featureVisible("money"), - }, - { - id: "site", - labelKey: "nav.site", - landing: "site", - visible: featureVisible("site"), - }, - { id: "settings", labelKey: "nav.settings", landing: "settings" }, -] as const; - -export type AdminSectionId = (typeof ADMIN_SECTIONS)[number]["id"]; +/** A route: its pattern alone, or a pattern whose role differs from its area. */ +export type AdminDestinationSpec = + | string + | { readonly audience: AdminAudience; readonly pattern: string }; + +type AdminRouteGroup = Readonly>; + +/** An area serving pages. Declaring a route requires declaring who reaches it. */ +type AdminAreaWithRoutes = { + readonly audience: AdminAudience; + readonly segments?: readonly string[]; + readonly view?: AdminRouteGroup; + readonly write?: AdminRouteGroup; +}; + +/** An area whose routes have no page of their own, such as a POST endpoint. */ +type AdminAreaWithoutRoutes = { readonly segments: readonly string[] }; + +export type AdminAreaSpec = AdminAreaWithRoutes | AdminAreaWithoutRoutes; +export type AdminAreasSpec = Readonly>; export type AdminDestinationDef = { - readonly area: AdminAreaId; + readonly area: string; readonly audience: AdminAudience; readonly id: string; readonly intent: AdminRouteIntent; - readonly nav?: { - readonly kind: AdminNavKind; - readonly labelKey: string; - readonly visible?: (ctx: AdminSurfaceContext) => boolean; - }; readonly pattern: string; - readonly section: AdminSectionId; }; -const defineDestination = - (intent: AdminRouteIntent) => - ( - id: Id, - area: AdminAreaId, - pattern: Pattern, - audience: AdminAudience, - section: AdminSectionId, - nav?: AdminDestinationDef["nav"], - ): AdminDestinationDef & { readonly id: Id; readonly pattern: Pattern } => ({ +/** The `/admin/` part of a path, or "" for `/admin` itself. */ +export const adminPathSegment = (path: string): string => + path.split("/")[2] ?? ""; + +const groupDestinations = ( + area: string, + areaAudience: AdminAudience, + intent: AdminRouteIntent, + group: AdminRouteGroup | undefined, +): AdminDestinationDef[] => + Object.entries(group ?? {}).map(([id, spec]) => ({ area, - audience, + audience: typeof spec === "string" ? areaAudience : spec.audience, id, intent, - pattern, - section, - ...(nav ? { nav } : {}), - }); - -export const view = ( - id: Id, - area: AdminAreaId, - section: AdminSectionId, - pattern: Pattern, - audience: AdminAudience, - labelKey: string, - kind: AdminNavKind = "link", - visible?: (ctx: AdminSurfaceContext) => boolean, -): AdminDestinationDef & { readonly id: Id; readonly pattern: Pattern } => - defineDestination( - kind === "create" || kind === "import" ? "write-form" : "view", - )(id, area, pattern, audience, section, { - kind, - labelKey, - ...(visible ? { visible } : {}), - }); - -export const writeForm = defineDestination("write-form"); + pattern: typeof spec === "string" ? spec : spec.pattern, + })); + +export type FoldedAdminSurface = { + readonly areas: Readonly>; + readonly destinations: Readonly>; +}; + +/** + * Fold the declaration into the flat maps consumers read: every destination by + * id, and every area's segments. Runs once at module load over pure data. + */ +export const foldAdminAreas = (spec: AdminAreasSpec): FoldedAdminSurface => { + const areas: Record = {}; + const destinations: Record = {}; + + for (const [areaId, area] of Object.entries(spec)) { + const declaredSegments = area.segments ?? []; + if (!("audience" in area)) { + areas[areaId] = declaredSegments; + continue; + } + const areaDestinations = [ + ...groupDestinations(areaId, area.audience, "view", area.view), + ...groupDestinations(areaId, area.audience, "write-form", area.write), + ]; + for (const destination of areaDestinations) { + // Two areas claiming one id would leave the loser silently unreachable, + // and every link to it pointing at the winner. + const claimed = destinations[destination.id]; + if (claimed) { + throw new Error( + `Admin route "${destination.id}" is declared by both ` + + `"${claimed.area}" and "${destination.area}"`, + ); + } + destinations[destination.id] = destination; + } + areas[areaId] = [ + ...new Set([ + ...areaDestinations.map((one) => adminPathSegment(one.pattern)), + ...declaredSegments, + ]), + ]; + } + + return { areas, destinations }; +}; diff --git a/src/shared/admin-surface/ids.ts b/src/shared/admin-surface/ids.ts new file mode 100644 index 0000000000..24bd0bd8fd --- /dev/null +++ b/src/shared/admin-surface/ids.ts @@ -0,0 +1,49 @@ +/** + * The destination ids and path patterns, read back out of the areas table. + * + * Every id and pattern in the system comes from here, so a link built with + * `adminPath` names a route that exists and fills the parameters that route + * actually has. + */ + +import type { ADMIN_AREAS } from "#shared/admin-surface/areas.ts"; +import type { RouteParamNames } from "#shared/route-pattern.ts"; + +type Areas = typeof ADMIN_AREAS; + +type ViewsOf = Area extends { readonly view: infer Group } + ? Group + : Record; +type WritesOf = Area extends { readonly write: infer Group } + ? Group + : Record; + +export type AdminAreaId = keyof Areas; + +export type AdminDestinationId = { + [Area in keyof Areas]: + | keyof ViewsOf + | keyof WritesOf; +}[keyof Areas] & + string; + +/** The one area entry that declares this id, whichever group it sits in. */ +type SpecFor = { + [Area in keyof Areas]: Id extends keyof ViewsOf + ? ViewsOf[Id] + : Id extends keyof WritesOf + ? WritesOf[Id] + : never; +}[keyof Areas]; + +type PatternFor = + SpecFor extends string + ? SpecFor + : SpecFor extends { readonly pattern: infer Pattern } + ? Pattern + : never; + +export type AdminPathParams = Record< + RouteParamNames & string>, + string | number +>; diff --git a/src/shared/admin-surface/nav-routes.ts b/src/shared/admin-surface/nav-routes.ts deleted file mode 100644 index cd22cdeb04..0000000000 --- a/src/shared/admin-surface/nav-routes.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { - featureVisible, - OWNER_AUDIENCE, - view, -} from "#shared/admin-surface/definitions.ts"; -import { - CONTENT_ADMIN_LEVELS, - SITE_ADMIN_LEVELS, - STAFF_ADMIN_LEVELS, -} from "#shared/types.ts"; - -export const ADMIN_NAV_ROUTES = [ - view( - "home", - "dashboard", - "home", - "/admin/", - STAFF_ADMIN_LEVELS, - "nav.public.home", - "landing", - ), - view( - "listings", - "dashboard", - "listings", - "/admin/listings", - CONTENT_ADMIN_LEVELS, - "terms.listings", - "landing", - ), - view( - "listingNew", - "listings", - "listings", - "/admin/listing/new", - CONTENT_ADMIN_LEVELS, - "nav.sub.add", - "create", - ), - view( - "catalogImport", - "catalogTransfer", - "listings", - "/admin/catalog/import", - CONTENT_ADMIN_LEVELS, - "nav.sub.import", - "import", - ), - view( - "calendar", - "calendar", - "calendar", - "/admin/calendar", - STAFF_ADMIN_LEVELS, - "nav.calendar", - "landing", - ), - view( - "deliveries", - "deliveries", - "calendar", - "/admin/deliveries", - STAFF_ADMIN_LEVELS, - "nav.deliveries", - "link", - featureVisible("logistics"), - ), - view( - "servicing", - "servicing", - "servicing", - "/admin/servicing", - STAFF_ADMIN_LEVELS, - "nav.servicing", - "landing", - ), - view( - "servicingNew", - "servicing", - "servicing", - "/admin/servicing/new", - STAFF_ADMIN_LEVELS, - "nav.sub.add", - "create", - ), - view( - "attendees", - "attendees", - "attendees", - "/admin/attendees", - STAFF_ADMIN_LEVELS, - "terms.attendees", - "landing", - ), - view( - "attendeeNew", - "attendees", - "attendees", - "/admin/attendees/new", - STAFF_ADMIN_LEVELS, - "nav.sub.add", - "create", - ), - view( - "users", - "users", - "users", - "/admin/users", - OWNER_AUDIENCE, - "terms.users", - "landing", - ), - view( - "userNew", - "users", - "users", - "/admin/user/new", - OWNER_AUDIENCE, - "nav.sub.invite", - "create", - ), - view( - "sessions", - "sessions", - "users", - "/admin/sessions", - OWNER_AUDIENCE, - "nav.sub.sessions", - ), - view( - "apiKeys", - "apiKeys", - "users", - "/admin/api-keys", - OWNER_AUDIENCE, - "nav.sub.api_keys", - "link", - featureVisible("apiKeys"), - ), - view( - "groups", - "groups", - "groups", - "/admin/groups", - CONTENT_ADMIN_LEVELS, - "terms.groups", - "landing", - ), - view( - "groupNew", - "groups", - "groups", - "/admin/groups/new", - CONTENT_ADMIN_LEVELS, - "nav.sub.add", - "create", - ), - view( - "images", - "images", - "images", - "/admin/images", - CONTENT_ADMIN_LEVELS, - "terms.images", - "landing", - ), - view( - "imageNew", - "images", - "images", - "/admin/images/new", - CONTENT_ADMIN_LEVELS, - "nav.sub.add", - "create", - ), - view( - "modifiers", - "modifiers", - "modifiers", - "/admin/modifiers", - STAFF_ADMIN_LEVELS, - "terms.modifiers", - "landing", - ), - view( - "modifierNew", - "modifiers", - "modifiers", - "/admin/modifiers/new", - STAFF_ADMIN_LEVELS, - "nav.sub.add", - "create", - ), - view( - "ledger", - "ledger", - "ledger", - "/admin/ledger", - OWNER_AUDIENCE, - "nav.ledger", - "landing", - ), - view( - "site", - "site", - "site", - "/admin/site", - SITE_ADMIN_LEVELS, - "site.sub_nav.homepage", - "landing", - ), - view( - "siteContact", - "site", - "site", - "/admin/site/contact", - SITE_ADMIN_LEVELS, - "site.sub_nav.contact", - ), - view( - "siteOrder", - "site", - "site", - "/admin/site/order", - SITE_ADMIN_LEVELS, - "site.sub_nav.order", - ), - view( - "sitePages", - "sitePages", - "site", - "/admin/site/pages", - SITE_ADMIN_LEVELS, - "nav.site.pages", - ), - view( - "news", - "news", - "site", - "/admin/site/news", - SITE_ADMIN_LEVELS, - "nav.site.news", - ), - view( - "settings", - "settings", - "settings", - "/admin/settings", - OWNER_AUDIENCE, - "nav.sub.settings", - "landing", - ), - view( - "listingDefaults", - "settings", - "settings", - "/admin/listing-defaults", - OWNER_AUDIENCE, - "nav.sub.listing_defaults", - ), - view( - "statuses", - "settingsStatuses", - "settings", - "/admin/settings/statuses", - OWNER_AUDIENCE, - "nav.sub.statuses", - ), - view( - "privacy", - "privacy", - "settings", - "/admin/privacy", - OWNER_AUDIENCE, - "nav.sub.privacy", - ), - view( - "attributes", - "attributes", - "settings", - "/admin/attributes", - OWNER_AUDIENCE, - "terms.attributes", - "link", - featureVisible("attributes"), - ), - view( - "questions", - "questions", - "settings", - "/admin/questions", - OWNER_AUDIENCE, - "terms.questions", - "link", - featureVisible("questions"), - ), - view( - "logistics", - "settingsLogistics", - "settings", - "/admin/logistics", - OWNER_AUDIENCE, - "nav.logistics", - "link", - featureVisible("logistics"), - ), - view( - "emails", - "bulkEmail", - "settings", - "/admin/emails", - OWNER_AUDIENCE, - "nav.emails", - ), - view( - "holidays", - "holidays", - "settings", - "/admin/holidays", - OWNER_AUDIENCE, - "terms.holidays", - ), - view( - "builtSites", - "builtSites", - "settings", - "/admin/built-sites", - OWNER_AUDIENCE, - "nav.built_sites", - "link", - (ctx) => ctx.builder, - ), - view( - "settingsAdvanced", - "settings", - "settings", - "/admin/settings-advanced", - OWNER_AUDIENCE, - "nav.sub.advanced", - ), - view( - "backup", - "backup", - "settings", - "/admin/backup", - OWNER_AUDIENCE, - "nav.sub.backups", - ), - view( - "update", - "update", - "settings", - "/admin/update", - OWNER_AUDIENCE, - "nav.sub.updates", - ), - view( - "debug", - "debug", - "settings", - "/admin/debug", - OWNER_AUDIENCE, - "nav.sub.debug", - ), - view( - "schemaAtlas", - "schemaAtlas", - "settings", - "/admin/schema", - OWNER_AUDIENCE, - "nav.sub.schema", - ), - view( - "support", - "support", - "settings", - "/admin/support", - OWNER_AUDIENCE, - "nav.support", - "link", - (ctx) => ctx.support, - ), -] as const; diff --git a/src/shared/admin-surface/sections.ts b/src/shared/admin-surface/sections.ts new file mode 100644 index 0000000000..b92bdc6bfc --- /dev/null +++ b/src/shared/admin-surface/sections.ts @@ -0,0 +1,205 @@ +/** + * The admin navigation: which sections the sidebar shows, and which routes sit + * under each one, in the order a reader sees them. + * + * A section names its routes by id. The route itself — its pattern and the + * role that reaches it — is declared once in `areas.ts`, so a link here can + * never point somewhere its target refuses. + */ + +import { + type AdminNavKind, + type AdminSurfaceContext, + featureVisible, +} from "#shared/admin-surface/definitions.ts"; +import type { AdminDestinationId } from "#shared/admin-surface/ids.ts"; + +export type AdminNavEntry = { + readonly id: AdminDestinationId; + readonly kind: AdminNavKind; + readonly labelKey: string; + readonly visible?: (ctx: AdminSurfaceContext) => boolean; +}; + +export type AdminSectionDef = { + readonly detailPath?: string; + readonly id: string; + readonly labelKey: string; + readonly landing: AdminDestinationId; + readonly nav: readonly AdminNavEntry[]; + readonly staffOnlyDetail?: boolean; + readonly visible?: (ctx: AdminSurfaceContext) => boolean; +}; + +export const ADMIN_SECTIONS: readonly AdminSectionDef[] = [ + { + id: "home", + labelKey: "nav.public.home", + landing: "home", + nav: [{ id: "home", kind: "landing", labelKey: "nav.public.home" }], + }, + { + detailPath: "/admin/listing/:id", + id: "listings", + labelKey: "terms.listings", + landing: "listings", + nav: [ + { id: "listings", kind: "landing", labelKey: "terms.listings" }, + { id: "listingNew", kind: "create", labelKey: "nav.sub.add" }, + { id: "catalogImport", kind: "import", labelKey: "nav.sub.import" }, + ], + staffOnlyDetail: true, + }, + { + id: "calendar", + labelKey: "nav.calendar", + landing: "calendar", + nav: [ + { id: "calendar", kind: "landing", labelKey: "nav.calendar" }, + { + id: "deliveries", + kind: "link", + labelKey: "nav.deliveries", + visible: featureVisible("logistics"), + }, + ], + }, + { + id: "servicing", + labelKey: "nav.servicing", + landing: "servicing", + nav: [ + { id: "servicing", kind: "landing", labelKey: "nav.servicing" }, + { id: "servicingNew", kind: "create", labelKey: "nav.sub.add" }, + ], + visible: featureVisible("servicing"), + }, + { + id: "attendees", + labelKey: "terms.attendees", + landing: "attendees", + nav: [ + { id: "attendees", kind: "landing", labelKey: "terms.attendees" }, + { id: "attendeeNew", kind: "create", labelKey: "nav.sub.add" }, + ], + }, + { + id: "users", + labelKey: "terms.users", + landing: "users", + nav: [ + { id: "users", kind: "landing", labelKey: "terms.users" }, + { id: "userNew", kind: "create", labelKey: "nav.sub.invite" }, + { id: "sessions", kind: "link", labelKey: "nav.sub.sessions" }, + { + id: "apiKeys", + kind: "link", + labelKey: "nav.sub.api_keys", + visible: featureVisible("apiKeys"), + }, + ], + }, + { + detailPath: "/admin/groups/:id", + id: "groups", + labelKey: "terms.groups", + landing: "groups", + nav: [ + { id: "groups", kind: "landing", labelKey: "terms.groups" }, + { id: "groupNew", kind: "create", labelKey: "nav.sub.add" }, + ], + staffOnlyDetail: true, + }, + { + id: "images", + labelKey: "terms.images", + landing: "images", + nav: [ + { id: "images", kind: "landing", labelKey: "terms.images" }, + { id: "imageNew", kind: "create", labelKey: "nav.sub.add" }, + ], + visible: (ctx) => ctx.storage, + }, + { + id: "modifiers", + labelKey: "terms.modifiers", + landing: "modifiers", + nav: [ + { id: "modifiers", kind: "landing", labelKey: "terms.modifiers" }, + { id: "modifierNew", kind: "create", labelKey: "nav.sub.add" }, + ], + visible: featureVisible("modifiers"), + }, + { + id: "ledger", + labelKey: "nav.ledger", + landing: "ledger", + nav: [{ id: "ledger", kind: "landing", labelKey: "nav.ledger" }], + visible: featureVisible("money"), + }, + { + id: "site", + labelKey: "nav.site", + landing: "site", + nav: [ + { id: "site", kind: "landing", labelKey: "site.sub_nav.homepage" }, + { id: "siteContact", kind: "link", labelKey: "site.sub_nav.contact" }, + { id: "siteOrder", kind: "link", labelKey: "site.sub_nav.order" }, + { id: "sitePages", kind: "link", labelKey: "nav.site.pages" }, + { id: "news", kind: "link", labelKey: "nav.site.news" }, + ], + visible: featureVisible("site"), + }, + { + id: "settings", + labelKey: "nav.settings", + landing: "settings", + nav: [ + { id: "settings", kind: "landing", labelKey: "nav.sub.settings" }, + { + id: "listingDefaults", + kind: "link", + labelKey: "nav.sub.listing_defaults", + }, + { id: "statuses", kind: "link", labelKey: "nav.sub.statuses" }, + { id: "privacy", kind: "link", labelKey: "nav.sub.privacy" }, + { + id: "attributes", + kind: "link", + labelKey: "terms.attributes", + visible: featureVisible("attributes"), + }, + { + id: "questions", + kind: "link", + labelKey: "terms.questions", + visible: featureVisible("questions"), + }, + { + id: "logistics", + kind: "link", + labelKey: "nav.logistics", + visible: featureVisible("logistics"), + }, + { id: "emails", kind: "link", labelKey: "nav.emails" }, + { id: "holidays", kind: "link", labelKey: "terms.holidays" }, + { + id: "builtSites", + kind: "link", + labelKey: "nav.built_sites", + visible: (ctx) => ctx.builder, + }, + { id: "settingsAdvanced", kind: "link", labelKey: "nav.sub.advanced" }, + { id: "backup", kind: "link", labelKey: "nav.sub.backups" }, + { id: "update", kind: "link", labelKey: "nav.sub.updates" }, + { id: "debug", kind: "link", labelKey: "nav.sub.debug" }, + { id: "schemaAtlas", kind: "link", labelKey: "nav.sub.schema" }, + { + id: "support", + kind: "link", + labelKey: "nav.support", + visible: (ctx) => ctx.support, + }, + ], + }, +]; diff --git a/src/shared/admin-surface/write-routes-a-m.ts b/src/shared/admin-surface/write-routes-a-m.ts deleted file mode 100644 index f77ef1d291..0000000000 --- a/src/shared/admin-surface/write-routes-a-m.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { - OWNER_AUDIENCE, - writeForm, -} from "#shared/admin-surface/definitions.ts"; -import { CONTENT_ADMIN_LEVELS, STAFF_ADMIN_LEVELS } from "#shared/types.ts"; - -export const ADMIN_WRITE_ROUTES_A_M = [ - writeForm( - "apiKeyDelete", - "apiKeys", - "/admin/api-keys/:apiKeyId/delete", - OWNER_AUDIENCE, - "users", - ), - writeForm( - "attendeeNote", - "attendeeNotes", - "/admin/attendee/:attendeeId/note", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeNoteDelete", - "attendeeNotes", - "/admin/attendee/:attendeeId/note/:noteId/delete", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeEdit", - "attendees", - "/admin/attendees/:attendeeId/edit", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeLogistics", - "attendees", - "/admin/attendees/:attendeeId/logistics", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeActions", - "attendees", - "/admin/attendees/:attendeeId/actions", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeDelete", - "attendees", - "/admin/attendees/:attendeeId/delete", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeResend", - "attendees", - "/admin/attendees/:attendeeId/resend-notification", - STAFF_ADMIN_LEVELS, - "attendees", - ), - writeForm( - "attendeeRefund", - "attendeeRefunds", - "/admin/attendees/:attendeeId/refund", - OWNER_AUDIENCE, - "attendees", - ), - writeForm( - "attendeePaymentReview", - "attendees", - "/admin/attendees/:attendeeId/payment-review", - OWNER_AUDIENCE, - "attendees", - ), - writeForm( - "attributeDelete", - "attributes", - "/admin/attributes/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "attributeOptionEdit", - "attributes", - "/admin/attributes/:id/options/:optionId/edit", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "attributeOptionDelete", - "attributes", - "/admin/attributes/:id/options/:optionId/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "builtSiteNew", - "builtSites", - "/admin/built-sites/new", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "builtSiteEdit", - "builtSites", - "/admin/built-sites/:id/edit", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "builtSiteDelete", - "builtSites", - "/admin/built-sites/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "bulkActions", - "bulkActions", - "/admin/groups/:id/bulk-actions", - STAFF_ADMIN_LEVELS, - "groups", - ), - writeForm( - "bulkDeactivate", - "bulkActions", - "/admin/groups/:id/bulk-actions/deactivate", - STAFF_ADMIN_LEVELS, - "groups", - ), - writeForm( - "bulkDuplicate", - "bulkActions", - "/admin/groups/:id/bulk-actions/duplicate", - STAFF_ADMIN_LEVELS, - "groups", - ), - writeForm( - "bulkReactivate", - "bulkActions", - "/admin/groups/:id/bulk-actions/reactivate", - STAFF_ADMIN_LEVELS, - "groups", - ), - writeForm( - "emailTemplateDelete", - "bulkEmail", - "/admin/emails/templates/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "groupEdit", - "groups", - "/admin/groups/:id/edit", - CONTENT_ADMIN_LEVELS, - "groups", - ), - writeForm( - "groupImages", - "groups", - "/admin/groups/:id/images", - CONTENT_ADMIN_LEVELS, - "groups", - ), - writeForm( - "groupDelete", - "groups", - "/admin/groups/:id/delete", - STAFF_ADMIN_LEVELS, - "groups", - ), - writeForm( - "holidayNew", - "holidays", - "/admin/holidays/new", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "holidayEdit", - "holidays", - "/admin/holidays/:id/edit", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "holidayDelete", - "holidays", - "/admin/holidays/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "imageEdit", - "images", - "/admin/images/:id/edit", - CONTENT_ADMIN_LEVELS, - "images", - ), - writeForm( - "imageDelete", - "images", - "/admin/images/:id/delete", - CONTENT_ADMIN_LEVELS, - "images", - ), - writeForm( - "ledgerAdd", - "ledger", - "/admin/ledger/:type/:ref/add", - OWNER_AUDIENCE, - "ledger", - ), - writeForm( - "ledgerEdit", - "ledger", - "/admin/ledger/entries/:id/edit", - OWNER_AUDIENCE, - "ledger", - ), - writeForm( - "listingEdit", - "listings", - "/admin/listing/:id/edit", - CONTENT_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingImages", - "listings", - "/admin/listing/:id/images", - CONTENT_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingAttributes", - "listings", - "/admin/listing/:id/attributes", - OWNER_AUDIENCE, - "listings", - ), - writeForm( - "listingQuestions", - "listings", - "/admin/listing/:id/questions", - OWNER_AUDIENCE, - "listings", - ), - writeForm( - "listingQr", - "listings", - "/admin/listing/:id/qr", - STAFF_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingDuplicate", - "listings", - "/admin/listing/:id/duplicate", - CONTENT_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingDeactivate", - "listings", - "/admin/listing/:id/deactivate", - STAFF_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingDelete", - "listings", - "/admin/listing/:id/delete", - STAFF_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingReactivate", - "listings", - "/admin/listing/:id/reactivate", - STAFF_ADMIN_LEVELS, - "listings", - ), - writeForm( - "listingRefundAll", - "attendeeRefunds", - "/admin/listing/:id/refund-all", - OWNER_AUDIENCE, - "listings", - ), - writeForm( - "listingRecalculate", - "listings", - "/admin/listings/recalculate/:listingId", - STAFF_ADMIN_LEVELS, - "listings", - ), - writeForm( - "logisticsNew", - "settingsLogistics", - "/admin/logistics/new", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "logisticsEdit", - "settingsLogistics", - "/admin/logistics/:id/edit", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "logisticsDelete", - "settingsLogistics", - "/admin/logistics/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "modifierEdit", - "modifiers", - "/admin/modifiers/:id/edit", - STAFF_ADMIN_LEVELS, - "modifiers", - ), - writeForm( - "modifierDelete", - "modifiers", - "/admin/modifiers/:id/delete", - STAFF_ADMIN_LEVELS, - "modifiers", - ), - writeForm( - "modifierRecalculate", - "modifiers", - "/admin/modifiers/recalculate/:modifierId", - STAFF_ADMIN_LEVELS, - "modifiers", - ), -] as const; diff --git a/src/shared/admin-surface/write-routes-n-z.ts b/src/shared/admin-surface/write-routes-n-z.ts deleted file mode 100644 index 4d39a276a8..0000000000 --- a/src/shared/admin-surface/write-routes-n-z.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - OWNER_AUDIENCE, - writeForm, -} from "#shared/admin-surface/definitions.ts"; -import { SITE_ADMIN_LEVELS, STAFF_ADMIN_LEVELS } from "#shared/types.ts"; - -export const ADMIN_WRITE_ROUTES_N_Z = [ - writeForm( - "questionDelete", - "questions", - "/admin/questions/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "answerEdit", - "questions", - "/admin/questions/:id/answers/:answerId/edit", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "answerDelete", - "questions", - "/admin/questions/:id/answers/:answerId/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "answerRecalculate", - "questions", - "/admin/questions/:id/answers/:answerId/recalculate", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "servicingEdit", - "servicing", - "/admin/servicing/:id", - STAFF_ADMIN_LEVELS, - "servicing", - ), - writeForm( - "statusNew", - "settingsStatuses", - "/admin/settings/statuses/new", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "statusEdit", - "settingsStatuses", - "/admin/settings/statuses/:id/edit", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "statusDelete", - "settingsStatuses", - "/admin/settings/statuses/:id/delete", - OWNER_AUDIENCE, - "settings", - ), - writeForm( - "sitePageNew", - "sitePages", - "/admin/site/pages/new", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "sitePageEdit", - "sitePages", - "/admin/site/pages/:id/edit", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "sitePageItems", - "sitePages", - "/admin/site/pages/:id/items", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "sitePageImages", - "sitePages", - "/admin/site/pages/:id/images", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "sitePageActions", - "sitePages", - "/admin/site/pages/:id/actions", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "sitePageDelete", - "sitePages", - "/admin/site/pages/:id/delete", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "newsNew", - "news", - "/admin/site/news/new", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "newsEdit", - "news", - "/admin/site/news/:id/edit", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "newsImages", - "news", - "/admin/site/news/:id/images", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "newsActions", - "news", - "/admin/site/news/:id/actions", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "newsDelete", - "news", - "/admin/site/news/:id/delete", - SITE_ADMIN_LEVELS, - "site", - ), - writeForm( - "userAgents", - "users", - "/admin/users/:id/agents", - OWNER_AUDIENCE, - "users", - ), - writeForm( - "userDelete", - "users", - "/admin/users/:id/delete", - OWNER_AUDIENCE, - "users", - ), -] as const; diff --git a/test/features/admin/index.test.ts b/test/features/admin/index.test.ts new file mode 100644 index 0000000000..854c8e1a02 --- /dev/null +++ b/test/features/admin/index.test.ts @@ -0,0 +1,88 @@ +/** + * Admin request dispatch: unknown segments stop before any session work, a + * segment's router is reusable once built, the gate that decides whether a + * request needs a session lets exactly the right paths through without one, + * and only staff get the footer that exposes the query log. + */ + +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { handleRequest } from "#routes"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { awaitTestRequest } from "#test-utils/mocks.ts"; +import { + createTestEditorSession, + getTestSession, +} from "#test-utils/session.ts"; + +const bodyOf = async (path: string, cookie: string): Promise => { + const response = await awaitTestRequest(path, { cookie }); + expect(response.status, path).toBe(200); + return await response.text(); +}; + +describeWithEnv("admin segment dispatch", { db: true }, () => { + test("a path under no declared segment gets a 404", async () => { + const response = await awaitTestRequest("/admin/no-such-area"); + expect(response.status).toBe(404); + }); + + test("a repeat hit on a settings segment succeeds twice", async () => { + // A segment builds its router once and keeps it. Both hits must be signed + // in: a signed-out one is refused before the router is ever built, so it + // would pass whether or not the second hit can reuse the first's router. + const { cookie } = await getTestSession(); + const first = await awaitTestRequest("/admin/settings", { cookie }); + const second = await awaitTestRequest("/admin/settings", { cookie }); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + }); + + test("a signed-out visitor is sent back to /admin", async () => { + const response = await awaitTestRequest("/admin/settings"); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/admin"); + }); + + test("a signed-out visitor still reaches the login pages", async () => { + // These two segments are how somebody signs in, so they must answer + // without a session rather than send the visitor back to themselves. + for (const path of ["/admin/", "/admin/login"]) { + const response = await awaitTestRequest(path); + expect(response.status, path).toBe(200); + } + }); + + test("a signed-out check-in is refused as JSON, not redirected", async () => { + // The scanner posts check-ins here and reads the answer as JSON, so this + // path must skip the redirect every other signed-out page gets. + const response = await handleRequest( + new Request("http://localhost/admin/listing/1/scan", { + body: JSON.stringify({}), + headers: { "content-type": "application/json", host: "localhost" }, + method: "POST", + }), + ); + expect(response.status).toBe(401); + expect(response.headers.get("content-type")).toContain("application/json"); + }); + + test("a signed-in visitor reaches a protected page", async () => { + const { cookie } = await getTestSession(); + const response = await awaitTestRequest("/admin/users", { cookie }); + expect(response.status).toBe(200); + }); +}); + +describeWithEnv("the admin footer's query log", { db: true }, () => { + test("opens for staff reading a page", async () => { + const { cookie } = await getTestSession(); + expect(await bodyOf("/admin/users", cookie)).toContain("debug-menu"); + }); + + test("stays shut for an editor", async () => { + // The log exposes every query the page ran; only staff may read it. + const { cookie } = await createTestEditorSession(); + expect(await bodyOf("/admin/listings", cookie)).not.toContain("debug-menu"); + }); +}); diff --git a/test/integration/admin-role-matrix.test.ts b/test/integration/admin-role-matrix.test.ts new file mode 100644 index 0000000000..be5f84b649 --- /dev/null +++ b/test/integration/admin-role-matrix.test.ts @@ -0,0 +1,105 @@ +/** + * Proves the admin surface tells the truth about who can reach each page. + * + * Every page the navigation can link to declares the roles that reach it. This + * walks that declaration and asks each page as every admin role: a role outside + * the audience must never be served the page, and a role inside it must never + * be forbidden. A disagreement here is a real fault — either a link the viewer + * cannot follow, or a page that serves somebody the surface keeps out. + * + * A page can answer a role it admits with a 404 when the feature that owns it + * is switched off in this environment. That is still a refusal, so the two + * rules below are written about being served (200) and being forbidden (403) + * rather than about one exact status. + * + * Scope: the pages whose pattern takes no parameter, so the answer is about the + * role alone. A page for one record answers 404 when the record is absent, + * which says nothing about permission, so those pages need their own fixtures + * and are not covered here. + */ + +import { expect } from "@std/expect"; +import { beforeEach, it as test } from "@std/testing/bdd"; +import { ADMIN_SURFACE } from "#shared/admin-surface.ts"; +import type { AdminLevel } from "#shared/types.ts"; +import { ALL_ADMIN_LEVELS } from "#shared/types.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { + createTestAgentSession, + createTestEditorSession, + createTestManagerSession, + getTestSession, +} from "#test-utils/session.ts"; + +const FORBIDDEN = 403; +const SERVED = 200; + +/** Pages that answer about the role alone, with no record to look up first. */ +const roleOnlyDestinations = Object.values(ADMIN_SURFACE.destinations).filter( + (destination) => !destination.pattern.includes(":"), +); + +describeWithEnv("admin role matrix", { db: true }, () => { + const cookies = new Map(); + + beforeEach(async () => { + const owner = await getTestSession(); + cookies.set("owner", owner.cookie); + cookies.set("manager", await createTestManagerSession()); + cookies.set("editor", (await createTestEditorSession()).cookie); + cookies.set("agent", (await createTestAgentSession()).cookie); + }); + + const askAs = async ( + path: string, + adminLevel: AdminLevel, + ): Promise => { + const { handleRequest } = await import("#routes"); + const response = await handleRequest( + new Request(`http://localhost${path}`, { + headers: { cookie: cookies.get(adminLevel)!, host: "localhost" }, + }), + ); + return response.status; + }; + + test("covers every parameter-free page the surface declares", () => { + // Guards the walk below: a surface that stopped declaring its pages would + // otherwise make this suite pass by testing nothing. The split is stated + // exactly so the plan cannot claim wider cover than this suite has. + const all = Object.values(ADMIN_SURFACE.destinations); + expect(roleOnlyDestinations.length).toBe(48); + expect(all.length - roleOnlyDestinations.length).toBe(63); + }); + + test("never serves a page to a role it does not declare", async () => { + const served: string[] = []; + for (const destination of roleOnlyDestinations) { + for (const adminLevel of ALL_ADMIN_LEVELS) { + if (destination.audience.includes(adminLevel)) continue; + const status = await askAs(destination.pattern, adminLevel); + if (status === SERVED) { + served.push( + `${destination.id} (${destination.pattern}) served ${adminLevel}`, + ); + } + } + } + expect(served).toEqual([]); + }); + + test("never forbids a role the page does declare", async () => { + const refused: string[] = []; + for (const destination of roleOnlyDestinations) { + for (const adminLevel of destination.audience) { + const status = await askAs(destination.pattern, adminLevel); + if (status === FORBIDDEN) { + refused.push( + `${destination.id} (${destination.pattern}) refused ${adminLevel}`, + ); + } + } + } + expect(refused).toEqual([]); + }); +}); diff --git a/test/integration/admin-route-manifest.test.ts b/test/integration/admin-route-manifest.test.ts index d9c5a38410..fafce04e8c 100644 --- a/test/integration/admin-route-manifest.test.ts +++ b/test/integration/admin-route-manifest.test.ts @@ -10,11 +10,9 @@ import { expect } from "@std/expect"; import { beforeAll, describe, it as test } from "@std/testing/bdd"; import { ADMIN_AREA_LOADERS } from "#routes/admin/area-loaders.ts"; -import { adminPathSegment } from "#routes/admin/index.ts"; +import { adminPathSegment } from "#shared/admin-surface/definitions.ts"; import { ADMIN_SURFACE } from "#shared/admin-surface.ts"; import { routePathPatternToRegex } from "#shared/route-pattern.ts"; -import { describeWithEnv } from "#test-utils/db.ts"; -import { awaitTestRequest } from "#test-utils/mocks.ts"; const loadAreaRoutes = async (): Promise> => { const routesByArea = new Map(); @@ -79,7 +77,7 @@ describe("admin route manifest", () => { }); test("every UI destination is served by a GET route in its area", () => { - for (const destination of ADMIN_SURFACE.destinations) { + for (const destination of Object.values(ADMIN_SURFACE.destinations)) { const concretePath = destination.pattern .replace(/:(\w+)/g, (_, name: string) => name === "id" || name.endsWith("Id") ? "1" : "value", @@ -97,29 +95,9 @@ describe("admin route manifest", () => { } }); - test("adminPathSegment picks the part after /admin", () => { - expect(adminPathSegment("/admin")).toBe(""); - expect(adminPathSegment("/admin/settings")).toBe("settings"); - expect(adminPathSegment("/admin/listing/5/edit")).toBe("listing"); - }); - test("guide message ownership rejects an undeclared segment", () => { expect(() => ADMIN_AREA_LOADERS.guide.messageGroupsFor("unknown")).toThrow( 'No message groups declared for admin segment "unknown"', ); }); }); - -describeWithEnv("admin segment dispatch", { db: true }, () => { - test("a path under no declared segment gets a 404", async () => { - const response = await awaitTestRequest("/admin/no-such-area"); - expect(response.status).toBe(404); - }); - - test("a repeat hit on a settings segment succeeds twice", async () => { - const first = await awaitTestRequest("/admin/settings"); - const second = await awaitTestRequest("/admin/settings"); - expect(first.status).toBe(302); - expect(second.status).toBe(302); - }); -}); diff --git a/test/shared/admin-pages.test.ts b/test/shared/admin-pages.test.ts new file mode 100644 index 0000000000..9c72994138 --- /dev/null +++ b/test/shared/admin-pages.test.ts @@ -0,0 +1,176 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + parseEnabledFeatures, + setFeatureEnabled, +} from "#shared/admin-features.ts"; +import { + entityReturnPath, + readOnlyGetRoutePatterns, + visibleSections, + visibleTopLevel, +} from "#shared/admin-pages.ts"; + +const DEFAULT_ENABLED_FEATURES = parseEnabledFeatures(""); + +describe("admin navigation", () => { + test("shows the Site section to editors only when Site is enabled", () => { + const context = { + active: "/admin", + adminLevel: "editor" as const, + builder: false, + enabledFeatures: DEFAULT_ENABLED_FEATURES, + isReadOnly: false, + storage: false, + support: false, + }; + expect(visibleTopLevel(context).map((link) => link.href)).not.toContain( + "/admin/site", + ); + expect( + visibleTopLevel({ + ...context, + enabledFeatures: setFeatureEnabled( + DEFAULT_ENABLED_FEATURES, + "site", + true, + ), + }).map((link) => link.href), + ).toContain("/admin/site"); + }); + + test("applies per-link feature visibility", () => { + const context = { + active: "/admin/settings", + adminLevel: "owner" as const, + builder: false, + enabledFeatures: DEFAULT_ENABLED_FEATURES, + isReadOnly: false, + storage: false, + support: false, + }; + const hidden = visibleSections(context).flatMap((section) => section.items); + const visible = visibleSections({ + ...context, + builder: true, + support: true, + }).flatMap((section) => section.items); + expect(hidden.map((link) => link.href)).not.toContain("/admin/built-sites"); + expect(hidden.map((link) => link.href)).not.toContain("/admin/support"); + expect(hidden.map((link) => link.href)).not.toContain("/admin/attributes"); + expect(hidden.map((link) => link.href)).not.toContain("/admin/questions"); + expect(visible.map((link) => link.href)).toContain("/admin/built-sites"); + expect(visible.map((link) => link.href)).toContain("/admin/support"); + const featureLinks = visibleSections({ + ...context, + enabledFeatures: setFeatureEnabled( + setFeatureEnabled(DEFAULT_ENABLED_FEATURES, "attributes", true), + "questions", + true, + ), + }).flatMap((section) => section.items); + expect(featureLinks.map((link) => link.href)).toContain( + "/admin/attributes", + ); + expect(featureLinks.map((link) => link.href)).toContain("/admin/questions"); + }); + + test("omits sections with no sub-navigation", () => { + const sections = visibleSections({ + active: "/admin/ledger", + adminLevel: "owner", + builder: false, + enabledFeatures: DEFAULT_ENABLED_FEATURES, + isReadOnly: false, + storage: false, + support: false, + }); + expect(sections.map((section) => section.topHref)).not.toContain( + "/admin/ledger", + ); + }); + + test("derives read-only blocks from destination intent", () => { + expect(readOnlyGetRoutePatterns()).toContain("/admin/listing/new"); + expect(readOnlyGetRoutePatterns()).not.toContain("/admin/listings"); + }); +}); + +describe("entityReturnPath (role-aware detail vs edit redirect)", () => { + test("editors are sent to the edit form (they can't open the detail page)", () => { + expect(entityReturnPath("/admin/listings", "editor", 5)).toBe( + "/admin/listing/5/edit", + ); + expect(entityReturnPath("/admin/groups", "editor", 7)).toBe( + "/admin/groups/7/edit", + ); + }); + + test("staff are sent to the detail page", () => { + expect(entityReturnPath("/admin/listings", "owner", 5)).toBe( + "/admin/listing/5", + ); + expect(entityReturnPath("/admin/listings", "manager", 5)).toBe( + "/admin/listing/5", + ); + expect(entityReturnPath("/admin/groups", "owner", 7)).toBe( + "/admin/groups/7", + ); + expect(entityReturnPath("/admin/groups", "agent", 7)).toBe( + "/admin/groups/7", + ); + }); + + test("a section with no detail page falls back to its list page", () => { + expect(entityReturnPath("/admin/settings", "owner", 1)).toBe( + "/admin/settings", + ); + }); +}); + +describe("links a section hides", () => { + const ownerContext = { + active: "/admin/settings", + adminLevel: "owner" as const, + builder: false, + enabledFeatures: setFeatureEnabled(DEFAULT_ENABLED_FEATURES, "money", true), + isReadOnly: false, + storage: false, + support: false, + }; + + test("drops a section whose only link is its own landing", () => { + // Money is on, so the owner can see the Ledger section, and it still has + // no sub-navigation of its own because it holds exactly one link. + expect(visibleTopLevel(ownerContext).map((link) => link.href)).toContain( + "/admin/ledger", + ); + expect( + visibleSections(ownerContext).map((section) => section.topHref), + ).not.toContain("/admin/ledger"); + }); + + test("hides an add link while the site is read only, keeping the rest", () => { + const readOnly = visibleSections({ ...ownerContext, isReadOnly: true }) + .flatMap((section) => section.items) + .map((link) => link.href); + const writable = visibleSections(ownerContext) + .flatMap((section) => section.items) + .map((link) => link.href); + expect(writable).toContain("/admin/listing/new"); + expect(readOnly).not.toContain("/admin/listing/new"); + expect(readOnly).toContain("/admin/listings"); + }); + + test("hides a link the viewer's role cannot reach", () => { + const editorLinks = visibleSections({ + ...ownerContext, + active: "/admin/listings", + adminLevel: "editor", + }) + .flatMap((section) => section.items) + .map((link) => link.href); + // Editors reach the listings pages but not the owner-only settings ones. + expect(editorLinks).not.toContain("/admin/settings"); + }); +}); diff --git a/test/shared/admin-surface.test.ts b/test/shared/admin-surface.test.ts index 39e3207cd5..e9a2665118 100644 --- a/test/shared/admin-surface.test.ts +++ b/test/shared/admin-surface.test.ts @@ -1,24 +1,11 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { - parseEnabledFeatures, - setFeatureEnabled, -} from "#shared/admin-features.ts"; -import { - entityReturnPath, - readOnlyGetRoutePatterns, - visibleSections, - visibleTopLevel, -} from "#shared/admin-pages.ts"; -import { - ADMIN_SURFACE, adminDestination, adminDestinationAllowed, adminPath, } from "#shared/admin-surface.ts"; -const DEFAULT_ENABLED_FEATURES = parseEnabledFeatures(""); - describe("admin surface paths", () => { test("fills every named route parameter", () => { expect(adminPath("answerEdit", { answerId: 9, id: 42 })).toBe( @@ -40,138 +27,15 @@ describe("admin surface paths", () => { expect(adminDestinationAllowed("modifiers", "manager", true)).toBe(true); }); - test("keeps the complete top-level section order", () => { - expect(ADMIN_SURFACE.sections.map((section) => section.id)).toEqual([ - "home", - "listings", - "calendar", - "servicing", - "attendees", - "users", - "groups", - "images", - "modifiers", - "ledger", - "site", - "settings", - ]); - }); - - test("uses link and view defaults for ordinary destinations", () => { - expect(adminDestination("sessions").nav?.kind).toBe("link"); + test("takes each route's intent from the group it is declared in", () => { expect(adminDestination("modifiers").intent).toBe("view"); + expect(adminDestination("modifierEdit").intent).toBe("write-form"); }); - test("shows the Site section to editors only when Site is enabled", () => { - const context = { - active: "/admin", - adminLevel: "editor" as const, - builder: false, - enabledFeatures: DEFAULT_ENABLED_FEATURES, - isReadOnly: false, - storage: false, - support: false, - }; - expect(visibleTopLevel(context).map((link) => link.href)).not.toContain( - "/admin/site", - ); - expect( - visibleTopLevel({ - ...context, - enabledFeatures: setFeatureEnabled( - DEFAULT_ENABLED_FEATURES, - "site", - true, - ), - }).map((link) => link.href), - ).toContain("/admin/site"); - }); - - test("applies per-link feature visibility", () => { - const context = { - active: "/admin/settings", - adminLevel: "owner" as const, - builder: false, - enabledFeatures: DEFAULT_ENABLED_FEATURES, - isReadOnly: false, - storage: false, - support: false, - }; - const hidden = visibleSections(context).flatMap((section) => section.items); - const visible = visibleSections({ - ...context, - builder: true, - support: true, - }).flatMap((section) => section.items); - expect(hidden.map((link) => link.href)).not.toContain("/admin/built-sites"); - expect(hidden.map((link) => link.href)).not.toContain("/admin/support"); - expect(hidden.map((link) => link.href)).not.toContain("/admin/attributes"); - expect(hidden.map((link) => link.href)).not.toContain("/admin/questions"); - expect(visible.map((link) => link.href)).toContain("/admin/built-sites"); - expect(visible.map((link) => link.href)).toContain("/admin/support"); - const featureLinks = visibleSections({ - ...context, - enabledFeatures: setFeatureEnabled( - setFeatureEnabled(DEFAULT_ENABLED_FEATURES, "attributes", true), - "questions", - true, - ), - }).flatMap((section) => section.items); - expect(featureLinks.map((link) => link.href)).toContain( - "/admin/attributes", - ); - expect(featureLinks.map((link) => link.href)).toContain("/admin/questions"); - }); - - test("omits sections with no sub-navigation", () => { - const sections = visibleSections({ - active: "/admin/ledger", - adminLevel: "owner", - builder: false, - enabledFeatures: DEFAULT_ENABLED_FEATURES, - isReadOnly: false, - storage: false, - support: false, - }); - expect(sections.map((section) => section.topHref)).not.toContain( - "/admin/ledger", - ); - }); - - test("derives read-only blocks from destination intent", () => { - expect(readOnlyGetRoutePatterns()).toContain("/admin/listing/new"); - expect(readOnlyGetRoutePatterns()).not.toContain("/admin/listings"); - }); -}); - -describe("entityReturnPath (role-aware detail vs edit redirect)", () => { - test("editors are sent to the edit form (they can't open the detail page)", () => { - expect(entityReturnPath("/admin/listings", "editor", 5)).toBe( - "/admin/listing/5/edit", - ); - expect(entityReturnPath("/admin/groups", "editor", 7)).toBe( - "/admin/groups/7/edit", - ); - }); - - test("staff are sent to the detail page", () => { - expect(entityReturnPath("/admin/listings", "owner", 5)).toBe( - "/admin/listing/5", - ); - expect(entityReturnPath("/admin/listings", "manager", 5)).toBe( - "/admin/listing/5", - ); - expect(entityReturnPath("/admin/groups", "owner", 7)).toBe( - "/admin/groups/7", - ); - expect(entityReturnPath("/admin/groups", "agent", 7)).toBe( - "/admin/groups/7", - ); - }); - - test("a section with no detail page falls back to its list page", () => { - expect(entityReturnPath("/admin/settings", "owner", 1)).toBe( - "/admin/settings", - ); + test("gives every route the audience its area declares", () => { + // holidayNew states no role of its own, so it takes the area's owner-only + // audience — the same one the handler enforces. + expect(adminDestination("holidayNew").audience).toEqual(["owner"]); + expect(adminDestination("holidays").audience).toEqual(["owner"]); }); }); diff --git a/test/shared/admin-surface/areas.test.ts b/test/shared/admin-surface/areas.test.ts new file mode 100644 index 0000000000..1673a29c16 --- /dev/null +++ b/test/shared/admin-surface/areas.test.ts @@ -0,0 +1,66 @@ +/** + * Rules the admin declaration itself must keep. The fold, the navigation, and + * the route tables all read this table, so a bad entry here reaches every one + * of them. + */ + +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { ADMIN_AREAS } from "#shared/admin-surface/areas.ts"; +import { ADMIN_SURFACE, adminDestination } from "#shared/admin-surface.ts"; +import { ALL_ADMIN_LEVELS } from "#shared/types.ts"; + +const routes = Object.values(ADMIN_SURFACE.destinations); + +describe("the admin areas table", () => { + test("gives every route a path under /admin", () => { + const stray = routes + .filter((route) => !route.pattern.startsWith("/admin")) + .map((route) => `${route.id}: ${route.pattern}`); + expect(stray).toEqual([]); + }); + + test("lets only real admin roles reach a route", () => { + const strange = routes + .filter((route) => + route.audience.some((level) => !ALL_ADMIN_LEVELS.includes(level)), + ) + .map((route) => route.id); + expect(strange).toEqual([]); + }); + + test("gives every route at least one role that can reach it", () => { + // A route nobody can reach is dead, and its link would never render. + const unreachable = routes + .filter((route) => route.audience.length === 0) + .map((route) => route.id); + expect(unreachable).toEqual([]); + }); + + test("declares every area that serves a route", () => { + const areaNames = new Set(Object.keys(ADMIN_AREAS)); + const orphans = routes + .filter((route) => !areaNames.has(route.area)) + .map((route) => `${route.id}: ${route.area}`); + expect(orphans).toEqual([]); + }); + + test("keeps the paths its own callers depend on", () => { + // Spot checks: each is a path another module or a person types by hand. + expect(adminDestination("home").pattern).toBe("/admin/"); + expect(adminDestination("settings").pattern).toBe("/admin/settings"); + expect(adminDestination("listingEdit").pattern).toBe( + "/admin/listing/:id/edit", + ); + }); + + test("keeps the run sheet reachable by delivery agents", () => { + // The handler is gated by deliveryPage, which admits agents; the surface + // has to say the same or every link to it answers the wrong question. + expect([...adminDestination("deliveries").audience].toSorted()).toEqual([ + "agent", + "manager", + "owner", + ]); + }); +}); diff --git a/test/shared/admin-surface/definitions.test.ts b/test/shared/admin-surface/definitions.test.ts new file mode 100644 index 0000000000..23966f40fc --- /dev/null +++ b/test/shared/admin-surface/definitions.test.ts @@ -0,0 +1,103 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + type AdminAreasSpec, + adminPathSegment, + foldAdminAreas, +} from "#shared/admin-surface/definitions.ts"; + +const SPEC: AdminAreasSpec = { + // An area whose routes mostly share one role, and one route that does not. + holidays: { + audience: ["owner"], + view: { holidays: "/admin/holidays" }, + write: { + holidayEdit: "/admin/holidays/:id/edit", + holidayReport: { audience: ["manager"], pattern: "/admin/reports/:id" }, + }, + }, + // An area serving a segment with no page of its own. + markdownPreview: { segments: ["markdown-preview"] }, + // An area whose extra segment sits beside the segment its own page serves. + scanner: { + audience: ["manager"], + segments: ["scan"], + view: { scanner: "/admin/listing/:id/scan" }, + }, +}; + +const folded = foldAdminAreas(SPEC); + +describe("foldAdminAreas", () => { + test("gives a route the audience of the area that declares it", () => { + expect(folded.destinations.holidayEdit!.audience).toEqual(["owner"]); + expect(folded.destinations.holidays!.audience).toEqual(["owner"]); + }); + + test("keeps the audience a route declares for itself", () => { + expect(folded.destinations.holidayReport!.audience).toEqual(["manager"]); + }); + + test("takes intent from the group the route is declared in", () => { + expect(folded.destinations.holidays!.intent).toBe("view"); + expect(folded.destinations.holidayEdit!.intent).toBe("write-form"); + expect(folded.destinations.holidayReport!.intent).toBe("write-form"); + }); + + test("names the area that declares each route", () => { + expect(folded.destinations.holidayEdit!.area).toBe("holidays"); + expect(folded.destinations.scanner!.area).toBe("scanner"); + }); + + test("carries the pattern through in both spellings", () => { + expect(folded.destinations.holidays!.pattern).toBe("/admin/holidays"); + expect(folded.destinations.holidayReport!.pattern).toBe( + "/admin/reports/:id", + ); + }); + + test("keys every route by its own id", () => { + expect(Object.keys(folded.destinations).toSorted()).toEqual([ + "holidayEdit", + "holidayReport", + "holidays", + "scanner", + ]); + }); + + test("reads an area's segments from the patterns it declares", () => { + // Two holidays routes share the "holidays" segment; the report adds one. + expect(folded.areas.holidays).toEqual(["holidays", "reports"]); + }); + + test("adds a segment the area serves without a page", () => { + expect(folded.areas.scanner).toEqual(["listing", "scan"]); + }); + + test("gives an area with no routes only the segments it declares", () => { + expect(folded.areas.markdownPreview).toEqual(["markdown-preview"]); + expect(folded.destinations.markdownPreview).toBeUndefined(); + }); +}); + +describe("adminPathSegment", () => { + test("picks the part after /admin", () => { + expect(adminPathSegment("/admin")).toBe(""); + expect(adminPathSegment("/admin/")).toBe(""); + expect(adminPathSegment("/admin/settings")).toBe("settings"); + expect(adminPathSegment("/admin/listing/5/edit")).toBe("listing"); + }); +}); + +describe("a table that declares one route twice", () => { + test("refuses to fold, naming both areas", () => { + expect(() => + foldAdminAreas({ + holidays: { audience: ["owner"], view: { report: "/admin/holidays" } }, + ledger: { audience: ["owner"], view: { report: "/admin/ledger" } }, + }), + ).toThrow( + 'Admin route "report" is declared by both "holidays" and "ledger"', + ); + }); +}); diff --git a/test/shared/admin-surface/sections.test.ts b/test/shared/admin-surface/sections.test.ts new file mode 100644 index 0000000000..feb541d1d1 --- /dev/null +++ b/test/shared/admin-surface/sections.test.ts @@ -0,0 +1,161 @@ +/** + * Rules the navigation must keep. Every link here names a route declared in + * `areas.ts`, so these checks are what stop the sidebar pointing at something + * the routing layer does not serve. + */ + +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { ADMIN_SECTIONS } from "#shared/admin-surface/sections.ts"; +import { ADMIN_SURFACE, adminDestination } from "#shared/admin-surface.ts"; + +const navEntries = ADMIN_SECTIONS.flatMap((section) => + section.nav.map((entry) => ({ entry, section })), +); + +describe("the admin sections table", () => { + test("keeps the complete top-level section order", () => { + expect(ADMIN_SECTIONS.map((section) => section.id)).toEqual([ + "home", + "listings", + "calendar", + "servicing", + "attendees", + "users", + "groups", + "images", + "modifiers", + "ledger", + "site", + "settings", + ]); + }); + + test("keeps each section's sub-navigation in its declared order", () => { + const users = ADMIN_SECTIONS.find((section) => section.id === "users")!; + expect(users.nav.map((entry) => entry.id)).toEqual([ + "users", + "userNew", + "sessions", + "apiKeys", + ]); + }); + + test("names a real route in every link", () => { + const missing = navEntries + .filter(({ entry }) => ADMIN_SURFACE.destinations[entry.id] === undefined) + .map(({ entry, section }) => `${section.id}: ${entry.id}`); + expect(missing).toEqual([]); + }); + + test("gives every section a landing route it can link to", () => { + const missing = ADMIN_SECTIONS.filter( + (section) => ADMIN_SURFACE.destinations[section.landing] === undefined, + ).map((section) => section.id); + expect(missing).toEqual([]); + }); + + test("opens every section with its own landing link", () => { + const wrong = ADMIN_SECTIONS.filter( + (section) => section.nav[0]?.id !== section.landing, + ).map((section) => section.id); + expect(wrong).toEqual([]); + }); + + test("marks exactly the landing link as the landing kind", () => { + const wrong = navEntries + .filter( + ({ entry, section }) => + (entry.kind === "landing") !== (entry.id === section.landing), + ) + .map(({ entry, section }) => `${section.id}: ${entry.id}`); + expect(wrong).toEqual([]); + }); + + test("points every add and import link at a write form", () => { + // Read-only mode hides a link by its route's intent, so a create link + // pointing at a view route would stay clickable with writing switched off. + const readable = navEntries + .filter(({ entry }) => entry.kind === "create" || entry.kind === "import") + .filter(({ entry }) => adminDestination(entry.id).intent !== "write-form") + .map(({ entry }) => entry.id); + expect(readable).toEqual([]); + }); + + test("links to each route from one section only", () => { + const seen = new Set(); + const twice: string[] = []; + for (const { entry } of navEntries) { + if (seen.has(entry.id)) twice.push(entry.id); + seen.add(entry.id); + } + expect(twice).toEqual([]); + }); +}); + +/** Every message key the English catalog defines, across all its files. */ +const catalogKeys = new Set( + Array.from(Deno.readDirSync("src/locales/en")) + .filter((entry) => entry.name.endsWith(".json")) + .flatMap((entry) => + Object.keys( + JSON.parse(Deno.readTextFileSync(`src/locales/en/${entry.name}`)), + ), + ), +); + +describe("the words the navigation shows", () => { + test("names a real message for every section", () => { + const missing = ADMIN_SECTIONS.filter( + (section) => !catalogKeys.has(section.labelKey), + ).map((section) => `${section.id}: ${section.labelKey}`); + expect(missing).toEqual([]); + }); + + test("names a real message for every link", () => { + const missing = navEntries + .filter(({ entry }) => !catalogKeys.has(entry.labelKey)) + .map(({ entry }) => `${entry.id}: ${entry.labelKey}`); + expect(missing).toEqual([]); + }); +}); + +describe("the sections that own a record page", () => { + const withDetail = ADMIN_SECTIONS.filter( + (section) => section.detailPath !== undefined, + ); + + test("cover listings and groups", () => { + expect(withDetail.map((section) => section.id)).toEqual([ + "listings", + "groups", + ]); + }); + + test("point at one record", () => { + const wrong = withDetail + .filter( + (section) => + !section.detailPath!.startsWith("/admin/") || + !section.detailPath!.includes(":id"), + ) + .map((section) => section.id); + expect(wrong).toEqual([]); + }); + + test("keep the record page for staff only", () => { + // An editor cannot open either detail page, so entityReturnPath sends + // them to the edit form instead. Both flags carry that. + const open = withDetail + .filter((section) => section.staffOnlyDetail !== true) + .map((section) => section.id); + expect(open).toEqual([]); + }); + + test("keep the exact paths entityReturnPath rewrites", () => { + expect(withDetail.map((section) => section.detailPath)).toEqual([ + "/admin/listing/:id", + "/admin/groups/:id", + ]); + }); +}); diff --git a/test/ui/templates/admin/nav.test.tsx b/test/ui/templates/admin/nav.test.tsx index e46574e879..471ff8acc5 100644 --- a/test/ui/templates/admin/nav.test.tsx +++ b/test/ui/templates/admin/nav.test.tsx @@ -78,23 +78,19 @@ describeWithEnv("AdminNav", {}, () => { * and the roles that reach it. Feature-flag-gated sections (Images) are * excluded here — they have dedicated tests that enable the flag. Each pair * lives only in its own section's sub-nav — never on the top-level bar. */ - const addLinkSections = ADMIN_SURFACE.destinations - .filter((route) => route.nav?.kind === "create") - .map((route) => ({ - route, - // Every destination section is declared by the shared ADMIN_SURFACE schema. - section: ADMIN_SURFACE.sections.find( - (section) => section.id === route.section, - )!, - })) - .filter(({ section }) => !("visible" in section)) - .map(({ route, section }) => ({ - addHref: route.pattern, - addText: t(route.nav!.labelKey), - createActive: route.pattern, - roles: route.audience, - sectionActive: adminDestination(section.landing).pattern, - })); + const addLinkSections = ADMIN_SURFACE.sections + .filter((section) => section.visible === undefined) + .flatMap((section) => + section.nav + .filter((entry) => entry.kind === "create") + .map((entry) => ({ + addHref: adminDestination(entry.id).pattern, + addText: t(entry.labelKey), + createActive: adminDestination(entry.id).pattern, + roles: adminDestination(entry.id).audience, + sectionActive: adminDestination(section.landing).pattern, + })), + ); // Regression: the create links used to sit on the top-level nav bar, visible // on every page. They must not appear at the top level any more — only inside