[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 render the entity header + tab strip above the screen (Instance navigation — entity header + 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 header + 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 Number & tag stock Returns Stock activity Onboard Manufacturer export 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 keeps the order fixed by US #750: Stock → Return → Stock activity → Onboard → Manufacturer export. Bulk Flag-unfit / Dispose (T04) has no row — it is a cross-cutting dialog (BulkActionDialogComponent) launched in-context from T02 and 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 header + grouped tabs
Entering an instance renders an entity header above the content: back-chevron to the parent list, entity name, status tag, key stats. Below it, a grouped tab strip; a second row of leaf links renders only when the active tab holds more than one screen.
┌ sidebar ──┬─ content ──────────────────────────────────────────────┐ │ WP Cycling│ ‹ Events Rooibos MTB 2026 [Reg open] 14 Mar │ │ │ ────────────────────────────────────────────────────── │ │ Home │ Overview │ Setup │ Entries │ Race day │ Results │ Money│ │ │ ────────────────────────────────────────────────────── │ │ EVENTS │ Categories · Courses · Races · Start groups · Program │ │ ▸ Events ◂│ ────────────────────────────────────────────────────── │ │ People │ [ data grid ] │
Event tabs:
| Tab | Leaves |
|---|---|
Overview |
|
Setup |
Categories · Courses · Races · Start groups · Program · Event settings (E03) |
Entries |
Participants (E02) · Participant detail (E09) · Pre-assignment (E07) · Import (E06) · Export (E10) · Export timing (E11) |
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.
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 instance header carries a scope chip —
Scoped to: Rooibos MTB 2026 ✕— whose dismiss navigates to the tenant-level route. That is the discoverable path from narrow to wide, and mirrors the filter-clear affordance operators know from other tools.The chip renders only where a tenant-scope equivalent exists — i.e. only on screens whose
ScopedScreendeclaration carries thetenantlevel: Orders, Financial recon, Results, Reports, Members, Renewals, and Participants (whose tenant-scope counterpart is People). On Setup, Race day or Import there is nowhere to land, so the chip is omitted rather than rendered with a dead✕.Raised by the 2026-08-13 design pass and adopted, because it follows directly from the rule already governing the sidebar — an affordance that cannot act is not shipped (Sidebar groups (tenant scope)). A
✕that does nothing is the "Coming soon" row problem wearing a different costume.The chip is also an event-level control only, and it belongs to the Event. It persists unchanged while a Race or Start group detail page is open, because those are pages within an event-scope screen rather than scopes of their own (AF-9). There is no race-level chip because there is no race-level scope.
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. |
Number & tag stock |
✅ |
— |
— |
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 entity header + tab strip render above the content; the sidebar is untouched and keepsEventshighlighted. Leaving via the header back-chevron returns to the list. -
AF-3 — Widen a dual-use screen to tenant scope. User is on
/o/4/events/144/ordersand dismisses the scope chip in the entity header. SPA navigates to/o/4/orders; the same component re-renders unfiltered, the entity header disappears, 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 entity header 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. -
A dual-use screen reached at instance scope shows the scope chip; dismissing it navigates to the tenant-scope route and clears the filter.
-
A screen with no tenant-scope level (Setup, Race day, Import) shows no scope chip — never a chip with an inert
✕. -
/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. T02 / T03 / Stock activity are real screens; Onboard (C02) and manufacturer-export remain placeholders pending the number/tag UI orchestration. Bulk Flag-unfit / Dispose (T04) has no route — it is a dialog launched in-context. Browser titles + breadcrumbs for the four batch-aware screens are derived centrally in |
|
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 header: back-chevron, name, status, stats, scope chip, grouped tab strip |
|
Topbar with breadcrumb / Jump-to / notifications bell |
|
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-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 header + 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. |