portal-mojo 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -1
- package/docs/DetailView.md +24 -3
- package/docs/ModelTable.md +44 -0
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +14 -7
- package/docs/charts.md +7 -0
- package/docs/client.md +109 -0
- package/docs/dataview.md +6 -3
- package/docs/defineModel.md +49 -2
- package/docs/grouping-and-fmt.md +17 -1
- package/docs/image-editor.md +13 -0
- package/docs/menus-and-access.md +57 -0
- package/docs/params.md +12 -0
- package/docs/personas.md +212 -0
- package/docs/realtime.md +44 -2
- package/docs/route-error.md +64 -0
- package/package.json +15 -2
- package/src/admin/assistant/AssistantLauncher.tsx +23 -0
- package/src/admin/assistant/AssistantPanel.tsx +3 -2
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/index.ts +1 -11
- package/src/admin/assistant/launchers.tsx +5 -11
- package/src/admin/assistant/pages.tsx +1 -1
- package/src/admin/assistant/permissions.ts +1 -0
- package/src/admin/assistant/streaming.ts +69 -41
- package/src/admin/bouncer/index.ts +1 -28
- package/src/admin/bouncer/models.ts +1 -1
- package/src/admin/bouncer/signatures.tsx +1 -1
- package/src/admin/cloudwatch/CloudWatchChart.tsx +1 -1
- package/src/admin/cloudwatch/data.ts +1 -1
- package/src/admin/cloudwatch/index.ts +1 -11
- package/src/admin/core/index.ts +230 -0
- package/src/admin/credentials/group-api-keys.tsx +1 -1
- package/src/admin/credentials/models.ts +1 -1
- package/src/admin/credentials/webhook-subscriptions.tsx +1 -1
- package/src/admin/dashboard/AdminDashboardPage.tsx +9 -18
- package/src/admin/dashboard/data.ts +1 -1
- package/src/admin/dashboard/index.ts +1 -0
- package/src/admin/dns/CertificateDetail.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +1 -1
- package/src/admin/dns/CertificatesPage.tsx +1 -1
- package/src/admin/dns/DnsRecordsPanel.tsx +1 -1
- package/src/admin/dns/DomainCertificatesSection.tsx +1 -1
- package/src/admin/dns/DomainDetail.tsx +1 -1
- package/src/admin/dns/DomainOnboardingDialog.tsx +1 -1
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/ProviderCredentialsPage.tsx +1 -1
- package/src/admin/dns/RegistrantContactPage.tsx +1 -1
- package/src/admin/dns/api.ts +1 -1
- package/src/admin/dns/index.ts +2 -59
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/dns/setup.ts +27 -0
- package/src/admin/domains/assistant.ts +13 -0
- package/src/admin/domains/communications.ts +35 -0
- package/src/admin/domains/identity.ts +24 -0
- package/src/admin/domains/infrastructure.ts +24 -0
- package/src/admin/domains/observability.ts +28 -0
- package/src/admin/domains/operations.ts +18 -0
- package/src/admin/domains/security.ts +55 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +1 -1
- package/src/admin/identity/members/MembersPage.tsx +1 -1
- package/src/admin/identity/members/index.ts +1 -17
- package/src/admin/identity/members/member-flows.tsx +1 -1
- package/src/admin/identity/members/models.ts +1 -1
- package/src/admin/identity/users/UserDetail.tsx +1 -1
- package/src/admin/identity/users/UsersPage.tsx +1 -1
- package/src/admin/identity/users/index.ts +1 -16
- package/src/admin/identity/users/models.ts +1 -1
- package/src/admin/identity/users/sections/LoginsSection.tsx +1 -1
- package/src/admin/identity/users/sections/NotificationsSection.tsx +1 -1
- package/src/admin/identity/users/sections/PersonalSection.tsx +1 -1
- package/src/admin/identity/users/sections/actions.tsx +15 -6
- package/src/admin/identity/users/sections/permission-catalog.ts +1 -1
- package/src/admin/identity/users/sections/queries.ts +1 -1
- package/src/admin/identity/users/sections/shared.tsx +1 -1
- package/src/admin/incidents/EventsPage.tsx +1 -1
- package/src/admin/incidents/IncidentDetail.tsx +1 -1
- package/src/admin/incidents/IncidentsPage.tsx +1 -1
- package/src/admin/incidents/index.ts +1 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/index.ts +5 -364
- package/src/admin/jobs/JobDetail.tsx +5 -3
- package/src/admin/jobs/JobsTablePage.tsx +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +2 -2
- package/src/admin/jobs/ScheduledTaskDetail.tsx +1 -1
- package/src/admin/jobs/ScheduledTasksPage.tsx +1 -1
- package/src/admin/jobs/columns.tsx +1 -1
- package/src/admin/jobs/control.ts +1 -1
- package/src/admin/jobs/index.ts +1 -39
- package/src/admin/jobs/models.ts +1 -1
- package/src/admin/jobs/queries.ts +1 -1
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +1 -1
- package/src/admin/jobs/sections/JobOperationsSection.tsx +1 -1
- package/src/admin/messaging/EmailDomainWizard.tsx +1 -1
- package/src/admin/messaging/PublicMessagesPage.tsx +1 -1
- package/src/admin/messaging/api.ts +1 -1
- package/src/admin/messaging/data.ts +1 -1
- package/src/admin/messaging/index.ts +1 -9
- package/src/admin/messaging/models.ts +1 -1
- package/src/admin/messaging/push/PushPage.tsx +1 -1
- package/src/admin/messaging/push/api.ts +1 -1
- package/src/admin/messaging/push/index.ts +1 -4
- package/src/admin/messaging/push/models.ts +1 -1
- package/src/admin/monitoring/MetricsExplorerPage.tsx +1 -1
- package/src/admin/monitoring/MetricsSourcePicker.tsx +1 -1
- package/src/admin/monitoring/index.ts +1 -41
- package/src/admin/monitoring/metrics-explorer-client.ts +2 -2
- package/src/admin/monitoring/models.ts +1 -1
- package/src/admin/network/BlockedIPsPage.tsx +10 -7
- package/src/admin/network/BlockedIpDetail.tsx +1 -1
- package/src/admin/network/FirewallLogPage.tsx +3 -1
- package/src/admin/network/IPSetDetail.tsx +1 -1
- package/src/admin/network/IPSetsPage.tsx +1 -1
- package/src/admin/network/geofence/BlocksTab.tsx +1 -1
- package/src/admin/network/geofence/ExemptionsTab.tsx +1 -1
- package/src/admin/network/geofence/GeofencingPage.tsx +1 -1
- package/src/admin/network/geofence/RulesTab.tsx +1 -1
- package/src/admin/network/geofence/geofence-data.ts +3 -21
- package/src/admin/network/geofence/permissions.ts +13 -0
- package/src/admin/network/index.ts +1 -41
- package/src/admin/network/models.ts +3 -9
- package/src/admin/network/permissions.ts +8 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +3 -3
- package/src/admin/phonehub/api.ts +4 -1
- package/src/admin/phonehub/data.ts +1 -1
- package/src/admin/phonehub/index.ts +1 -4
- package/src/admin/phonehub/models.ts +1 -1
- package/src/admin/public/assistant-launcher.ts +1 -0
- package/src/admin/public/assistant.ts +2 -0
- package/src/admin/public/communications.ts +4 -0
- package/src/admin/public/identity.ts +3 -0
- package/src/admin/public/infrastructure.ts +4 -0
- package/src/admin/public/observability.ts +4 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/public/security.ts +7 -0
- package/src/admin/registry.ts +40 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +1 -1
- package/src/admin/rules/RuleSetsPage.tsx +1 -1
- package/src/admin/rules/index.ts +1 -6
- package/src/admin/rules/models.ts +1 -1
- package/src/admin/security/devices/LoginEventDetail.tsx +1 -1
- package/src/admin/security/devices/LoginLocationsPage.tsx +1 -1
- package/src/admin/security/devices/UserDeviceDetail.tsx +1 -1
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +1 -1
- package/src/admin/security/devices/index.ts +1 -22
- package/src/admin/security/devices/models.ts +1 -1
- package/src/admin/security/geoip/GeoIpCachePage.tsx +1 -1
- package/src/admin/security/geoip/GeoIpDossier.tsx +1 -1
- package/src/admin/security/geoip/geoip-forms.ts +1 -1
- package/src/admin/security/geoip/index.ts +1 -17
- package/src/admin/security/geoip/models.ts +1 -1
- package/src/admin/security/index.ts +1 -23
- package/src/admin/security/models.ts +1 -1
- package/src/admin/security/tickets.tsx +1 -1
- package/src/admin/settings/SettingDetail.tsx +1 -1
- package/src/admin/settings/SettingEditor.tsx +1 -1
- package/src/admin/settings/index.ts +1 -13
- package/src/admin/settings/model.ts +1 -1
- package/src/admin/shortlinks/index.ts +1 -12
- package/src/admin/shortlinks/models.ts +1 -1
- package/src/admin/storage/BackendsPage.tsx +1 -1
- package/src/admin/storage/FileManagerDetail.tsx +1 -1
- package/src/admin/storage/FileView.tsx +1 -1
- package/src/admin/storage/FilesPage.tsx +1 -1
- package/src/admin/storage/index.ts +1 -18
- package/src/admin/storage/models.ts +9 -4
- package/src/admin/storage/permissions.ts +3 -0
- package/src/admin/storage/storage-dialogs.tsx +1 -1
- package/src/client/action-result.ts +95 -0
- package/src/client/active-group.ts +29 -0
- package/src/client/client.ts +15 -1
- package/src/client/endpoint-scope.ts +79 -0
- package/src/client/group.tsx +9 -0
- package/src/client/index.ts +5 -0
- package/src/client/mock.ts +17 -12
- package/src/client/model.ts +26 -2
- package/src/client/params.ts +16 -4
- package/src/client/persist.ts +54 -0
- package/src/client/realtime-mock.ts +17 -13
- package/src/client/realtime.ts +18 -4
- package/src/client/runtime.ts +21 -0
- package/src/client/scoped.ts +93 -0
- package/src/client/upload.ts +6 -3
- package/src/personas/PersonaProvider.tsx +151 -0
- package/src/personas/audit.ts +73 -0
- package/src/personas/index.ts +13 -0
- package/src/personas/presets.ts +72 -0
- package/src/personas/registry.ts +111 -0
- package/src/personas/sections.tsx +202 -0
- package/src/ui/DetailView.tsx +30 -6
- package/src/ui/ModelTable.tsx +75 -9
- package/src/ui/RouteError.tsx +73 -0
- package/src/ui/SidebarNav.tsx +36 -5
- package/src/ui/active-persona.ts +34 -0
- package/src/ui/detail/DataView.tsx +10 -4
- package/src/ui/detail/KnownFieldsCard.tsx +13 -1
- package/src/ui/fixed-params.ts +58 -0
- package/src/ui/format.ts +36 -11
- package/src/ui/image-editor/ImageEditor.tsx +21 -22
- package/src/ui/image-editor/canvas.ts +82 -11
- package/src/ui/image-editor/math.ts +78 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/menu-registry.ts +128 -9
- package/src/ui/route-error.ts +60 -0
- package/src/ui/safe-node.tsx +88 -0
- package/src/ui/shell.ts +13 -0
- package/src/ui/warn-once.ts +12 -0
package/docs/personas.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# personas — hats/roles a signed-in user operates under (presentation only)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
definePersonas, getPersonas, getPersona, availablePersonas, personaHome,
|
|
6
|
+
PersonaProvider, usePersona, usePersonaFollowsRoute,
|
|
7
|
+
personaSectionRoutes, personaSectionsMenu, personasOwningPath,
|
|
8
|
+
personaPresets, registerPersonaMemberPermissions,
|
|
9
|
+
auditPersonas,
|
|
10
|
+
type PersonaDef, type PersonaSection, type PersonaSectionChild,
|
|
11
|
+
type PersonaSectionOwnership, type PersonaPreset, type PersonaContextValue,
|
|
12
|
+
} from 'portal-mojo/personas';
|
|
13
|
+
import { readPersisted, writePersisted, clearPersisted } from 'portal-mojo/client';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A **persona** is a working identity the user operates the portal AS — a
|
|
17
|
+
support agent, a brand owner, a compliance officer. Switching persona
|
|
18
|
+
reshapes the sidebar, the shell density, and "home". It changes NOTHING about
|
|
19
|
+
what the user may do.
|
|
20
|
+
|
|
21
|
+
## The one invariant that matters
|
|
22
|
+
|
|
23
|
+
**A persona is presentation ONLY. `<Guarded>` / `hasPermission` are the
|
|
24
|
+
security boundary.** `hasPermission` has no persona input by design (the
|
|
25
|
+
verify script greps for this); `sys.*` stays system-pinned and untouched.
|
|
26
|
+
The worst an over-optimistic persona can produce is an empty shell — every
|
|
27
|
+
menu item still permission-filters fail-closed, every route is still Guarded.
|
|
28
|
+
|
|
29
|
+
## Hats vs roles
|
|
30
|
+
|
|
31
|
+
- **Hat** — `gate` absent: available to every authenticated user. A pure
|
|
32
|
+
view preference ("show me the ops layout").
|
|
33
|
+
- **Role** — `gate` (any-of `PermSpec`): the persona appears only for users
|
|
34
|
+
holding one of its gate keys. Gate on **signature keys** — keys held by
|
|
35
|
+
exactly ONE preset. Gating on shared capability keys leaks portals
|
|
36
|
+
(`players.view` means "may read player records", not "is a support agent" —
|
|
37
|
+
the wmx 2026-08-09 audit found exactly this class). `auditPersonas`
|
|
38
|
+
mechanizes the check; strictness is opt-in.
|
|
39
|
+
|
|
40
|
+
## Declaring personas
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// boot.ts — categories FIRST, then personas that reference them by name
|
|
44
|
+
registerPermissionCategories({ support: ['support.queue', 'support.moderation'] });
|
|
45
|
+
definePersonas([
|
|
46
|
+
{ slug: 'support', label: 'Support', icon: 'bi-headset', home: '/support',
|
|
47
|
+
density: 'simple', gate: ['support.queue'],
|
|
48
|
+
grants: { categories: ['support'], keys: ['players.view'] } },
|
|
49
|
+
{ slug: 'explorer', label: 'Explorer', home: '/' }, // a hat: no gate, no grants
|
|
50
|
+
]);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`definePersonas` REPLACES the registry (declare once at boot; HMR re-runs are
|
|
54
|
+
safe). Duplicate slugs are ignored WITH a console.warn — first wins.
|
|
55
|
+
`availablePersonas(me)`: `null` me → `[]`; hats always included for an
|
|
56
|
+
authenticated user; roles via `hasPermission(me, gate)` (user-level — member
|
|
57
|
+
context is not consulted).
|
|
58
|
+
|
|
59
|
+
## PersonaProvider — the single owner
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
<PersonaProvider fallback="support">
|
|
63
|
+
<AppShell />
|
|
64
|
+
</PersonaProvider>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Owns the active persona and fans it out to:
|
|
68
|
+
|
|
69
|
+
1. **React context** — `usePersona()` → `{ persona, def, personas, available,
|
|
70
|
+
setPersona }` (throws outside the provider).
|
|
71
|
+
2. **The menu-resolution signal** (`ui/active-persona.ts`) — how
|
|
72
|
+
`resolveActiveMenu`/`SidebarNav` receive the persona without importing this
|
|
73
|
+
module.
|
|
74
|
+
3. **Root attributes** — `<html data-persona="support" data-density="simple">`
|
|
75
|
+
for persona-scoped styling. Unmount clears both signal and attributes.
|
|
76
|
+
|
|
77
|
+
Lifecycle: initial = valid persisted slug (`mojo:persona` via
|
|
78
|
+
`readPersisted`, validated against defined slugs — garbage is cleared, never
|
|
79
|
+
cast) → else a valid `fallback` prop → else the first defined persona. Once
|
|
80
|
+
`me` resolves and at least one persona is available, a persona the user
|
|
81
|
+
doesn't hold **snaps** to `available[0]` (state-only; the stored choice
|
|
82
|
+
survives for when grants return). Zero-available keeps the current defined
|
|
83
|
+
persona — safe because menus/routes stay fail-closed.
|
|
84
|
+
|
|
85
|
+
### Root styling law
|
|
86
|
+
|
|
87
|
+
Persona-scoped CSS variables MUST carry `:root` defaults:
|
|
88
|
+
|
|
89
|
+
```css
|
|
90
|
+
:root { --queue-accent: var(--accent); } /* the default is mandatory */
|
|
91
|
+
:root[data-persona='support'] { --queue-accent: var(--good); }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Cautionary example (wmx): a variable defined ONLY under
|
|
95
|
+
`[data-wmx-view='support']` rendered as the literal fallback off-lens — every
|
|
96
|
+
surface styled per-persona was broken for every other persona. Pre-auth the
|
|
97
|
+
attribute may also briefly be absent entirely. Default at `:root`, override
|
|
98
|
+
under `data-persona`. Same for `data-density` (`'simple' | 'dense'`; the
|
|
99
|
+
provider stamps `dense` when a persona doesn't say).
|
|
100
|
+
|
|
101
|
+
## Menus: persona is a resolution INPUT
|
|
102
|
+
|
|
103
|
+
`MenuConfig.personas?: string[]` scopes a menu to persona slugs. Menus are
|
|
104
|
+
**declared ONCE** at import/boot via `registerMenus` — never re-registered to
|
|
105
|
+
switch personas (that was wmx's `applyViewMenu` mutation; the primitive
|
|
106
|
+
replaces it). Resolution (`ui/menu-registry.ts`):
|
|
107
|
+
|
|
108
|
+
1. First registered **eligible** menu whose visible items contain the route —
|
|
109
|
+
persona-scoped menus are eligible only while their persona is active, so
|
|
110
|
+
two personas may both carry `/players` and the active persona's menu wins.
|
|
111
|
+
2. **2a.** The active persona's own first menu (preferring one with visible
|
|
112
|
+
items) — beats `defaultMenu`.
|
|
113
|
+
3. 2b. `defaultMenu`, then the agnostic fallback chain.
|
|
114
|
+
|
|
115
|
+
Apps that never touch personas are unaffected: the signal stays `null`,
|
|
116
|
+
persona-scoped menus are simply never eligible, agnostic menus resolve
|
|
117
|
+
exactly as before.
|
|
118
|
+
|
|
119
|
+
## PersonaSection — routes + sidebar from one declaration
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
const SECTIONS: PersonaSection[] = [{
|
|
123
|
+
id: 'wmx:support', label: 'Support Console', icon: 'bi-headset',
|
|
124
|
+
basePath: 'support', permissions: ['players.view', 'support.queue'],
|
|
125
|
+
groupScoped: true,
|
|
126
|
+
personas: { support: {}, owner: { label: 'Support (read)', navGroup: 'Oversight' } },
|
|
127
|
+
element: <ConsoleHome />,
|
|
128
|
+
children: [
|
|
129
|
+
{ path: 'redemptions', label: 'Redemptions', icon: 'bi-cash-coin',
|
|
130
|
+
navGroup: 'Work queues', permissions: ['redemptions.view', 'sys.admin'],
|
|
131
|
+
element: <RedemptionsQueuePage /> },
|
|
132
|
+
{ path: 'player/:id', label: 'Player', hidden: true,
|
|
133
|
+
permissions: ['players.view'], element: <PlayerView /> }, // route, no nav row
|
|
134
|
+
],
|
|
135
|
+
}];
|
|
136
|
+
|
|
137
|
+
const router = createHashRouter([{ element: <AppShell />, children: [
|
|
138
|
+
...personaSectionRoutes(SECTIONS), /* … */
|
|
139
|
+
]}]);
|
|
140
|
+
registerMenus(personaSectionsMenu(SECTIONS)); // ONCE, at boot
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `personaSectionRoutes` — every element wrapped in fail-closed
|
|
144
|
+
`<Guarded permission={…} fallback={null}>`; `groupScoped` adds
|
|
145
|
+
`<RequiresGroup>` INSIDE the guard. Deliberately **not** admin/core's
|
|
146
|
+
`guardedElement`: that wraps `AdminGlobalScope`, which blanks group/member —
|
|
147
|
+
right for the framework admin, wrong for product surfaces where member
|
|
148
|
+
grants count. Hidden children still route.
|
|
149
|
+
- `personaSectionsMenu` — ONE `MenuConfig` per persona slug
|
|
150
|
+
(`name: 'persona:<slug>'`, `personas: [slug]`); items are that persona's
|
|
151
|
+
sections with per-persona `label`/`navGroup`/`order` overrides applied;
|
|
152
|
+
`navGroup` renders as side-label divider rows; every item carries the
|
|
153
|
+
section/child permissions so `itemVisible` filters fail-closed.
|
|
154
|
+
- `usePersonaFollowsRoute()` — call once in the shell: when navigation lands
|
|
155
|
+
on a route owned by other personas' sections and the user HOLDS an owner,
|
|
156
|
+
the persona converges to it. Never navigates. (Owners derive from the
|
|
157
|
+
sections passed to the two builders above — wire routes/menus first.)
|
|
158
|
+
- `personaHome(slug)` — the persona's landing route (unknown slug → warn + `/`).
|
|
159
|
+
|
|
160
|
+
## Phase B — presets as data
|
|
161
|
+
|
|
162
|
+
`grants` on a persona is its preset: `keys` (granulars) plus `categories`
|
|
163
|
+
(**names** of categories the app already registered via
|
|
164
|
+
`registerPermissionCategories` — register categories first; an unknown name
|
|
165
|
+
expands to nothing with a warn).
|
|
166
|
+
|
|
167
|
+
- `personaPresets()` → `{slug, label, keys}[]` — deduped union per persona;
|
|
168
|
+
hats without `grants` are omitted.
|
|
169
|
+
- `registerPersonaMemberPermissions({ tooltipBySlug? })` — feeds the union of
|
|
170
|
+
preset keys into the admin members catalog (`registerMemberPermissions`) so
|
|
171
|
+
team UIs render switches. `sys.*`/non-editable keys are dropped up front —
|
|
172
|
+
system-pinned keys can never be member grants.
|
|
173
|
+
- The **"apply preset"** button (check a preset's boxes on a member form) is
|
|
174
|
+
app-side UI over this data; the server stays authoritative on every write.
|
|
175
|
+
|
|
176
|
+
## auditPersonas
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
auditPersonas(getPersonas(), personaPresets()) // leak report
|
|
180
|
+
auditPersonas(getPersonas(), personaPresets(), { strict: 'signature-diagonal' })
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Default findings: a persona's gate key held by ANOTHER persona's preset (that
|
|
184
|
+
preset's holders see a portal that isn't theirs). Strict adds: every gate key
|
|
185
|
+
held by exactly one preset, and each persona's same-slug preset satisfies its
|
|
186
|
+
own gate. Hats are skipped. Run it in a boot-time dev assert or a verify
|
|
187
|
+
script; `findings: []` is the pass state.
|
|
188
|
+
|
|
189
|
+
## Persistence helper (`portal-mojo/client`)
|
|
190
|
+
|
|
191
|
+
`readPersisted(key, validate)` / `writePersisted(key, value)` /
|
|
192
|
+
`clearPersisted(key)` — the params.ts localStorage discipline for single
|
|
193
|
+
values: feature-detected storage, validate-don't-cast, invalid/unparseable
|
|
194
|
+
entries **cleared and null**, silent no-op writes. JSON canonical; a legacy
|
|
195
|
+
bare-string value is offered to the validator raw and self-heals to JSON on
|
|
196
|
+
the next write.
|
|
197
|
+
|
|
198
|
+
## Pitfalls
|
|
199
|
+
|
|
200
|
+
- **Menus are declared once.** Re-registering per persona reintroduces
|
|
201
|
+
mutable menu state; the persona input + `MenuConfig.personas` replaces it.
|
|
202
|
+
- **Pre-auth optimism.** Before `me` resolves the stored persona is applied;
|
|
203
|
+
its menu may render with zero visible items until grants load. That is the
|
|
204
|
+
fail-closed contract working, not a bug — don't "fix" it by widening
|
|
205
|
+
`itemVisible`.
|
|
206
|
+
- **localStorage is hostile.** Never `getItem(...) as Persona` — always the
|
|
207
|
+
validate-then-clear read (`readPersisted`); the provider already does this
|
|
208
|
+
for `mojo:persona`.
|
|
209
|
+
- **Don't gate on shared capability keys** (role personas) — signature keys
|
|
210
|
+
only; `auditPersonas` strict mode enforces it.
|
|
211
|
+
- **Don't reach for AdminSection** for persona surfaces — its guard blanks
|
|
212
|
+
the active group, so member grants stop counting.
|
package/docs/realtime.md
CHANGED
|
@@ -28,8 +28,50 @@ disable this shared provider.
|
|
|
28
28
|
separately and is never explicitly subscribed by the client. That automatic
|
|
29
29
|
topic is a delivery scope, not a presence feed.
|
|
30
30
|
- Framework messages, one wrapped `{type:'message',data,topic?,timestamp?}`
|
|
31
|
-
layer, and direct application events share one dispatch boundary.
|
|
32
|
-
|
|
31
|
+
layer, and direct application events share one dispatch boundary. Every
|
|
32
|
+
projected event carries `source: 'wrapped' | 'direct'` so consumers can keep
|
|
33
|
+
capability-bearing protocols on their exact wire source. Malformed frames
|
|
34
|
+
drop. No generic Query invalidation is performed.
|
|
35
|
+
|
|
36
|
+
## Error frames
|
|
37
|
+
|
|
38
|
+
A top-level `{type:'error'}` frame is attributed in order:
|
|
39
|
+
|
|
40
|
+
1. **During authentication** — auth failure: the client closes and suspends
|
|
41
|
+
until the token value changes. Surfaces through the lifecycle status only;
|
|
42
|
+
no `error` event is dispatched.
|
|
43
|
+
2. **While a subscribe/unsubscribe is pending** — that operation's denial
|
|
44
|
+
(acknowledgements are uncorrelated, so denial is a generic `error`); the
|
|
45
|
+
topic reports `denied`. Surfaces through the topic status only; no `error`
|
|
46
|
+
event is dispatched.
|
|
47
|
+
3. **Otherwise** — a backend answer to an application frame (chat rate limit,
|
|
48
|
+
room rules, moderation block). Dispatched as an observable `error` event
|
|
49
|
+
with `source: 'direct'`.
|
|
50
|
+
|
|
51
|
+
Application error frames carry the message in `error` and may carry a
|
|
52
|
+
`reasons` array: `{type:'error', error:'Rate limit exceeded'}`,
|
|
53
|
+
`{type:'error', error:'Message blocked by moderation', reasons:[...]}`.
|
|
54
|
+
|
|
55
|
+
Known ambiguity, inherent to the uncorrelated wire: an application error
|
|
56
|
+
landing while a subscribe/unsubscribe is pending is still read as that
|
|
57
|
+
operation's denial.
|
|
58
|
+
|
|
59
|
+
Consumer pattern — a chat composer listens for `error` alongside its ack event
|
|
60
|
+
and surfaces the backend's message instead of guessing from a timeout:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
const chatError = defineRealtimeEvent('error', (value) => {
|
|
64
|
+
const frame = value as {error?: unknown; reasons?: unknown};
|
|
65
|
+
return typeof frame?.error === 'string'
|
|
66
|
+
? {error: frame.error, reasons: Array.isArray(frame.reasons) ? frame.reasons : undefined}
|
|
67
|
+
: null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
useRealtimeEvent(chatError, ({data}) => showSendFailure(data.error, data.reasons));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The may-not-have-sent timeout heuristic remains the right fallback for the
|
|
74
|
+
no-frame-at-all case — a socket drop mid-send produces no error frame at all.
|
|
33
75
|
|
|
34
76
|
The transport does not invent application events. Current django-mojo has no
|
|
35
77
|
canonical record-created/updated/deleted stream and no canonical presence
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# RouteError — the route-level last-resort error card
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { RouteError, RouteErrorCard, describeRouteError, isChunkLoadError, isNotFoundError } from 'portal-mojo/ui';
|
|
5
|
+
// also exported from 'portal-mojo/ui/shell'
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
Any error that reaches the router without this renders React Router's raw
|
|
9
|
+
default — "Unexpected Application Error!" on a white page. `RouteError` is
|
|
10
|
+
the styled replacement: real message + stack for `Error` throws, a
|
|
11
|
+
status/body/JSON dump for object throws (rejected mojo envelopes — never
|
|
12
|
+
`[object Object]`), a "Page not found" branch for 404s, and Reload +
|
|
13
|
+
Back-to-home actions. Token classes only; renders correctly in both themes,
|
|
14
|
+
inside the shell or standalone.
|
|
15
|
+
|
|
16
|
+
## The error-handling ladder
|
|
17
|
+
|
|
18
|
+
1. **`safeNode` / `RenderGuard`** (#1602) — per-site: table cells, detail
|
|
19
|
+
sections. Degrade to a marker; the page survives.
|
|
20
|
+
2. **`LazyPageBoundary`** (#1557, narrowed by #1604) — **chunk-load failures
|
|
21
|
+
only** (stale deploy): the "Retry" card. Any other error is rethrown —
|
|
22
|
+
a real page crash must not be mislabeled "bundle unavailable".
|
|
23
|
+
3. **Child-level `errorElement`** — a pathless wrapper around the app's
|
|
24
|
+
routes: a page crash renders the card **inside the app shell**
|
|
25
|
+
(Sidebar/TopNav stay alive).
|
|
26
|
+
4. **Root-level `errorElement`** — 404s and shell crashes. By router
|
|
27
|
+
semantics it *replaces* the root element, so the card renders bare and
|
|
28
|
+
is styled to stand alone.
|
|
29
|
+
|
|
30
|
+
## Wiring (the portal scaffold ships this)
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
const router = createHashRouter([
|
|
34
|
+
{
|
|
35
|
+
path: '/',
|
|
36
|
+
element: <RequireAuth><App /></RequireAuth>,
|
|
37
|
+
errorElement: <RouteError />, // 404s + shell crashes (bare)
|
|
38
|
+
children: [
|
|
39
|
+
{ errorElement: <RouteError />, children: [...adminRoutes] }, // in-shell page crashes
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{ path: '/auth', element: <AuthLayout />, errorElement: <RouteError />, children: [/* lazy pages */] },
|
|
43
|
+
]);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
| Export | What |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `RouteError({ homeTo? })` | Data-router wrapper: reads `useRouteError()`. Use as `errorElement`. |
|
|
51
|
+
| `RouteErrorCard({ error, homeTo?, onReload? })` | Presentational card. Use outside data routers (`useRouteError` THROWS there — the showcase's plain `<HashRouter>` is why this split exists). `onReload` replaces the hard `window.location.reload()` on both actions (demos/tests). |
|
|
52
|
+
| `describeRouteError(err)` | Total stringifier: Error → message+stack; object → status/statusText head + string body + JSON dump (circular-safe); else `String(err)`. |
|
|
53
|
+
| `isNotFoundError(err)` | Structural router-ErrorResponse check with `status === 404`. |
|
|
54
|
+
| `isChunkLoadError(err)` | Vite dynamic-import failure predicate. When in doubt it returns `false` — a rethrown real crash with the true message beats a wrong Retry card. |
|
|
55
|
+
|
|
56
|
+
## Caveats
|
|
57
|
+
|
|
58
|
+
- **Unauthenticated 404 deep links** see "Page not found" instead of the
|
|
59
|
+
login redirect: the root `errorElement` replaces `RequireAuth`. Deliberate
|
|
60
|
+
— Back-to-home re-enters through the auth guard.
|
|
61
|
+
- The 404 branch suppresses the JSON dump (noise); the crash branch shows
|
|
62
|
+
everything a developer needs. The crash still logs to the console.
|
|
63
|
+
- The card never itself crashes: no queries, no app context,
|
|
64
|
+
`describeRouteError` is total.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -26,9 +26,22 @@
|
|
|
26
26
|
},
|
|
27
27
|
"exports": {
|
|
28
28
|
"./client": "./src/client/index.ts",
|
|
29
|
+
"./client/runtime": "./src/client/runtime.ts",
|
|
29
30
|
"./ui": "./src/ui/index.ts",
|
|
31
|
+
"./ui/shell": "./src/ui/shell.ts",
|
|
30
32
|
"./charts": "./src/charts/index.ts",
|
|
31
|
-
"./
|
|
33
|
+
"./personas": "./src/personas/index.ts",
|
|
34
|
+
"./admin": "./src/admin/index.ts",
|
|
35
|
+
"./admin/core": "./src/admin/core/index.ts",
|
|
36
|
+
"./admin/registry": "./src/admin/registry.ts",
|
|
37
|
+
"./admin/identity": "./src/admin/public/identity.ts",
|
|
38
|
+
"./admin/security": "./src/admin/public/security.ts",
|
|
39
|
+
"./admin/observability": "./src/admin/public/observability.ts",
|
|
40
|
+
"./admin/operations": "./src/admin/public/operations.ts",
|
|
41
|
+
"./admin/infrastructure": "./src/admin/public/infrastructure.ts",
|
|
42
|
+
"./admin/communications": "./src/admin/public/communications.ts",
|
|
43
|
+
"./admin/assistant": "./src/admin/public/assistant.ts",
|
|
44
|
+
"./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts"
|
|
32
45
|
},
|
|
33
46
|
"scripts": {
|
|
34
47
|
"typecheck": "tsc --noEmit"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState, type MouseEvent } from 'react';
|
|
2
|
+
import { useCan } from '../../client/runtime';
|
|
3
|
+
import { useRightPanel } from '../../ui/RightPanel';
|
|
4
|
+
import { ASSISTANT_PERMISSIONS } from './permissions';
|
|
5
|
+
|
|
6
|
+
export function AssistantLauncher() {
|
|
7
|
+
const { can } = useCan(ASSISTANT_PERMISSIONS);
|
|
8
|
+
const panel = useRightPanel();
|
|
9
|
+
const [loading, setLoading] = useState(false);
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
|
|
12
|
+
if (!can) return null;
|
|
13
|
+
const open = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
14
|
+
const launcher = event.currentTarget;
|
|
15
|
+
setLoading(true); setError('');
|
|
16
|
+
try {
|
|
17
|
+
const { AssistantPanel } = await import('./AssistantPanel');
|
|
18
|
+
panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, launcher);
|
|
19
|
+
} catch { setError('Assistant could not load. Click to retry.'); }
|
|
20
|
+
finally { setLoading(false); }
|
|
21
|
+
};
|
|
22
|
+
return <button type="button" className="btn-icon" title={error || 'Open Assistant'} aria-label={error || 'Open Assistant'} aria-pressed={panel.descriptor?.key === 'assistant:new'} disabled={loading} onClick={(event) => void open(event)}><i className="bi bi-stars" aria-hidden="true" /></button>;
|
|
23
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
|
|
2
|
+
import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
|
|
3
3
|
import type { RightPanelRenderContext } from '../../ui';
|
|
4
4
|
import { AssistantFeed } from './AssistantFeed';
|
|
5
5
|
import { sendAssistantMessage } from './api';
|
|
6
6
|
import type { AssistantConversation, AssistantMessage } from './types';
|
|
7
7
|
import type { FileReference } from '../../client/record-feed';
|
|
8
8
|
import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeProgress, type AssistantRealtimeTurn } from './streaming';
|
|
9
|
+
import { ASSISTANT_PERMISSIONS } from './permissions';
|
|
9
10
|
|
|
10
|
-
export
|
|
11
|
+
export { ASSISTANT_PERMISSIONS } from './permissions';
|
|
11
12
|
|
|
12
13
|
export function AssistantPanel({ context, initialConversation = null }: { context: RightPanelRenderContext; initialConversation?: AssistantConversation | null }) {
|
|
13
14
|
const { can } = useCan(ASSISTANT_PERMISSIONS);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mojoCall, mojoDelete, mojoList, type Params } from '../../client';
|
|
1
|
+
import { mojoCall, mojoDelete, mojoList, type Params } from '../../client/runtime';
|
|
2
2
|
import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
|
|
3
3
|
import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
|
|
4
4
|
import type { FileReference } from '../../client/record-feed';
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type { AdminSection } from '../index';
|
|
2
|
-
import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
|
|
3
|
-
import { ConversationsPage, MemoriesPage, SkillsPage } from './pages';
|
|
4
1
|
|
|
5
2
|
export * from './types';
|
|
6
3
|
export * from './data';
|
|
@@ -11,11 +8,4 @@ export * from './launchers';
|
|
|
11
8
|
export * from './pages';
|
|
12
9
|
export * from './streaming';
|
|
13
10
|
|
|
14
|
-
export
|
|
15
|
-
id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
|
|
16
|
-
routes: [
|
|
17
|
-
{ path: 'conversations', label: 'Conversations', component: ConversationsPage, permissions: ASSISTANT_PERMISSIONS },
|
|
18
|
-
{ path: 'skills', label: 'Skills', component: SkillsPage, permissions: ASSISTANT_PERMISSIONS },
|
|
19
|
-
{ path: 'memories', label: 'Memory', component: MemoriesPage, permissions: ['sys.assistant'] },
|
|
20
|
-
],
|
|
21
|
-
};
|
|
11
|
+
export { ASSISTANT_ADMIN_SECTION } from '../domains/assistant';
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type MouseEvent } from 'react';
|
|
2
|
-
import { useCan } from '../../client';
|
|
3
|
-
import { useRightPanel } from '../../ui';
|
|
2
|
+
import { useCan } from '../../client/runtime';
|
|
3
|
+
import { useRightPanel } from '../../ui/RightPanel';
|
|
4
4
|
import { SECURITY_VIEW_PERMS } from '../security-permissions';
|
|
5
|
-
import {
|
|
5
|
+
import { ASSISTANT_PERMISSIONS } from './permissions';
|
|
6
6
|
import { createAssistantContext } from './api';
|
|
7
7
|
|
|
8
|
-
export
|
|
9
|
-
const { can } = useCan(ASSISTANT_PERMISSIONS);
|
|
10
|
-
const panel = useRightPanel();
|
|
11
|
-
useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
|
|
12
|
-
if (!can) return null;
|
|
13
|
-
return <button type="button" className="btn-icon" title="Open Assistant" aria-label="Open Assistant" aria-pressed={panel.descriptor?.key === 'assistant:new'} onClick={(event) => panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, event.currentTarget)}><i className="bi bi-stars" aria-hidden="true" /></button>;
|
|
14
|
-
}
|
|
8
|
+
export { AssistantLauncher } from './AssistantLauncher';
|
|
15
9
|
|
|
16
10
|
export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }: { model: 'incident.Incident' | 'incident.Ticket'; pk: number; label?: string }) {
|
|
17
11
|
const assistant = useCan(ASSISTANT_PERMISSIONS);
|
|
@@ -25,7 +19,7 @@ export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }:
|
|
|
25
19
|
if (!assistant.can || !security.can) return null;
|
|
26
20
|
const launch = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
27
21
|
const launcher = event.currentTarget; const run = ++generation.current; setLoading(true); setError('');
|
|
28
|
-
try { const conversation = await createAssistantContext(model, pk); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
|
|
22
|
+
try { const [conversation, { AssistantPanel }] = await Promise.all([createAssistantContext(model, pk), import('./AssistantPanel')]); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
|
|
29
23
|
catch (cause) { if (generation.current === run) setError(cause instanceof Error ? cause.message : 'Could not create context'); }
|
|
30
24
|
finally { if (generation.current === run) setLoading(false); }
|
|
31
25
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
|
|
2
|
+
import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
|
|
3
3
|
import { Badge, fmt, modal, toast } from '../../ui';
|
|
4
4
|
import { AssistantFeed } from './AssistantFeed';
|
|
5
5
|
import { deleteAssistantConversation, deleteAssistantMemory, deleteAssistantSkill, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ASSISTANT_PERMISSIONS = ['sys.view_admin', 'sys.assistant'];
|