portal-mojo 0.2.5 → 0.2.7

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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/docs/README.md +1 -0
  3. package/docs/admin-identity-directories-sources.json +130 -0
  4. package/docs/admin-identity-directories.md +60 -0
  5. package/docs/admin-identity-users.md +6 -1
  6. package/docs/admin-members.md +16 -0
  7. package/docs/admin-network-security.md +1 -1
  8. package/docs/menus-and-access.md +6 -2
  9. package/docs/user-menu.md +131 -0
  10. package/package.json +1 -1
  11. package/src/admin/core/index.ts +4 -4
  12. package/src/admin/domains/identity.ts +19 -1
  13. package/src/admin/identity/groups/GroupDetail.tsx +287 -0
  14. package/src/admin/identity/groups/GroupsPage.tsx +125 -0
  15. package/src/admin/identity/groups/group-permissions.ts +5 -0
  16. package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
  17. package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
  18. package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
  19. package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
  20. package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
  21. package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
  22. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
  23. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
  24. package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
  25. package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
  26. package/src/admin/identity/groups/group-sections/models.ts +110 -0
  27. package/src/admin/identity/groups/group-sections/shared.ts +21 -0
  28. package/src/admin/identity/groups/index.ts +6 -0
  29. package/src/admin/identity/groups/models.ts +70 -0
  30. package/src/admin/identity/index.ts +1 -0
  31. package/src/admin/identity/members/MemberDetail.tsx +32 -3
  32. package/src/admin/identity/members/models.ts +3 -0
  33. package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
  34. package/src/admin/identity/users/UserDetail.tsx +2 -2
  35. package/src/admin/identity/users/index.ts +2 -0
  36. package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
  37. package/src/admin/registry.ts +3 -1
  38. package/src/ui/FileField.tsx +1 -10
  39. package/src/ui/SidebarNav.tsx +2 -2
  40. package/src/ui/UserMenu.tsx +401 -0
  41. package/src/ui/index.ts +1 -0
  42. package/src/ui/safe-url.ts +14 -0
  43. package/src/ui/shell.ts +1 -0
package/README.md CHANGED
@@ -23,8 +23,8 @@ additional exports are required.
23
23
  |---|---|
24
24
  | `portal-mojo/client` | Typed django-mojo protocol layer: envelope unwrap at exactly one boundary (a failed save **rejects**), `start`/`size` paging, `'-field'` sort, Django lookups, the URL-synced `useTableParams` store (single source of truth for table state), TanStack Query hooks. Auth client: password / magic-link / passkey login, forgot/reset, cross-origin handoff (`?auth_code=` scrub-before-network), single-flight refresh + pre-request gate (synthetic-401 reject, `/api/token/refresh` recursion guard), `X-Mojo-UID` device header, `getAuthSnapshot`/`subscribeAuth` for React. Auth-challenged realtime transport: `RealtimeProvider`/`RealtimeClient`, refcounted topics, typed event projection, and a deterministic mock. Boot auth with `initAuth()`. The in-memory HTTP mock transport lives here too — it is the wire contract's executable spec and evolves in lockstep with the client (any seeded active user logs in with email + `"mojo"`). |
25
25
  | `portal-mojo/client/runtime` | Stable, narrow client boundary for first-party application runtime code. It omits eager mock-test controls; the mock transport itself loads on the first mock request. |
