diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 56c7ac0bad..bba17f06f8 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -48,6 +48,7 @@ const crmApp = defineApp({ | `active` | `boolean` | optional | Is app active (default: `true`) | | `isDefault` | `boolean` | optional | Is default app | | `navigation` | `NavigationItem[]` | optional | Navigation tree | +| `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) | | `branding` | `AppBranding` | optional | Visual customization | | `requiredPermissions` | `string[]` | optional | Required permissions to access | @@ -213,6 +214,202 @@ rejected — a divider has nothing to label, gate, or badge.) | `requiresObject` | `string` | Hide/disable unless the named object is registered | | `requiresService` | `string` | Hide/disable unless the named kernel service is registered | +## Areas + +An **area** partitions one app's navigation by business domain — Sales, Service, +Settings — and each area carries its own independent navigation tree. The active +area's tree is what the sidebar renders; the shell offers a switcher above it +once more than one area is visible. + +### When to use an area instead of a top-level group + +Both split a long sidebar, but they split it differently, and the choice is +about whether the user needs to see the partitions *at the same time*: + +- A **`group` item** keeps everything on screen — a collapsible section inside + one tree, with every sibling group still visible beside it. Reach for this + first; most apps never need anything else. +- An **area** *replaces* the sidebar. Only the active area's items are listed; + the rest are reached by switching. That is the right shape when a Service rep + and a Sales rep share one app but never work in the other's tree, and the + wrong shape when the two sets are browsed together. + +So: simple apps use `navigation` alone. `areas[]` is for apps large enough that +a single tree would be unusable, and where the domains are contexts a user +switches between rather than sections they scan. + +### Area properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `id` | `string` | ✅ | Unique area identifier (`snake_case`) — the switcher's identity and active-area state key | +| `label` | `string` | ✅ | Area display label | +| `icon` | `string` | optional | Area icon (Lucide) | +| `description` | `string` | optional | Authoring annotation only — no surface renders it today | +| `navigation` | `NavigationItem[]` | ✅ | The area's own navigation tree | + +That is the whole key set: `NavigationAreaSchema` is **strict**, so any other +key fails the parse — see [Retired area-level keys](#retired-area-level-keys) +for the three that used to be accepted here. + +### How `areas[].navigation` relates to the top-level tree + +Both trees hold the same `NavigationItem` shape, and every item property means +the same thing in either one. What differs is which tree the shell renders: + +- **Areas take precedence.** With areas declared, the active area's + `navigation` is what the sidebar renders; the top-level `navigation` is the + fallback, rendered when no area is visible to this user. +- **Declaration order is display order.** Both the sidebar and + `AppSchemaRenderer` iterate the `areas` array exactly as authored — nothing + sorts areas. To rearrange them, reorder the array itself. +- **The first visible area is the initial one**, and the switcher appears only + when *more than one* area is visible. A single-area app renders as an ordinary + sidebar with no switcher chrome. +- **Area visibility is derived, never authored.** An area is offered if and only + if at least one item inside it survives the item-level gates below. An area + whose every item is gated away disappears from the switcher instead of + stranding the user on an empty tree, and it is never auto-activated. + +### Server-side and client-side gates are not symmetric + + + **Anything that must never reach the browser goes in `requiredPermissions`, + never in `visible`.** + + +An area carries no gate of its own — gating lives on the items inside it, and +the two item-level mechanisms are enforced in *different places*: + +| Item property | Enforced where | What it actually does | +| :--- | :--- | :--- | +| `requiredPermissions`, `requiresService` | **Server**, in both trees — then re-checked in the shell | The entry is never served: it is absent from the `/meta` body | +| `visible` (CEL), `requiresObject` | **Client only**, at every level | Hides an entry the browser has already received | + +Since #4722 the authoritative server-side filter (`filterAppForUser`) runs the +**same** item filter over the app's top-level `navigation` *and* over every +`areas[].navigation`, so an item's `requiredPermissions` / `requiresService` is +enforced identically in both trees: a gated entry — with its `objectName` / +`pageName` / `componentRef` target — never leaves the server. An area emptied +**by** the gate is dropped from the response, mirroring how an emptied `group` +collapses; an area *authored* empty is passed through untouched, because +filtering reports what the caller may not see rather than tidying the metadata. + +`visible` did **not** move server-side with them, and that asymmetry is +deliberate: CEL is evaluated in the browser because server-side evaluation needs +a bound `user` context the read layer does not have. So `visible` is a +decluttering affordance — it hides an entry the response already contains, and +reading the JSON defeats it. Use it to reduce noise, never to keep a secret. The +same holds for `requiresObject`. + +### Retired area-level keys + + + `areas[].order`, `areas[].visible` and `areas[].requiredPermissions` were + removed in `@objectstack/spec` 17.0.0 (ADR-0049 enforce-or-remove) and are now + **refused at parse time** rather than ignored, so one leftover key fails the + whole save. Run `os migrate meta --from 16` to rewrite existing sources + automatically. + + - `order` (#4667) — no renderer ever sorted areas, so declaration order + already *was* display order and an author who set `order` saw nothing move. + Delete the key and reorder the `areas` array. Note the neighbour that + behaves differently: a navigation **item's** `order` is genuinely sorted, + and this removal does not touch it. + - `visible` and `requiredPermissions` (#4651) — these were not merely unread + keys, they were **fail-open gates**. No layer read them, so an area "hidden" + by one, or restricted to `['sales.admin']`, was served to and rendered for + *every* user: the author got a clean parse, a stored value, and no gate at + all. Removing a gate that never gated is strictly safer than shipping one + that looks like it works. Gate the items **inside** the area instead — an + item's `visible` takes the same CEL expression, and its + `requiredPermissions` is the server-enforced one — or gate the whole app + with the app-level `requiredPermissions`, which *is* checked server-side. + + The removals do not read back onto the items: item-level gating inside an area + is fully supported, and since #4722 it is server-enforced. + + +### Complete Example + +Two areas for one field-service app, showing both gate kinds side by side: + +{/* os:check */} +```typescript +import { defineApp } from '@objectstack/spec'; + +const fieldServiceApp = defineApp({ + name: 'field_service', + label: 'Field Service', + description: 'Dispatch, work orders, and service analytics', + icon: 'wrench', + active: true, + + branding: { + primaryColor: '#0f766e', + logo: '/assets/fs-logo.svg', + }, + + // Declaration order IS display order — areas carry no `order` key. + areas: [ + { + id: 'area_dispatch', + label: 'Dispatch', + icon: 'calendar-clock', + navigation: [ + { id: 'nav_board', type: 'page', label: 'Dispatch Board', pageName: 'dispatch_board', icon: 'layout-dashboard' }, + { id: 'nav_work_orders', type: 'object', label: 'Work Orders', objectName: 'work_order', icon: 'clipboard-list', viewName: 'open_work_orders' }, + { + id: 'nav_my_jobs', + type: 'object', + label: 'My Jobs', + objectName: 'work_order', + icon: 'user-check', + filters: { technician_id: '{current_user_id}', status: 'scheduled' }, + }, + ], + }, + { + id: 'area_analytics', + label: 'Analytics', + icon: 'bar-chart', + navigation: [ + { id: 'nav_sla', type: 'dashboard', label: 'SLA Overview', dashboardName: 'service_sla', icon: 'gauge' }, + { + id: 'nav_job_margin', + type: 'report', + label: 'Job Margin', + reportName: 'job_margin', + icon: 'file-bar-chart', + // SERVER-enforced in both trees: a caller without this permission + // never receives this entry, so its `reportName` is not readable + // from the /meta body either. + requiredPermissions: ['service.finance'], + }, + { + id: 'nav_forecast_beta', + type: 'dashboard', + label: 'Forecast (beta)', + dashboardName: 'service_forecast', + icon: 'trending-up', + // CLIENT-only: this entry IS sent and then hidden. Decluttering, + // not access control — never put a secret behind `visible`. + visible: "'service_beta' in current_user.positions", + }, + ], + }, + ], + + // The fallback tree: rendered only when NO area is visible to the caller + // (every area's items gated away, or the areas list filtered empty). + navigation: [ + { id: 'nav_handbook', type: 'url', label: 'Service Handbook', url: 'https://help.example.com/service', icon: 'book-open', target: '_blank' }, + ], + + requiredPermissions: ['service_access'], +}); +``` + ## Branding Customize the visual appearance of the app: