[C01] Application Structure: Shell, Scope Model, Navigation, Notifications
Summary
The application shell — the durable chrome around every screen in admin-portal: the organisation switcher, the tenant-scope sidebar, the instance-scope entity header, the topbar with breadcrumb / Jump-to / notifications, and sidebar collapse. C01 owns the structure and the rule for where a new screen goes; it does not own the data shown inside any landing.
|
Rev 6 (2026-08-12) replaced the navigation model. The four-tier cascade (Tenant → Workspace → Mode nav → Entity) had four tiers on three UI surfaces, so the mode nav had to shape-shift and tenant-wide-but-not-instance-specific functions had no home. The workspace switcher is removed. See Navigation Model — three surfaces, three scopes. Rationale and the ten defects that motivated it are in the design journal named in the header. The canvas matches this spec as of rev 6c (2026-08-13). |
Scope discipline: C01 is structural only.
-
Events landing content (Resume / Active / Recent / Glance / Attention) → E05 Events Control Centre
-
Memberships landing → owned by an
M0xuse case when designed (mirror the E05 pattern until then) -
Tenant-admin screens → T01
-
Platform (super-tenant) screens → S01
-
Single-event drill-down → E01 Event Overview
-
Pre-auth public landing → C03 Public Landing
Actor & Context
Actors:
-
Staff user — has access to one or more organisations. Sees the sidebar groups their capabilities and the organisation’s
enabledModulespermit. -
Tenant admin — staff user holding tenant-admin capabilities at one or more organisations (sourced from
OrgPermission.role, see Feature #534). Additionally sees the Tenant admin group, which covers tenant-wide operational records as well as configuration (see Sidebar groups (tenant scope)). -
Super admin — staff user with the global
isSuperAdminJWT claim (US #536). Additionally sees the collapsible Platform group.
Frequency: every login. The shell is the constant frame around all other portal work.
Vocabulary — "tenant" in this portal
The word tenant is used throughout this spec and in the UI. It needs one definition, because the same word means something different one system over.
| Term | Meaning in admin-portal |
|---|---|
Tenant |
The user-facing label for the organisation currently in context — the one the sidebar chip names and whose data every organisation-scope screen shows. It is a label, not a record: there is no tenant table, no tenant id, and nothing in this portal’s model called a tenant. Where a value is passed, stored or validated, it is an |
Organisation |
The actual entity ( |
Linked organisations |
The set a user has been granted ( |
|
This is not the registration-portal Practical consequences:
|
Precondition: user has authenticated via OIDC (OIDC ⇄ admin-service Token Exchange); admin-portal session holds a valid backend JWT (Session-Held JWT & JSESSIONID).
Organisation claims and the opaque-token boundary
The multi-organisation substrate exists today; the portal simply does not consume it. NimbusTokenProvider mints two organisation claims:
| Claim | Meaning |
|---|---|
|
The active organisation for this token. |
|
The full set the principal may act on — a list of |
ITenantService.getOrganisation(Long requestedOrganisation) already enforces the selector contract: parameter not in the principal’s set → 403; absent parameter → the validated X-Organisation-Id header, else the sole permitted organisation, else AmbiquousOrganisationProblem. PersonResource, ImportResource, EventParticipantResourceEx, ResultSetResourceEx and MembershipResourceEx already accept and validate organisationId through it.
|
An organisation outside the principal’s permitted set is rejected with
|
|
The JWT is opaque to the admin-portal gateway. Only admin-service decodes it. The gateway holds the token in the server-side session (Session-Held JWT & JSESSIONID) and attaches it to outbound calls, but never parses its claims — not for Consequences, which bind the rest of this spec:
|
|
|
Entry point: authenticated landing — direct URL https://admin.event.idealogic.co.za/, or post-OIDC-callback redirect from the gateway. Pre-auth predecessor is the public landing (C03).
Main Flow
-
Bootstrap. SPA
APP_INITIALIZERcallsGET /api/session/currenton the gateway, which composes the response from admin-service rather than from the session-held token (Organisation claims and the opaque-token boundary): user,currentOrgId,linkedOrgs[](id, name, role,enabledModules),isSuperAdmin, last-context. -
Resolve organisation from the URL. The organisation is the first path segment group (
/o/:orgId…, see Organisation in the URL). On a bare URL, redirect to the last-used organisation, or the sole entry inlinkedOrgswhen there is only one. -
Derive capabilities. Translate the organisation’s backend role into the capability set the nav gates on (Capability gating).
-
Render shell. Sidebar: organisation chip → grouped tenant nav → user/collapse footer. Topbar: breadcrumb, Jump-to (⌘K), notifications bell.
-
Render the route. Tenant-scope routes render straight into the content area. Instance-scope routes (
/events/:id…,/memberships/:typeId/periods/:periodId…) additionally extend the breadcrumb with the entity and render the tab strip beneath the topbar (Instance navigation — entity crumb + grouped tabs). -
The user proceeds. Switching organisation, opening notifications, collapsing the sidebar, or invoking ⌘K all stay within shell concerns.
Navigation Model — three surfaces, three scopes
Three scope tiers, each with its own UI surface. No surface changes shape as a consequence of another. This is the rule that answers "where does this new screen go?".
| Scope | Surface | Contains |
|---|---|---|
platform |
Collapsible Platform group at the foot of the sidebar. Rendered only when |
Cross-tenant configuration — tenants, global users, role templates, modules, regions, global master data, system settings, audit log. S01. |
tenant |
The sidebar. Always this scope, always the same shape. |
Everything that spans the organisation: the events and memberships portfolios, cross-cutting operational records, inventory, insights, tenant administration. |
instance |
Entity crumb in the breadcrumb, then a tab strip in the content area. Never the sidebar. |
One event, or one membership type + period. |
The sidebar never shape-shifts. Entering an event does not remove tenant navigation, and leaving one does not remove event navigation — because event navigation was never in the sidebar to begin with.
Sidebar groups (tenant scope)
WP Cycling ▾ ← organisation chip ────────────────────────────────── Home EVENTS Events ← list; entry to one event People Orders Results MEMBERSHIPS Membership types ← list; entry to one type + period Members Renewals OPERATIONS Numbers & Tags ← section anchor; tab strip inside INSIGHTS Reports Financial recon TENANT ADMIN (capability-gated) Users & roles Master data Modules · Branding · Billing Audit log PLATFORM [ALL TENANTS] (super admins only, collapsed) … ────────────────────────────────── Chris Thoni · Tenant admin ⌄
A group renders when at least one of its rows is both permitted (capability, Capability gating) and enabled (Organisation.enabledModules). A tenant with only the membership module never sees the EVENTS group — enabledModules becomes the render lever that the removed workspace switcher used to be.
The OPERATIONS group has a single row, Numbers & Tags. It is a section anchor (see Section tabs at tenant scope): its screens share one tab strip, and its commands are actions in the command row, not sidebar rows. (Rev 9, 2026-10-16. Supersedes the five rows Stock, Returns, Stock activity, Onboard and Manufacturer export, and the separate Stock reports row. Three of those were commands, and a command is an action on the screen it acts on, not a place to go.) Bulk Flag-unfit / Dispose (T04) still has no row. It is a dialog launched in context from the number list and from the T03 Done stage.
Why OPERATIONS is a tenant group, not an event one. Number/tag inventory crosses every event an organisation runs (numbers are tagged once and used across many events) and stretches into pre-event onboarding. It is tenant-scope data and now sits in a tenant-scope surface. Rev 4 parked it in an ad-hoc third sidebar "scope" only because the workspace switcher was deferred; that workaround is retired.
Why the workspace switcher is removed. Events and Memberships are not mutually exclusive modes. A modal switcher makes cross-domain tenant functions (People, Orders, financial recon, reports) homeless, costs a click plus a remembered mode on every cross-domain task, and hides half the application behind a dropdown. Group headings give the same segregation without the hiding. It was never built, so nothing is lost.
Instance navigation — entity crumb + grouped tabs
Entering an instance extends the breadcrumb with the entity — its name, status tag and date — and renders a grouped tab strip directly beneath the topbar. There is no separate entity header: the breadcrumb is the one navigation construct above the tabs, and the crumb for the parent list is the way back up. A second row of leaf links renders only when the active tab holds more than one sibling screen. (Rev 8, 2026-10-16. Supersedes the rev 6 entity header with its back-chevron and scope chip, which stacked a third and fourth chrome row above every instance screen and repeated what the breadcrumb already said.)
┌ sidebar ──┬─ content ────────────────────────────────────────────────────────┐ │ WP Cycling│ WP Cycling › Events › Rooibos MTB 2026 [Reg open] 14 Mar › Participants 🔔│ │ │ ──────────────────────────────────────────────────────────────── │ │ EVENTS │ Overview│Setup│Entries│Numbers│Race day│Results│Money│Reports │ │ ▸ Events ◂│ ──────────────────────────────────────────────────────────────── │ │ Orders │ 297 participants [Import] [Add] [Export] │ │ │ [ data grid ] │
The rules the breadcrumb and tab strip encode:
-
Crumbs are the full path: organisation › parent list › entity › screen [› child page]. Every crumb but the last is a link. The last crumb is the page’s
<h1>, rendered at title weight; there is no second title anywhere beneath it. -
The entity crumb carries the status tag and the date. Status is derived from the entity’s dates, never typed.
-
Child pages extend the breadcrumb and keep the parent tab highlighted: an import opens at
…/participants/importand reads… › Rooibos MTB 2026 › Participants › Import; a race opens under Races (AF-9). Child pages are reached from the parent screen’s actions, not from the leaf row, so they do not count towards the "more than one screen" test that renders it. -
Beneath the tabs sits the command row of C09 — a count or subtitle on the left, the screen’s actions or bulk bar on the right. It repeats neither the entity nor the screen name.
-
Screen real estate: at most two chrome rows above the content at instance scope (breadcrumb, tab strip) and one at tenant scope (breadcrumb), plus the command row only where a screen has actions or a count. An "About this screen" band counts as content, not chrome, whether collapsed or expanded (see UI Design Principles — About this screen band).
Event tabs:
| Tab | Leaves |
|---|---|
Overview |
|
Setup |
Categories · Courses · Races · Start groups · Program · Event settings (E03) |
Entries |
Participants (E02), with Participant detail (E09) and Import (E06) as its child pages · Pre-assignment (E07) · Export (E10) · Export timing (E11) |
Numbers |
The event’s numbers: every race number assigned at the event, on the number list (T02). The event is in the breadcrumb, never a filter chip. |
Race day |
Start processing · Timing hand-off |
Results |
|
Money |
Orders · Financial recon |
Reports |
Event reports |
Membership type + period takes the same shape: Overview · Setup (pricing, criteria) · Members · Renewals · Money · Reports.
Grouping is what keeps this viable — roughly seventeen event screens fit in seven tabs, and a new screen joins an existing group instead of widening the strip. Two alternatives were rejected: a nested sidebar column (Jira/Linear/Azure-blade style) handles twenty items natively but costs ~430 px of persistent horizontal chrome on screens that are overwhelmingly wide data grids; tabs plus a More ▾ overflow makes anything in the overflow undiscoverable.
The sidebar highlights the parent list row (Events) throughout an instance, so the user’s position in the tenant hierarchy stays legible.
Section tabs at tenant scope
A sidebar row may anchor a section: several tenant-scope screens over the same data, which share a tab strip in the content area. The anchor row stays highlighted on every screen of the section, and its tab strip uses the same component and the same rules as the instance tab strip above. The first tab is the section’s landing screen. Commands that start a workflow are actions in the C09 command row of the section’s landing screen, and open child pages that extend the breadcrumb.
A child page opened by one of these commands to run a task is a command page, and it hides the section tab strip rather than highlighting a tab. This applies to section tabs only; the instance tab strip is unchanged. The breadcrumb reads Organisation › Numbers & Tags › Return, and the section crumb leads back. A detail page opened from a tab, such as one batch under Activity, keeps the strip with its tab highlighted. See UI Design Principles — Command pages.
At most two chrome rows sit above the content: the breadcrumb and the tab strip. That is the same limit as instance scope.
Numbers & Tags is the only section so far.
┌ sidebar ─────────┬─ content ──────────────────────────────────────────────────┐ │ OPERATIONS │ WP Cycling › Numbers & Tags › Stock status 🔔 │ │ ▸ Numbers & Tags◂│ ────────────────────────────────────────────────────────── │ │ │ Stock status │ Numbers │ UNFIT │ Activity │ │ │ ────────────────────────────────────────────────────────── │ │ │ [Onboard] [Return] [Manufacture] [CSV]│ │ │ [ type × state breakdown ] │
| Tab | Route (under /o/:org) |
Content |
|---|---|---|
Stock status (landing) |
|
Counts by type, subtype and state (T05). Each count opens the Numbers tab, filtered to that type, subtype and state. |
Numbers |
|
The number list (T02). Filters live in the query string. Two further filters open it in a narrower context: |
UNFIT |
|
The number list fixed to state |
Activity |
|
Operational batches and one batch’s detail (T09). |
| Command | Command page route | Opens |
|---|---|---|
Stock take |
|
The stock return workflow (T03) as a full stock take. |
Onboard |
|
Number capture (C02) into a new stock batch. |
Return |
|
The stock return workflow (T03). Also offered on the number list’s bulk bar, with the selection pre-loaded. |
Flag unfit |
|
The stock return workflow (T03) in Flag unfit mode. |
Manufacture |
|
The manufacture export (T08). |
Every earlier route (stock, stock-reports, stock-activity, stock-activity/:batchId, returns, onboard, manufacturer-export) redirects to its new place, keeping its query string.
Dual-use screens
Some screens are wanted at tenant scope and anchored to one instance — all orders across the organisation, and this event’s orders; financial recon overall, and for one membership period. These are declared once and rendered at every anchor they permit, so a tenant-level row and its instance-level alias cannot drift apart.
export type ScopeLevel = 'tenant' | 'event' | 'membership';
export interface ScopedScreen {
key: 'orders' | 'financial-recon' | 'results' | 'reports' | /* … */;
label: string;
icon: IconName;
segment: string; // appended under the active anchor
levels: ReadonlyArray<ScopeLevel>; // anchors this screen may hang from
tab?: EventTab; // its tab at instance level
capability: Capability;
}
Routes are generated from levels, all loading the same component:
-
tenant→/o/:orgId/orders -
event→/o/:orgId/events/:eventId/orders -
membership→/o/:orgId/memberships/:typeId/periods/:periodId/orders
Scope is resolved by a ScopeService from the deepest ActivatedRoute snapshot carrying an eventId / membershipPeriodId param — never by pattern-matching router.url. Query-param forms are accepted as deep-link input and canonicalised into path form (?eventId=144 → /o/4/events/144/orders), so there is exactly one URL per view.
Consequences:
-
Active-highlight needs no special cases. At tenant scope the sidebar row highlights; at instance scope the sidebar highlights the parent list and the tab strip highlights the leaf. Both derive from the path.
-
The rev-4/rev-5 failure mode is structurally impossible: scope used to live in a query param while the highlight matcher deliberately stripped
?, so an event-scoped link silently dropped the user out of event scope. -
The breadcrumb states the scope.
WP Cycling › Events › Rooibos MTB 2026 › Ordersis the event’s orders;WP Cycling › Ordersis the organisation’s. Widening is the sidebar row, which is always tenant scope; narrowing is the event’s tab. No scope chip is rendered. (Rev 8. The rev 6c chip —Scoped to: Rooibos MTB 2026 ✕in the entity header — restated the breadcrumb, and on the event Money screen the build rendered it twice, once in the header and once in the list. Its only unique affordance, dismissal, is the sidebar row.)
Current inventory:
| Screen | tenant | event | membership | Note |
|---|---|---|---|---|
Orders |
✅ |
✅ |
✅ |
Orders cross event boundaries by design (C06). |
Financial recon |
✅ |
✅ |
✅ |
|
Reports |
✅ |
✅ |
✅ |
|
Results |
✅ |
✅ |
— |
Tenant level = cross-event results and leaderboards. |
Numbers & Tags |
✅ |
— |
— |
Inherently cross-event; the event-level view is pre-assignment (E07), a different screen. |
Imports |
— |
✅ |
✅ |
Instance-anchored by nature; entry stays a header action, not a nav row. |
People |
✅ |
— |
— |
Not dual-use with Participants — different entities, see below. |
|
People and Participants are not the same screen. Tenant-level People lists |
Capability gating
Nav visibility is gated on capabilities, not on a role string. The backend role delivered by GET /api/org-permissions/current-user (US #538) against the Feature #534 role enum is translated client-side into a capability set:
export type Capability =
| 'events.view' | 'events.manage'
| 'participants.view'| 'participants.manage'
| 'results.view' | 'results.publish'
| 'memberships.view' | 'memberships.manage'
| 'orders.view' | 'orders.refund'
| 'reports.view'
| 'stock.view' | 'stock.manage'
| 'people.view' | 'people.merge'
| 'finance.view' | 'finance.reconcile'
| 'tenant.users' | 'tenant.masterdata' | 'tenant.settings' | 'tenant.audit'
| 'platform.tenants' | 'platform.users' | 'platform.masterdata' | 'platform.settings';
CapabilityService.has(cap) plus an *apIfCan structural directive. Every sidebar row and every ScopedScreen declares its capability.
|
|
|
This translation is a UI-convenience projection only. Hiding a row is not access control — admin-service remains the enforcement point, and a capability the UI hides must still return |
Rows a role can never reach are hidden. Rows the user could plausibly gain are shown disabled with the reason, consistent with the project’s preference for explaining limited affordances inline rather than hiding them silently.
Platform scope (super tenant)
A collapsible PLATFORM group in the same sidebar, rendered only when the session carries isSuperAdmin (US #536). One nav tree; no application-wide mode to enter or lose.
-
Position: last, below TENANT ADMIN. Collapsed by default; expanded state persisted in localStorage.
-
Pill: an
ALL TENANTStag — reusing the existingTagprimitive with the near-black#0a0a0atreatment — on the group header row, beside the page title of every platform screen, and as crumb 0 of the breadcrumb in place of the organisation crumb. -
Colour differentiation is deliberately not required. The pill carries the safety signal.
|
Platform screens ignore the current organisation for data scope. The organisation chip stays populated while on a platform screen — deliberately, so the user can see where exiting returns them — which makes the pill the only thing distinguishing tenant-scoped from platform-scoped data on screen. It is therefore mandatory on every platform screen, not decorative. |
Rejected: pinning Super Tenant as a row in the organisation switcher (the rev-1..5 design). It makes platform scope masquerade as an organisation, and selecting it silently redefines what every other control on screen means.
Organisation in the URL
The organisation is the first path segment group: /o/:orgId[-:slug]/….
-
The numeric id is authoritative; any trailing
-slugis decoration, ignored on read (parseInt(segment, 10))./o/4-wp-cycling/events/144and/o/4/events/144are the same view. -
Renames change only the decoration — old links keep resolving. No unique index, no reserved-word list, no slug→id lookup.
-
The segment value is the
organisationIdsent on API calls, so no translation layer exists to drift.
Why not session-held only? A session-held current organisation means two browser tabs cannot hold two organisations: switching in tab B re-mints the JWT and silently changes what tab A’s next request returns. Path-scoping removes that class of bug and makes URLs portable between users.
Why /o/ and not /t/? Tenant is a distinct live concept in this system — hostname/registration-system resolution, where register.tourdeworcester.co.za → tenant 1508 → registration system 1505 → org 10. Tenant ids and organisation ids are different number spaces. "Tenant" remains the correct user-facing label in the UI; the URL segment and the API field say organisation so the two can never be confused.
Organisation hierarchy does not cascade access
Organisation.parent exists and disciplines cascade from it, but permissions do not. Holding a permission on a parent organisation grants nothing on its children; every organisation a user may act on is added explicitly and appears in linkedOrgIds in its own right.
Therefore:
-
The switcher renders a flat list, not a tree. Parent/child relationships are a domain concern, not a navigation one.
-
The
organisationIdsubset check is a plain set-membership test againstlinkedOrgIds. No ancestor walk, no transitive expansion — which also means the check cannot be widened by a future hierarchy change. -
A tenant admin at a parent organisation sees the TENANT ADMIN group for that organisation only. Administering a child requires its own grant.
Alternative Flows
-
AF-1 — Switch organisation. User clicks the organisation chip. Dropdown: search field, then the entries from
linkedOrgsas a flat list (alphabetical or recency-sorted; no parent/child nesting — see Organisation hierarchy does not cascade access). Selecting one callsPOST /api/session/organisation(re-issues the admin-service JWT with the neworgIdclaim — see OIDC Token Exchange § Runtime Tenant Switch + US #537) and navigates to/o/:newOrgId/…, landing on the screen last used in that organisation. Interim state: until the multi-org portal work lands, the chip is populated fromlinkedOrgsbut only the active organisation is selectable. -
AF-2 — Enter an instance. User clicks a row in the Events list (or Membership types list). Route becomes
/o/:orgId/events/:id; the breadcrumb extends with the entity crumb and the tab strip renders beneath the topbar; the sidebar is untouched and keepsEventshighlighted. Leaving is theEventscrumb or the sidebar row. -
AF-3 — Widen a dual-use screen to tenant scope. User is on
/o/4/events/144/ordersand clicks the sidebarOrdersrow. SPA navigates to/o/4/orders; the same component re-renders unfiltered, the tab strip disappears, the breadcrumb shortens toWP Cycling › Orders, and the sidebarOrdersrow takes the highlight. -
AF-3a — Enter platform scope. Super admin expands the
PLATFORMgroup and picks a screen. The organisation chip stays populated (it is the exit target); theALL TENANTSpill appears beside the page title and as crumb 0; screen data ignores the current organisation. Collapsing the group or picking any tenant-scope row returns to tenant work. -
AF-4 — Open notifications. User clicks the bell in the topbar. A 380 px panel drops below the bell with: header (title, "N unread" subtitle, "Mark all read" button, settings icon), three tabs (All / Unread / Mentions, with unread count badge on the Unread tab), and a scrollable list of notification rows. Each row: workspace-tinted icon, title, body (truncated), source ("Tour du Worcester 2025 · Comments"), relative timestamp, unread-dot rail on the left. Click a row to navigate to its source. Empty state: "You’re all caught up." See Notifications (designed).
-
AF-5 — Collapse sidebar. User clicks the collapse toggle in the sidebar footer. Sidebar animates from 232 px to 52 px (180 ms ease) showing icons only. Hover surfaces tooltips. Collapse state persists in localStorage (per user, per browser).
-
AF-6 — Jump-to command palette. User presses ⌘K (or Ctrl-K) or clicks the topbar "Jump to…" pill. Palette opens with fuzzy search over tenants, events in current tenant, members in current tenant, recent screens. Selecting a result navigates directly. Internals out of C01 scope; flag as a sibling C-screen when it grows.
-
AF-7 — Single-organisation fast path. The user is linked to exactly one organisation. The chip renders its name with no chevron and no dropdown — there is nothing to switch to, and an affordance that opens a one-entry list is noise. It is informational only; the layout is otherwise unchanged. The chevron and dropdown appear as soon as
linkedOrgs.length > 1. (Resolved 2026-08-13.) -
AF-9 — Open a Race or Start group. The operator picks a row on the event’s Races or Start groups screen. A detail page opens within that same screen at
…/races/:raceIdor…/start-groups/:startGroupId. The Event’s entity crumb persists unchanged, the Setup tab keeps its highlight, and the breadcrumb extends (WP Cycling ▸ Events ▸ Rooibos MTB 2026 ▸ Races ▸ <race>). There is no header swap, no race-level tab strip, and no race-level scope. (Rewritten 2026-08-15 — see the note below. Supersedes the 2026-08-13 "swap" resolution, now retired.)Why the swap was retired. Rev 6c gave a Race its own entity header and a four-tab strip — Overview · Start groups · Entries · Results — treating it as a small Event. The entity model does not support that reading.
A
Raceis the intersection of anEventRaceTypeand anEventCategory(both@ManyToOne(optional = false), plus aCourse). It owns exactly one collection,StartGroup. Its other three proposed tabs are not children at all:-
Participants is derived.
EventParticipanthas noRaceforeign key — it carries acategoryand a many-to-manyeventRaceTypes. A race’s participants are computed from the same intersection that defines the race, widened throughEventCategory.feederCategorieswhere the race runs a combined category (raceCategory = true), e.g. Men 50–59 drawing from Men 50–54 and Men 55–59. -
Categories is the race’s own
eventCategoryplus those feeders — an attribute of the race, not a collection under it. -
Results is scored per race but stored against the result entities.
A tab strip signals owned child collections, the way an Event owns Participants, Races and Orders. A Race has facets, not children, and facets belong on one page as panels. The swap existed only to make room for the tab strip; with the strip gone there is nothing to swap, and keeping the Event header means the operator never loses event context — which matters because race administration is comparative ("does 50–59 run the same course as 40–49?").
This also settles the navigation question: Races and Start groups are ordinary Setup leaves alongside Categories, and neither needs a
ScopedScreenentry beyond its list. No fourthScopeLevel, no second registry axis.Page composition (panels, in order):
-
Race — identity block showing the intersection as two chips (
[Road Race] × [Men 50–59]); Categories, rendering feeders asMen 50–59 ← Men 50–54 · Men 55–59; Details (course, distance, duration, min/max entries); Participants, as a count plus the first rows, with "View all" deep-linking to E02 pre-filtered by race rather than duplicating that grid; Start groups. -
Start group — identity block; Races (see below); Participants, the assigned
StartGroupParticipantrows inseqorder; and Candidates, listing participants eligible for this group’s races but not in it, each showing their current start group, since reassignment is a transfer (an 08:00 → 08:05 move) and the operator must see what they are moving someone out of.
Start group ↔ Race cardinality is a known backend gap. The schema has carried a
start_group_feeder_racemany-to-many since 2020-06-17, matching the real domain — several races may share one start group, running the same course off the same gun while scoring independently, each with its own 1st/2nd/3rd. JPA never mapped it;StartGroupmaps a singlerace. The Start group page therefore shows one race until the mapping lands. It is deliberately not a blocker: the routing above holds either way. Tracked separately — the removal ofStartGroup.raceis non-trivial because it is the organisation path for row-level security in bothStartGroupQueryServiceExandStartGroupParticipantQueryServiceEx. -
-
AF-8 — User loses access to current tenant mid-session. A tenant switch returns 403; SPA shows a toast and reverts to the previous tenant; tenant list is re-fetched (
GET /api/session/tenants).
Acceptance Criteria
-
After OIDC login, the SPA renders the shell + the right landing within 1.5s of
/api/session/currentreturning. -
The organisation chip shows the current organisation’s name truncated to fit; full name visible on hover.
-
The sidebar renders identically on a tenant-scope screen and on an instance-scope screen — entering or leaving an event changes nothing in it except which row is highlighted.
-
Every sidebar group renders only when at least one child row is capability-permitted and module-enabled. No permanently-disabled rows ship.
-
The
PLATFORMgroup appears only when the JWT carriesisSuperAdmin: true. Otherwise it is absent — not hidden via CSS, not rendered. -
Every platform screen displays the
ALL TENANTSpill beside its title and as crumb 0, and its data is not filtered by the current organisation. -
At instance scope the breadcrumb reads organisation › parent list › entity › screen, the entity crumb carrying the status tag and date; every crumb but the last is a link; the last crumb is the page’s only
<h1>; no separate entity header, back-chevron or scope chip renders. -
A dual-use screen reached at instance scope is widened by the sidebar row; the breadcrumb then shortens to organisation › screen.
-
At most two chrome rows render above the content at instance scope (breadcrumb, tab strip) and one at tenant scope.
-
A command page (Stock take, Return, Flag unfit, Onboard, Manufacture) renders no section tab strip; its breadcrumb reads section › command, and the section crumb leads back. A detail page, such as a batch under Activity, keeps the strip with its tab highlighted.
-
/o/4/events/144/ordersand/o/4-wp-cycling/events/144/ordersresolve to the same view; a stale slug never 404s. -
?eventId=Non a tenant-scope dual-use route redirects to the canonical path form. -
Switching organisation navigates to the new
/o/:orgIdroute; every subsequent admin-service call carriesX-Organisation-Idmatching that segment. The JWT is not re-minted — the active organisation is a property of the request, not of the token (US #934). -
An organisation outside the principal’s permitted set is rejected server-side with
403— the portal never widens its own scope. A request naming none is scoped to every permitted organisation, which is the deliberate multi-client contract, not a fallback. -
Neither the SPA nor the gateway decodes the admin-service JWT. Organisation list, role and super-admin flag all arrive from admin-service endpoints; a grep for claim parsing outside admin-service finds nothing.
-
A user holding a permission on a parent organisation but not on its child sees only the parent in the switcher, and receives
403if they hand-edit the URL to the child. -
Breadcrumb segments are links; crumb 0 is the organisation (or
ALL TENANTSin platform scope). -
Sidebar collapse / expand animates smoothly (180 ms ease); collapse state persists across reloads.
-
Bell icon shows red unread-count badge when notifications are unread; badge caps at "9+". Badge is absent when
unreadCount === 0. -
Notifications panel opens below the bell on click, closes on outside-click or Esc.
-
Domain accent colours are used for chips/badges only and never as dominant UI. (Rev 6: these were "workspace" accents; the workspaces are gone but the palette survives as the accent for a sidebar group and for notification-row tinting.)
Group Accent Events
indigo
#4f46e5Memberships
teal
#0891b2Affiliates
amber
#ca8a04Tenant admin
burnt amber
#b45309Platform
near-black
#0a0a0a -
All shell elements meet WCAG AA contrast on the Linear/Vercel-style cool-neutral palette.
API Surface
C01 needs only the shell-level endpoints. Screen-specific data (events, members, glance metrics, attention items) belongs to the relevant screen’s use case (E05 etc.).
| Call | Purpose |
|---|---|
|
Bootstrap. |
|
Refresh the linked-organisation list. Proxies admin-service |
|
Switch active organisation. Re-mints the admin-service JWT via |
|
Exposes the super-admin flag to the gateway, which cannot read the |
|
List notifications for the current user, scoped to the current tenant by default. Pagination + filter ( |
|
Read-state mutations. Per-user, server-persisted. |
Notification-specific endpoints are still candidates — see Notifications (designed); final shape decided when WS5 starts. Existing alternatives (extend CommunicationLogResource, or define a new NotificationResource) need a small spike at WS5 kickoff.
Front-end routes
C01 fixes the shape of the in-shell routes. New screens add routes that match these prefixes; routes that diverge need a discussion before they ship, because the ScopedScreen route generator and the scope resolver both rely on the shape.
Every authenticated route is prefixed /o/:orgId[-:slug] (Organisation in the URL). The prefix is omitted from the table below for readability.
| Route | Purpose |
|---|---|
|
Tenant home. |
|
E05 Events Control Centre. |
|
E01 + instance-scope children. |
|
Membership type + period instance scope. Same shape as events. |
|
Tenant-scope Orders list + C06 Order Detail. Also reachable as |
|
Tenant-scope master person list. Not aliased to event Participants — different entity. |
|
INSIGHTS group. Both dual-use; instance aliases follow the standard shape. |
|
Async import flow (C05 host + E06/E08/M bookends). Imports has no sidebar row — entry is a header action on the screen being imported into. |
|
OPERATIONS group, the Numbers & Tags section (Section tabs at tenant scope). Onboard (C02) and Manufacture remain placeholders pending the number and tag UI orchestration. Bulk Flag-unfit / Dispose (T04) has no route, because it is a dialog launched in context. |
|
TENANT ADMIN group — T01. |
|
Platform scope — S01. Not prefixed by |
Route-level guards remain minimal: deep-linking to a screen the user’s capabilities do not cover renders a "not available" state, and admin-service returns 403. Visibility is the sidebar’s job; enforcement is the backend’s.
Out of Scope
-
Landing-screen content (lives in the relevant screen’s use case).
-
Single-event detail (E01-E04, M01-M02, etc.).
-
Public landing page (C03).
-
Internal command-palette mechanics.
-
Notification creation — that lives in admin-service (publishers like CommunicationLogService, PersonMergeService etc. emit; notification-fan-out lives in the Async Signal & Sweep Framework, Feature #403).
Notifications (designed)
The bell in the topbar opens a 380 px notifications panel. Designed in the 2026-04-27 Claude Design pass; reverse-engineered into this spec.
Bell
-
Topbar right, next to the Jump-to pill.
-
bellicon, 15 px, in a 28×28 px button. -
Active state: button background + border becomes
bgMuted/border. -
Unread-count badge: top-right of the icon; red
#ef4444background, white text, ≥15 px tall, rounded-full. Caps at "9+". Absent whenunreadCount === 0. White 1.5 px border to lift it off the topbar.
Panel
-
Anchored absolute below the bell,
top: 38px,right: -4px. -
380 × ≤440 px, panel background, 1 px border, 10 px radius, soft shadow. -
z-index: 30so it overlays the body content.
Header
-
Title "Notifications" + "N unread" muted subtitle when applicable.
-
Right side: ghost button "Mark all read" with check icon; quiet settings icon.
-
Tab strip below: All · Unread · Mentions. Active tab marked with bottom indigo accent border. Unread tab carries a small unread count next to its label.
Notification row
| Element | Detail |
|---|---|
Unread rail |
6 px column on the left; renders an indigo |
Group icon |
26×26 px rounded square. Background = group accent at 9% alpha; foreground = group accent. Icon picked per type: |
Title |
Bold, single line, ellipsis on overflow. |
Body |
Short, muted text. Single line of context — quote, count, summary. |
Source |
Smaller still, faint colour. e.g. "Tour du Worcester 2025 · Comments". Click target. |
Timestamp |
Right-aligned, faint, relative ("2m", "14m", "1h", "Yesterday", "2 days ago"). |
Read-state background |
Unread rows have a subtle indigo-tinted background ( |
Notification types (sample, from the design)
The 2026-04-27 design seeds the panel with seven sample notifications across these types:
| Type | Example | Source pattern |
|---|---|---|
|
"Zanele mentioned you" — "@chris can you confirm the start groups for the 109 km?" |
|
|
"Participant import completed" — 1,204 rows · 12 warnings · 0 errors |
|
|
"Role assigned: Results officer" — Pieter de Villiers granted Results officer |
|
|
"Event status changed: Setup → Registration open" |
|
|
"Bulk email sent — 12,480 recipients" — open-rate % |
|
|
"83 renewals processed" |
|
|
"Results published" |
|
Type-set is open — new types are added by adding a new icon mapping. Treat the list as illustrative, not exhaustive.
Tenant scope
-
Within a regular organisation: notifications scope to the current organisation. Cross-organisation notifications are not shown.
-
In platform scope: the panel shows the user’s notifications across the organisations they have access to, with a small per-row organisation badge (the design’s
onSuperTenantprop wires this). Defer the badge implementation until platform scope becomes a real workflow.
Read state
Server-persisted (cross-device). POST /api/notifications/{id}/read on click; POST /api/notifications/read-all on the "Mark all read" button. Optimistic UI with rollback on 4xx/5xx.
Open follow-ups (design)
-
Settings cog in the panel header — currently inert. Decide v1 scope: silence-by-type? quiet hours? defer to a second design pass.
-
Empty-state per tab: the design has one ("You’re all caught up") for the All tab. Tab-specific copy ("No unread", "No mentions") could be small wins.
-
Long-list pagination: panel caps at 440 px height. "View all" link to a dedicated
/notificationspage is a candidate; not designed yet.
Open Questions
Resolved 2026-04-27 (round 2)
| Question | Resolution |
|---|---|
Affiliates state |
Designed: disabled row, |
Memberships landing |
Mirror Events landing pattern (E05). Not a design priority right now. |
Public landing scope |
Extracted to C03 — confirmed in design. |
Super-admin claim |
|
Last-context persistence |
Browser localStorage (per-user, per-browser). |
Tenant glance composite |
Belongs to E05, not C01. Endpoint design follows the gateway-side composite recommendation. |
Notifications structure |
Designed: bell with unread-count badge, 380 px panel, tabs (All/Unread/Mentions), per-row icon/title/body/source/timestamp, read-state row tinting, mark-all-read. |
Resolved 2026-08-12 (rev 6)
| Question | Resolution |
|---|---|
Two sidebar modes (portfolio vs event) flagged by E05 |
Superseded. There are no sidebar modes — the sidebar is always tenant scope; instance navigation moved to the content area. Closes the E05 |
Where tenant-wide operational functions live |
A tenant-scope sidebar with named groups (OPERATIONS, INSIGHTS, TENANT ADMIN). T01 re-scoped to cover operational records, not configuration only. |
Dual-use screens (tenant-wide vs instance-anchored) |
One |
Tab-strip width for instance scope |
Grouped tabs with an optional second row of leaves — ~17 event screens in 7 tabs. |
Super-tenant mechanism |
Collapsible in-sidebar |
Organisation identifier in the URL |
Numeric PK authoritative, optional ignored slug suffix, under |
Role gating |
Capability set translated client-side from the backend role; UI projection only, backend enforces. |
Organisation hierarchy |
|
Source of the switcher’s organisation list |
|
Does the gateway decode the JWT? |
No. The token is opaque outside admin-service; the gateway attaches it and reads everything else from endpoints. Adds an endpoint requirement to US #536. |
Still open
-
Recent events on organisation switch. Jump straight to the last-used entity in the destination organisation, or drop the user at its home? Belongs to E05.
-
Bulk-action placement on list pages. Out of C01 scope; affects E02 / M02 / E04.
-
Notifications: settings cog scope. Defer to a follow-up design pass.
Forward-Engineering Backlog
All items now ADO-tracked under Epic #533 (Admin Portal):
| Concern | Detail | ADO |
|---|---|---|
|
Define |
Feature #534 |
|
Boolean flag on |
US #536 |
|
Optional field; when present + valid, mint with this |
US #537 |
|
Returns user’s accessible orgs |
US #538 |
Expose org claims to the portal |
|
New — file under Epic #533 |
Send |
Portal API calls must carry the active |
New — file under Epic #533 |
|
Enum-set column ( |
Open ticket — file under Epic #533 when first needed by a screen. |
Notifications backend |
|
Open ticket — file under Epic #533 when WS5 nears. |
Tenant glance composite (gateway) |
Owned by E05 (workspace landing). Not C01. |
Belongs to E05’s API surface. |
Browser-local persistence |
Last-context + sidebar-collapse — localStorage. No backend ticket. |
n/a |
Reference Implementation
| File | Role |
|---|---|
|
Tenant-scope sidebar — grouped rows from the nav registry, capability-gated |
|
The |
|
Resolves |
|
Role → capability translation + |
|
Instance-scope grouped tab strip and leaf row; the entity itself is a crumb |
|
Topbar with breadcrumb (full path; the entity crumb carries status and date; last crumb is the |
|
Notifications panel (380 px dropdown) |
|
Reads |
|
Reads |
|
Wraps localStorage for last organisation + screen + entity |
|
Group accent colour CSS custom properties + dev banner |
Notes for Implementation
-
Place shell components under
admin-portal-app/src/app/core/shell/. Landings (E05 etc.) live underfeatures/<domain>/. -
The sidebar renders from the nav registry; it holds no scope state and no URL matching. With a stable sidebar every row is a static route, so
routerLinkActivereplaces the hand-rolledactiveKey()/routeFor()matcher entirely. -
Instance tabs read their ids from
ActivatedRouteparams, not fromrouter.url. -
Generate routes from
ScopedScreen.levelsrather than hand-writing each alias — a hand-written alias is exactly how a tenant row and its instance twin drift. -
TenantContextServiceis the canonical client of/api/session/*endpoints. Sidebar binds to its observables. It must exposelinkedOrgsandisSuperAdmin, not just the current id. -
Sidebar collapse — Signal-based; persisted in localStorage on change; read at app init.
-
Last-context — write
lastContext = { orgId, route, entityType, entityId, label, timestamp }on every entity-screen navigation, keyed by user and organisation (a user’s last context in org 4 must not leak into org 9). Read on bootstrap. Landing screens (E05 etc.) read this for their Resume bars. -
Group accent colours — define as CSS custom properties at the root, e.g.
--ems-events: #4f46e5. Tag/Dot components consume the property. -
NotificationsPanelComponentopens with a Material-CDK overlay or Angular CDK overlay; not a custom positioning solution.
Change History
| Date | Change |
|---|---|
2026-10-26 (rev 10) |
Event tab strip gains Numbers, between Entries and Race day: the number list at event scope ( |
2026-10-21 (rev 9) |
Command pages hide the section tab strip. At tenant scope, a child page that runs a task, opened from a section’s command row, no longer keeps the parent tab highlighted. The command takes the page, and a row of tabs would invite the operator to leave mid-session. The breadcrumb still reads section › command, and the leave-page guard handles any departure. Detail pages keep the strip, and the instance tab strip is unchanged. Product owner decision; the pattern is in UI Design Principles — Command pages. |
2026-10-16 (rev 8) |
Entity header and scope chip retired; the breadcrumb is the one construct above the tab strip. A review of the built portal on stage found four chrome rows above the Overview content — breadcrumb, entity header with back-chevron, tab strip, page header — with the page header reading "OVERVIEW / Overview", and the event Money screen carrying two scope chips. The breadcrumb already named the organisation, the parent list and the entity, so the entity header repeated it and the back-chevron duplicated a crumb that had become a link. The entity crumb now carries the status tag and the date; the last crumb is the page’s |
2026-08-15 (rev 7) |
AF-9’s entity-header swap retired; Race and Start group become detail pages, not screens. Rev 6c had a Race replace the Event in the entity header and take a four-tab strip of its own. Reviewing the entity model showed the premise was wrong: a |
2026-08-14 (rev 6d) |
|
2026-08-13 (rev 6c) |
Design canvas rebuilt to match the spec. |
2026-08-13 (rev 6b) |
"Tenant" defined rather than removed, plus two open questions closed. A first attempt at this revision renamed every portal-facing use of tenant to organisation (sidebar group, pill, capability namespace, |
2026-08-12 (rev 6a) |
Two constraints absorbed from the design session. (1) Organisation hierarchy does not cascade permissions — |
2026-08-12 (rev 6) |
Navigation model replaced. Root cause: four tiers (Tenant → Workspace → Mode nav → Entity) on three UI surfaces, so tiers 3 and 4 shared one nav list that had to shape-shift, and tenant-wide-but-not-instance-specific functions had no home — the reason rev 4 bolted Inventory on as an ad-hoc third "scope", and the reason the membership import wizard became the entry point to the membership domain. Second conflation corrected: "workspace" mixed domain (Events/Memberships/Affiliates) with scope (Tenant admin/Super admin). Now three scopes on three surfaces (Navigation Model — three surfaces, three scopes): organisation chip → stable grouped tenant sidebar (Sidebar groups (tenant scope)) → instance entity header + grouped tab strip (Instance navigation — entity crumb + grouped tabs). Workspace switcher removed; |
2026-06-24 (rev 5) |
US #750 navigation-consistency pass for the four batch-aware Inventory screens (T02, T03, Stock activity list + detail). Documented the final Inventory sidebar order (Stock → Return → Stock activity → Onboard → Manufacturer export) + Stock activity’s slot + uniform admin gate; corrected the prior rev-4 listing that named "Bulk (T04)" as a sidebar row and a |
2026-05-07 (rev 4) |
Added third sidebar scope |
2026-04-27 (rev 3 — handoff-ready) |
Stripped Events-specific landing content (Resume / Active / Glance / Attention) — moved to E05. Notifications section rewritten as "designed" (bell with unread-count badge, 380 px panel with tabs, per-row anatomy, sample types, read-state semantics) reverse-engineered from the 2026-04-27 design pass. Public landing extraction confirmed → C03. Affiliates "Coming soon" tooltip implementation matches design. Forward-engineering items linked to ADO Epic #533 (Features #534/#535, USs #536–538). Status promoted to handoff-ready: structural shell is settled. |
2026-04-27 (rev 2) |
User decisions absorbed: Affiliates "Coming soon" tooltip; Memberships landing mirrors Events; public-landing extracts to C03; super-admin = JWT flag (not org); last-context = localStorage v1; tenant glance = gateway composite. |
2026-04-27 |
Initial reverse-engineered draft from Claude Design canvas. Status: in-design. |