26
- | `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
27
- | `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, modal/toast hosts, password helpers, and formatters. |
26
+ | `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `UserMenu` (top-nav account menu), `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
27
+ | `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, account menu (`UserMenu`), modal/toast hosts, password helpers, and formatters. |
28
28
  | `portal-mojo/charts` | Dependency-free SVG charts: `SeriesChart` (line/bar/area, stacked bars, legend toggle, crosshair tooltip) and `MetricsChart` (granularity/range/type control bar for `/api/metrics/fetch`). |
29
29
  | `portal-mojo/admin/core` | Side-effect-light Admin contracts and host: `AdminSection`, the discriminated `AdminRoute` XOR, `adminSectionRoutes`, `adminSectionsMenu`, and `AdminLazyPage`. |
30
30
  | `portal-mojo/admin/registry` | Stable, narrow built-in `ADMIN_SECTIONS` roster for first-party hosts, without broad page barrels. It installs the optional Email→DNS adapter synchronously so communications routes work before DNS navigation. |
package/docs/README.md CHANGED
@@ -51,6 +51,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
51
51
  | [image-editor.md](image-editor.md) | Full-resolution crop/transform/filter composition, bounded history, PNG Blob results, modal helper, and ImageField pre-upload editing |
52
52
  | [loading.md](loading.md) | `Spinner`, `busy()`/`busyWhile()` blocking overlay, `ViewLoader`, `InlineLoader`, `Busy` — the anti-flash delay rule and skeleton-vs-loader guidance |
53
53
  | [popover.md](popover.md) | Anchored top-layer popover shell — placement, reposition, outside/Escape close, the dialog stacking story |
54
+ | [user-menu.md](user-menu.md) | Top-nav account menu — avatar trigger, identity header, theme control, app items, pending Sign out, signed-out matrix, keyboard map |
54
55
  | [taginput.md](taginput.md) | Chip/tag entry — CSV wire shape, keyboard matrix, validation + inline errors |
55
56
  | [combobox.md](combobox.md) | The house autocomplete — options with descriptions/meta, commit-only change pipeline, allowCustom, ARIA |
56
57
  | [location-address.md](location-address.md) | Six-endpoint LocationClient, private provider sessions, stale guards, controlled AddressField, atomic form patching |
@@ -0,0 +1,130 @@
1
+ {
2
+ "package": "portal-mojo",
3
+ "package_version": "0.2.5",
4
+ "source_repository": "https://github.com/NativeMojo/portal-mojo",
5
+ "source_tag": "v0.2.5",
6
+ "source_commit": "93c7fdfeb3096d33010e72bd4483cf9ca6e3d6f5",
7
+ "license": "Apache-2.0",
8
+ "license_file": "packages/portal-mojo/LICENSE",
9
+ "files": [
10
+ {
11
+ "source_path": "apps/portal/src/group-permissions.ts",
12
+ "source_sha256": "6237ad45328ecb9637a1a07d8954dd27cd019e22fa32942f5e7d05a76037fb5c",
13
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-permissions.ts",
14
+ "package_sha256": "b909f5a5cf029b8b910a70e6817fea8abd9ded6797af14a16170b2695c0c1699",
15
+ "adaptation": "Moves system-pinned group permission clauses into the canonical package identity surface."
16
+ },
17
+ {
18
+ "source_path": "apps/portal/src/models.ts",
19
+ "source_sha256": "0b98a906a774e572f9109703fe22444c3a2e190ca5e02c3a93dfbd02cc3fcb84",
20
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/models.ts",
21
+ "package_sha256": "6ac12a64d00e409874bc1b13d7941cf54c38a715af5ebcfddd2b2e9257356759",
22
+ "adaptation": "Extracts the Group model/type and group kind options; shared User, Member and Log models remain owned by their package domains."
23
+ },
24
+ {
25
+ "source_path": "apps/portal/src/pages/ApiKeysPage.tsx",
26
+ "source_sha256": "3d338cf77c3110b72a9361565c1cced10a7fccc52c486e3dafda119d66c676b2",
27
+ "package_path": "packages/portal-mojo/src/admin/identity/users/PersonalApiKeysPage.tsx",
28
+ "package_sha256": "f377d8a97b5c0ccbce745c333fdd7e4a5fd5f5a89b868b058677b31e3efe64ff",
29
+ "adaptation": "Expanded row facts and actions are presented in a native DetailView modal that observes the current API key record."
30
+ },
31
+ {
32
+ "source_path": "apps/portal/src/pages/GroupDetail.tsx",
33
+ "source_sha256": "01a8a6032a59a337636ec28e5e2aef90717e5bc00656807135e02820a92a394b",
34
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/GroupDetail.tsx",
35
+ "package_sha256": "e26943218e3e8f5ac79ddc7f1b0bd40736bcab0bd6e9c694e01830086349a4e2",
36
+ "adaptation": "Raw metadata is visible in the explicit Metadata section without a collapsible control."
37
+ },
38
+ {
39
+ "source_path": "apps/portal/src/pages/GroupsPage.tsx",
40
+ "source_sha256": "82e4aa5e7ebf67832bbd3bcf7e15dcfdeea64383039cc426591fa15262e4d4c8",
41
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/GroupsPage.tsx",
42
+ "package_sha256": "e16f3d0630fdb1937cf667cd2abe8cd1aabc9bb2b731113112930e66bde2f1df",
43
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
44
+ },
45
+ {
46
+ "source_path": "apps/portal/src/pages/group-sections/AuditSection.tsx",
47
+ "source_sha256": "49848f57e5050c0008aa83f7092af9abb8f55d262791a2591e525eaa6d741df4",
48
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/AuditSection.tsx",
49
+ "package_sha256": "07a40636549a0a1d8636a115ee330313b84c0c82500890c6464301fd39de1e06",
50
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
51
+ },
52
+ {
53
+ "source_path": "apps/portal/src/pages/group-sections/AuthConfigDialog.tsx",
54
+ "source_sha256": "f42e18a802f206a2b3f30248c30ad12887598b170deb73b532232a6bf89699dd",
55
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx",
56
+ "package_sha256": "e2280185bde7eab3ca7108f7d6da9e0e66c4f4d8323ec97f3fd7d49376932b65",
57
+ "adaptation": "Raw auth overrides remain visible in the explicit configuration dialog without collapsible content."
58
+ },
59
+ {
60
+ "source_path": "apps/portal/src/pages/group-sections/EventsSection.tsx",
61
+ "source_sha256": "b8cb12ae99d7c8d5931a090b7be8ea4e0373ff4657e786094e9ac862167d43ec",
62
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/EventsSection.tsx",
63
+ "package_sha256": "f0004605fbf7ceb2704adb42c0a3dd14dc5a468c445f0f94f2548fbc5f1bfeb4",
64
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
65
+ },
66
+ {
67
+ "source_path": "apps/portal/src/pages/group-sections/GeofenceSection.tsx",
68
+ "source_sha256": "d0ce22f5ef922371d7a78c353e1a29e5d9ca4932e45814499e9c3cdeda7dc137",
69
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/GeofenceSection.tsx",
70
+ "package_sha256": "227850a8ef844867ea55d599997144b462ee058967224c361dd821713d1b677a",
71
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
72
+ },
73
+ {
74
+ "source_path": "apps/portal/src/pages/group-sections/IdentitySection.tsx",
75
+ "source_sha256": "cda776a26f1e21b73820ed88ab7dcbafdaf2b146c4c3ee4bf6b24dcbaeecc9f4",
76
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/IdentitySection.tsx",
77
+ "package_sha256": "888ff5f7a70c80d8a868f13675b39af9a2a7076d6ea5fbc323493c0265ce9880",
78
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
79
+ },
80
+ {
81
+ "source_path": "apps/portal/src/pages/group-sections/MembersSection.tsx",
82
+ "source_sha256": "dc2e99924b8ba856ce2ed48dffae082e98cac9ef78df3ddb1f05fe8a71f9725d",
83
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/MembersSection.tsx",
84
+ "package_sha256": "abccb58bcdb62d0d1de2611cad080ca7237e0dfa781b10c5fc1d7e251398f628",
85
+ "adaptation": "Passes the group navigation callback into UserDetail to keep cross-record navigation available."
86
+ },
87
+ {
88
+ "source_path": "apps/portal/src/pages/group-sections/OverviewSection.tsx",
89
+ "source_sha256": "fb395569018b89e6efd45cea241ba665b73efd15549802a6f0d98d90116ce894",
90
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/OverviewSection.tsx",
91
+ "package_sha256": "08b460272af71ba6209fe388b71f37f79d94a9fecc21c0c2ee3abaf11f01372f",
92
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
93
+ },
94
+ {
95
+ "source_path": "apps/portal/src/pages/group-sections/SubGroupsSection.tsx",
96
+ "source_sha256": "81074a2d74d80fda93e40379b87620807c31db7979736f90559e359bdafc0672",
97
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/SubGroupsSection.tsx",
98
+ "package_sha256": "61e412e709afb4384fdcbaa1d8fbb6df6ba414634ecc6aaa8201c66cf722af93",
99
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
100
+ },
101
+ {
102
+ "source_path": "apps/portal/src/pages/group-sections/auth-config.ts",
103
+ "source_sha256": "37d75dff84bca2577bcd1804c77aa0ef1519e4d913dcfdf7914266045fdbabe6",
104
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/auth-config.ts",
105
+ "package_sha256": "37d75dff84bca2577bcd1804c77aa0ef1519e4d913dcfdf7914266045fdbabe6",
106
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
107
+ },
108
+ {
109
+ "source_path": "apps/portal/src/pages/group-sections/geofence-data.ts",
110
+ "source_sha256": "169d5feb72fbe16a0463d591328ce49963e95f1bc0f5f7e5fa9ed271a5ace8e6",
111
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/geofence-data.ts",
112
+ "package_sha256": "169d5feb72fbe16a0463d591328ce49963e95f1bc0f5f7e5fa9ed271a5ace8e6",
113
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
114
+ },
115
+ {
116
+ "source_path": "apps/portal/src/pages/group-sections/models.ts",
117
+ "source_sha256": "ec6a2fd700a0a91e8f1d9e49c364ffe5466db340b435004971e719da08b49371",
118
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/models.ts",
119
+ "package_sha256": "ec6a2fd700a0a91e8f1d9e49c364ffe5466db340b435004971e719da08b49371",
120
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
121
+ },
122
+ {
123
+ "source_path": "apps/portal/src/pages/group-sections/shared.ts",
124
+ "source_sha256": "68aa819b14edcbbd8ae399d5c5a74360cc50ffd281b6d0d3be1c4f8ebc4e4f6c",
125
+ "package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/shared.ts",
126
+ "package_sha256": "68aa819b14edcbbd8ae399d5c5a74360cc50ffd281b6d0d3be1c4f8ebc4e4f6c",
127
+ "adaptation": "Moved into the package identity domain and updated package-relative imports."
128
+ }
129
+ ]
130
+ }
@@ -0,0 +1,60 @@
1
+ # Admin Groups and Personal API Keys
2
+
3
+ The global Admin registry contributes Groups and Personal API Keys to the
4
+ Identity & Access navigation group. `adminSectionRoutes()` and
5
+ `adminSectionsMenu()` therefore mount the same pages in the standalone
6
+ reference portal and in an embedded System Admin boundary:
7
+
8
+ ```tsx
9
+ import {
10
+ GROUPS_ADMIN_SECTION,
11
+ GROUP_VIEW_PERMS,
12
+ GroupDetail,
13
+ GroupModel,
14
+ GroupsPage,
15
+ PERSONAL_API_KEYS_ADMIN_SECTION,
16
+ PersonalApiKeysPage,
17
+ } from 'portal-mojo/admin/identity';
18
+ ```
19
+
20
+ The registered routes are `/groups` and `/apikeys`; an embedded mount prefixes
21
+ both routes. Group directory and detail access use the system-pinned
22
+ `sys.groups | sys.view_groups` clause. Group mutations use
23
+ `sys.groups | sys.manage_groups`. Active-group membership cannot satisfy
24
+ these gates. The personal key section uses the system identity-view clause,
25
+ while its API requests remain scoped to the signed-in user's own keys.
26
+
27
+ ## Groups
28
+
29
+ `GroupsPage` retains the native server-owned `ModelTable` search, filters,
30
+ presets, paging, sorting, column chooser, persistence and exports against
31
+ `GET /api/group`. Opening a row presents `GroupDetail` in `modal.detail`.
32
+ `GroupDetail` preserves the Overview, Identity, Members, Sub-Groups, API Keys,
33
+ Webhooks, Geofencing, Events, Audit and Metadata surfaces, with each section's
34
+ existing system permission gate and conditional queries. Parent, child, member
35
+ and user navigation opens native detail modals while keeping the directory
36
+ underneath.
37
+
38
+ The group model and section code are adapted from the reference portal at
39
+ `v0.2.5` commit `93c7fdfeb3096d33010e72bd4483cf9ca6e3d6f5`. The source hashes
40
+ and corresponding package hashes are recorded in
41
+ [`admin-identity-directories-sources.json`](admin-identity-directories-sources.json).
42
+ The moved implementation retains the package Apache-2.0 license. Two raw JSON
43
+ surfaces render their values directly in their explicit Metadata/Auth details;
44
+ they do not use collapsible content.
45
+
46
+ ## Personal API Keys
47
+
48
+ `PersonalApiKeysPage` uses the existing `ApiKeyModel` at
49
+ `/api/account/api_keys` and the caller-only generation mutation at
50
+ `/api/auth/generate_api_key`. The generated token is shown once and scrubbed
51
+ before it can reach the query or mutation cache. Key facts and actions open in
52
+ a native detail modal. Disable/enable remains reversible with an undo toast;
53
+ revoke remains armed because it rotates the secret and cannot be undone. There
54
+ is no delete action. This page does not use the separate group credential
55
+ endpoint.
56
+
57
+ The Personal API Keys route was added to the package identity section registry
58
+ alongside Users, Members, Groups, Credentials and Sign-in. Compatibility page
59
+ paths in the reference app re-export the package-owned pages, and its menu and
60
+ route composition do not inject duplicate directory entries.
@@ -25,7 +25,12 @@ reason per batch; rows already in the target state are ineligible.
25
25
 
26
26
  The detail preserves all 14 sections: Overview, Profile, Personal, Security,
27
27
  OAuth, Groups, Sys Perms, conditional App Perms, API Keys, Devices, Logins,
28
- Audit, Notifications, and Metadata. Subsidiary count/list queries are enabled
28
+ Audit, Notifications, and Metadata. Permission sections are labeled **System
29
+ permissions** and **Product permissions** in the UI. Consuming apps install
30
+ their product tabs with `registerFormTabs(USER_APP_PERMS_TABSET, tabs)`; the
31
+ section stays absent when none are registered. User grants can cover multiple
32
+ groups; use a Member product permission for a single group.
33
+ Subsidiary count/list queries are enabled
29
34
  only when their exact system permission is present. Logs use
30
35
  `sys.view_logs | sys.manage_logs | sys.security`; incident events use
31
36
  `sys.view_security | sys.security`. Permission revocation therefore removes
@@ -12,6 +12,7 @@ import {
12
12
  MEMBERS_ADMIN_SECTION,
13
13
  openMemberAdmissionDialog,
14
14
  registerMemberPermissions,
15
+ MEMBER_APP_PERMS_TABSET,
15
16
  } from 'portal-mojo/admin';
16
17
  ```
17
18
 
@@ -103,6 +104,16 @@ and read-only rather than disappearing. Permission switches autosave through
103
104
  `FormView`; a deployment's `MEMBER_PERMS_PROTECTION` remains authoritative,
104
105
  and rejected changes revert with the server error shown.
105
106
 
107
+ For a larger product catalog, also register native `FormView` tabs under
108
+ `MEMBER_APP_PERMS_TABSET` with `registerFormTabs` from `portal-mojo/ui`.
109
+ Use dotted `permissions.<grant>` fields and retain the corresponding
110
+ `registerMemberPermissions` definitions. A separate **Product permissions**
111
+ detail section appears when this tabset is populated. Fields covered by those
112
+ tabs leave the ordinary **Permissions** section, so each grant has one editor.
113
+ Both forms save the same membership row with the same gates, partial-dict
114
+ merge, escalation confirmation and rejection handling. The tabset creates no
115
+ new grant semantics: the backend still decides what each key authorizes.
116
+
106
117
  ## Composition
107
118
 
108
119
  `MEMBERS_ADMIN_SECTION` is root-relative and dual-mounts as `/members` in the
@@ -110,3 +121,8 @@ standalone Admin and `/system/members` when embedded. It never consumes active
110
121
  group context. `GroupMembersPanel` is the reusable Group-detail composition;
111
122
  pass optional `onNavigateUser` and `onNavigateGroup` callbacks to connect
112
123
  sibling identity details without coupling the package to an app.
124
+
125
+ Member product tabs only render `permissions.<grant>` fields whose grant is
126
+ registered through `registerMemberPermissions()` and editable. System/derived
127
+ grants, unregistered names and unrelated fields (such as `group` or `user`)
128
+ are ignored; tabs with no valid fields do not appear.
@@ -163,7 +163,7 @@ flag is detected, `true` REQUIRES the flag (rare), absent/`null` means don't
163
163
  care.
164
164
 
165
165
  `geofence-data.ts` is the ONE definition of the lossy projection between that
166
- DSL and the friendly form. `apps/portal/src/pages/group-sections/geofence-data.ts`
166
+ DSL and the friendly form. `packages/portal-mojo/src/admin/identity/groups/group-sections/geofence-data.ts`
167
167
  is a re-export shim over it, so the platform page and the group panel cannot
168
168
  drift. `GeofenceRuleEditor` is the ONE editor both render.
169
169
 
@@ -26,8 +26,10 @@ setDefaultMenu('admin');
26
26
  Resolution: route containment in registration order (group menus eligible
27
27
  only under a kind-matched active group) → defaultMenu → first non-group
28
28
  visible. Items support `permissions` (any-of) ∧ `requiresGroupKind`;
29
- dividers with no visible children drop; a parent lights when a child route
30
- is active. `scope: 'admin' | 'group' | 'account' | 'global'` makes ownership
29
+ dividers with no visible children drop. Only the destination row is selected;
30
+ an accordion parent is a neutral disclosure control and opens for its active
31
+ child. `exact: true` end-matches both the visual selection and `aria-current`.
32
+ `scope: 'admin' | 'group' | 'account' | 'global'` makes ownership
31
33
  explicit; legacy `groupKind` menus still infer group scope. Static rendering
32
34
  remains the default for compatibility. `presentation: 'accordion'` enables
33
35
  searchable, single-open-section navigation. Give accordion parents stable
@@ -57,6 +59,8 @@ Admin section bundles contribute through `adminSectionsMenu`. Use
57
59
  `{grouped: true, presentation: 'accordion'}` for the domain-grouped Admin
58
60
  experience. Routes remain mount-relative; multiple embedded root sections
59
61
  share one `/system` landing instead of emitting duplicate index routes.
62
+ Generated Admin destinations are exact matched, so an embedded mount’s root
63
+ Dashboard cannot remain selected on every nested page.
60
64
 
61
65
  Import the route/menu machinery from `portal-mojo/admin/core`, then import only
62
66
  the registry domains the shell installs. The stable domains are `identity`,
@@ -0,0 +1,131 @@
1
+ # UserMenu — the top-nav account menu
2
+
3
+ ```ts
4
+ import { UserMenu, type UserMenuItem } from 'portal-mojo/ui';
5
+ // also on the narrow shell boundary:
6
+ import { UserMenu, type UserMenuItem } from 'portal-mojo/ui/shell';
7
+ ```
8
+
9
+ The shared identity menu for every django-mojo portal's top bar — replaces
10
+ hand-rolled "user chips" and the separate theme-cycle button. Ported from
11
+ web-mojo's TopNav user dropdown (`src/core/views/navigation/TopNav.js`:
12
+ avatar-or-icon trigger, end-aligned dropdown, divider rows) and
13
+ `PortalApp._injectThemeToggleItems` (theme choice sits just above the
14
+ trailing logout); the theme dialog becomes an inline Light/Dark/System
15
+ segmented control.
16
+
17
+ ```tsx
18
+ <header className="topnav">
19
+ <h2 className="topnav-title">Dashboard</h2>
20
+ <div className="topnav-right">
21
+ <UserMenu
22
+ authMode="inapp"
23
+ items={[
24
+ { label: 'My profile', icon: 'bi-person', to: '/profile' },
25
+ { label: 'Leave organization', icon: 'bi-box-arrow-left', danger: true, divider: true, onSelect: leave },
26
+ ]}
27
+ onSignOut={async () => { await revokeServerSession(); logout(); }}
28
+ />
29
+ </div>
30
+ </header>
31
+ ```
32
+
33
+ Live demo: showcase → Components → Overlays → **UserMenu**.
34
+
35
+ ## Props
36
+
37
+ | Prop | Type | Notes |
38
+ |---|---|---|
39
+ | `items?` | `UserMenuItem[]` | App entries, rendered between the theme control and Sign out. |
40
+ | `children?` | `ReactNode` | Rendered after `items`, above Sign out. Elements with `role="menuitem"` (and `tabIndex={-1}`, class `user-menu-item` for the look) join arrow-key navigation, and clicking one closes the menu. |
41
+ | `authMode?` | `'inapp' \| 'hosted'` | Signed-out affordance (see matrix). The app knows its mode — the toolkit never imports app config. Unknown values warn once and render the signed-out chip. |
42
+ | `onSignOut?` | `() => void \| Promise<void>` | Replaces the default `logout()`. Sign out shows a pending state ("Signing out…", spinner, `aria-busy`) until it settles; repeat clicks are ignored. A rejection toasts `Sign out failed: <message>` and leaves the menu open for a retry. |
43
+ | `size?` | `'sm' \| 'md'` | Trigger density: 24px vs 28px avatar. Default `md`; unknown values warn once and fall back. |
44
+ | `className?` | `string` | Appended to the trigger (or the signed-out element). |
45
+
46
+ ```ts
47
+ interface UserMenuItem {
48
+ label: string;
49
+ icon?: string; // bootstrap-icons class, e.g. 'bi-person'
50
+ to?: string; // react-router <Link> destination
51
+ onSelect?: () => void; // runs after the menu closes (also for `to` items)
52
+ danger?: boolean; // danger tone
53
+ divider?: boolean; // separator line ABOVE this item
54
+ }
55
+ ```
56
+
57
+ `divider` is a flag on the following item, not a standalone row (web-mojo's
58
+ `{divider: true}` rows map to `divider: true` on the next entry).
59
+
60
+ ## Anatomy
61
+
62
+ - **Trigger** — `<button>` with avatar (image, or `fmt.initials(name)` while
63
+ loading / without one / after an image error), display name, caret.
64
+ `aria-haspopup="menu"`, `aria-expanded`, `aria-controls` (while open), and
65
+ `aria-label="Account menu: <name>"` so the avatar-only phone trigger keeps
66
+ an accessible name. Under **720px** the name and caret hide.
67
+ - **Popover** (`placement="bottom-end"`, 280px, clamped to the viewport):
68
+ identity header (42px avatar, name, email, `is_superuser` → "System admin"
69
+ chip), then the `role="menu"` list: Theme (three `menuitemradio`s wired to
70
+ `useTheme().setPref`), items, children, Sign out (danger tone).
71
+ - Name = `me.display_name || me.email || token email || '…'`.
72
+
73
+ ## Wire contract
74
+
75
+ - Identity: `useMe()` → `GET /api/user/me` (cache key `['me', uid]`, the row
76
+ is sanitized so `avatar` is `{id} | null`).
77
+ - Avatar: when `me.avatar?.id` is set, `mojoCall('/api/fileman/file/<id>')`
78
+ runs **only while mounted** (AbortController on unmount/id change; failures
79
+ silent). The image is `thumbnail ?? url`, each through `safePreviewUrl`
80
+ (`ui/safe-url.ts`, shared with FileField): same-origin `/paths` or
81
+ credential-free `http(s)` only.
82
+ - Sign out: `onSignOut()` or `logout()` (client-side; django-mojo has no
83
+ logout endpoint).
84
+
85
+ ## Invariants
86
+
87
+ - The avatar URL is a **capability**: it lives in component state only and
88
+ is never written into any Query cache (the `me` cache holds `{id}` by
89
+ design). A previous identity's image can never render for the next
90
+ (state is keyed by avatar id).
91
+ - No avatar request while signed out or when `avatar` is null.
92
+ - Popover is controlled; outside-mousedown closes without moving focus.
93
+ - Tokens only — `apps/portal/src/theme/user-menu.css`; both themes from the
94
+ token swap. Reuses `.seg`/`.seg-btn`, `.chip-primary`, `.spin`,
95
+ `.btn-primary`/`.btn-compact`, `.chip-muted`.
96
+
97
+ ## Signed-out matrix
98
+
99
+ `useAuthSnapshot().authenticated === false` renders no menu:
100
+
101
+ | `authMode` | Renders |
102
+ |---|---|
103
+ | `'inapp'` | `<Link to="/auth/login">` Sign in button |
104
+ | `'hosted'` and `hostedAuthUrl()` resolves | Sign in button → `redirectToHostedAuth()` |
105
+ | `'hosted'` with no API origin, or omitted | muted "Signed out" chip |
106
+
107
+ ## Keyboard
108
+
109
+ | Where | Key | Effect |
110
+ |---|---|---|
111
+ | Trigger | Enter / Space / click | Toggle; on open focus the first item |
112
+ | Trigger | ↓ / ↑ | Open (or enter the open menu) on the first / last item |
113
+ | Menu | ↓ / ↑ | Next / previous item (wraps; includes the theme radios) |
114
+ | Menu | Home / End | First / last item |
115
+ | Theme control | ← / → | Previous / next theme option (wraps) |
116
+ | Menu | Enter / Space | Activate (Space also activates `to` links) |
117
+ | Menu | Escape | Close, focus back to the trigger (first Escape only closes the menu inside a modal) |
118
+ | Menu | Tab / Shift+Tab | Close; focus continues from the trigger |
119
+
120
+ Selecting an item or a `role="menuitem"` child closes the menu and returns
121
+ focus to the trigger before `onSelect` runs (so dialogs opened from
122
+ `onSelect` restore focus to a live element). A successful Sign out closes the
123
+ menu. Theme picks apply immediately and keep the menu open.
124
+
125
+ ## Pitfalls
126
+
127
+ - Needs a router context (items with `to`, and the in-app Sign in link) and
128
+ `ThemeProvider` for the theme control; `ToastHost` for the failure toast.
129
+ - Don't also render a theme toggle button in the same bar — the menu owns it.
130
+ - `onSignOut` that never settles leaves Sign out pending; resolve or reject.
131
+ - Apps must ship `theme/user-menu.css` (the package ships no CSS).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",
@@ -207,8 +207,8 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
207
207
  const labeled = section.routes.filter((route) => route.label);
208
208
  const destinations: MenuItem[] = labeled.length > 0 ? labeled.map((route) => ({
209
209
  id: `admin:${section.id}:${route.path || 'index'}`, label: route.label!, keywords: [section.title],
210
- route: absolutePath(base, route.path), permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
211
- })) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), permissionClauses: [section.permissions] }];
210
+ route: absolutePath(base, route.path), exact: true, permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
211
+ })) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), exact: true, permissionClauses: [section.permissions] }];
212
212
  if (section.navigationGroup === null) {
213
213
  items.push(...destinations.map((destination) => ({ ...destination, icon: section.icon })));
214
214
  continue;
@@ -227,8 +227,8 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
227
227
  const base = relativePath(mount, section.basePath ?? section.id);
228
228
  items.push({
229
229
  label: section.title, icon: section.icon, permissions: section.permissions,
230
- ...(base ? { route: absolutePath(base) } : {}),
231
- children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), permissions: route.permissions })),
230
+ ...(base ? { route: absolutePath(base), exact: true } : {}),
231
+ children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), exact: true, permissions: route.permissions })),
232
232
  });
233
233
  }
234
234
  return { name: opts.name ?? 'admin', scope: 'admin', presentation: opts.presentation ?? 'static', items };
@@ -3,6 +3,7 @@ import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
3
3
  import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
4
4
  import { USER_VIEW_PERMISSIONS } from '../identity/users/models';
5
5
  import { SIGNIN_ADMIN_PERMISSIONS } from '../signin/api';
6
+ import { GROUP_VIEW_PERMS } from '../identity/groups/group-permissions';
6
7
 
7
8
  export const USERS_ADMIN_SECTION: AdminSection = {
8
9
  id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
@@ -27,4 +28,21 @@ export const SIGNIN_ADMIN_SECTION: AdminSection = {
27
28
  routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
28
29
  };
29
30
 
30
- export const IDENTITY_ADMIN_SECTIONS = [USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION, SIGNIN_ADMIN_SECTION] as const;
31
+ export const GROUPS_ADMIN_SECTION: AdminSection = {
32
+ id: 'groups', basePath: '', title: 'Groups', icon: 'bi-diagram-3', navigationGroup: 'identity-access', permissions: GROUP_VIEW_PERMS,
33
+ routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(({ GroupsPage }) => ({ default: GroupsPage })), permissions: GROUP_VIEW_PERMS }],
34
+ };
35
+
36
+ export const PERSONAL_API_KEYS_ADMIN_SECTION: AdminSection = {
37
+ id: 'personal-api-keys', basePath: '', title: 'Personal API Keys', icon: 'bi-key', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
38
+ routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(({ PersonalApiKeysPage }) => ({ default: PersonalApiKeysPage })) }],
39
+ };
40
+
41
+ export const IDENTITY_ADMIN_SECTIONS = [
42
+ GROUPS_ADMIN_SECTION,
43
+ PERSONAL_API_KEYS_ADMIN_SECTION,
44
+ USERS_ADMIN_SECTION,
45
+ MEMBERS_ADMIN_SECTION,
46
+ CREDENTIALS_ADMIN_SECTION,
47
+ SIGNIN_ADMIN_SECTION,
48
+ ] as const;