portal-mojo 0.2.4 → 0.2.6

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 (60) hide show
  1. package/docs/README.md +2 -0
  2. package/docs/admin-fleet-configuration.md +50 -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/admin-push.md +85 -6
  9. package/docs/admin-settings.md +10 -0
  10. package/docs/admin-signin.md +73 -0
  11. package/docs/menus-and-access.md +6 -2
  12. package/docs/realtime.md +5 -0
  13. package/package.json +1 -1
  14. package/src/admin/core/index.ts +4 -4
  15. package/src/admin/domains/identity.ts +25 -1
  16. package/src/admin/domains/operations.ts +8 -1
  17. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
  18. package/src/admin/fleet-configuration/data.ts +89 -0
  19. package/src/admin/identity/groups/GroupDetail.tsx +287 -0
  20. package/src/admin/identity/groups/GroupsPage.tsx +125 -0
  21. package/src/admin/identity/groups/group-permissions.ts +5 -0
  22. package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
  23. package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
  24. package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
  25. package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
  26. package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
  27. package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
  28. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
  29. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
  30. package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
  31. package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
  32. package/src/admin/identity/groups/group-sections/models.ts +110 -0
  33. package/src/admin/identity/groups/group-sections/shared.ts +21 -0
  34. package/src/admin/identity/groups/index.ts +6 -0
  35. package/src/admin/identity/groups/models.ts +70 -0
  36. package/src/admin/identity/index.ts +1 -0
  37. package/src/admin/identity/members/MemberDetail.tsx +32 -3
  38. package/src/admin/identity/members/models.ts +3 -0
  39. package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
  40. package/src/admin/identity/users/UserDetail.tsx +2 -2
  41. package/src/admin/identity/users/index.ts +2 -0
  42. package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
  43. package/src/admin/index.ts +3 -0
  44. package/src/admin/messaging/push/PushPage.tsx +61 -13
  45. package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
  46. package/src/admin/messaging/push/api.ts +25 -1
  47. package/src/admin/messaging/push/models.ts +7 -6
  48. package/src/admin/public/identity.ts +1 -0
  49. package/src/admin/public/operations.ts +1 -0
  50. package/src/admin/registry.ts +6 -2
  51. package/src/admin/settings/SettingDetail.tsx +2 -1
  52. package/src/admin/settings/SettingValue.tsx +37 -0
  53. package/src/admin/settings/SettingsPage.tsx +2 -1
  54. package/src/admin/signin/SigninPage.tsx +397 -0
  55. package/src/admin/signin/api.ts +83 -0
  56. package/src/admin/signin/index.ts +4 -0
  57. package/src/client/fleet-mock.ts +61 -0
  58. package/src/client/mock.ts +239 -10
  59. package/src/client/realtime.ts +12 -0
  60. package/src/ui/SidebarNav.tsx +2 -2
package/docs/README.md CHANGED
@@ -19,12 +19,14 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
19
19
  | [admin-monitoring.md](admin-monitoring.md) | Monitoring Admin registration, logs, and exact Redis-backed metrics-permission mutations |
20
20
  | [admin-metrics-explorer.md](admin-metrics-explorer.md) | Read-only recorded-metrics discovery, full-slug adapters, KPI/fan-out/scalar contracts, and cache identity |
21
21
  | [admin-settings.md](admin-settings.md) | Runtime settings admin — direction-aware atomic writes, write-only secrets, and global/group scope |
22
+ | [admin-fleet-configuration.md](admin-fleet-configuration.md) | Superuser structured configuration publishing, version restore and per-node application evidence |
22
23
  | [admin-bouncer.md](admin-bouncer.md) | Bouncer decisions, device investigations, and bot-signature administration with token-safe caching |
23
24
  | [admin-security-tickets.md](admin-security-tickets.md) | Ticket queue, KISS detail modal, notes, approvals, and Maestro sync contract |
24
25
  | [admin-incidents.md](admin-incidents.md) | Incident/event triage, forensic detail, merge semantics, and sanitized export boundary |
25
26
  | [admin-rules.md](admin-rules.md) | Always-inactive RuleSet authoring and the lossless, runtime-effective handler-chain builder |
26
27
  | [admin-members.md](admin-members.md) | Global membership table/detail, no-oracle admission, extensible grants, Group-detail composition, and unsupported-operation boundaries |
27
28
  | [admin-identity-users.md](admin-identity-users.md) | Global Users table and 14-section detail, system gates, action contracts, and one-time credential invariants |
29
+ | [admin-signin.md](admin-signin.md) | System sign-in page look & feel plus Google/Apple/GitHub provider credentials — one endpoint, changed-paths saves, write-only secrets |
28
30
  | [admin-devices-geoip.md](admin-devices-geoip.md) | Fleet device triage, the login-location world map with country drill-down, and the GeoIP dossier — expiry-aware enforcement, the never-cached raw provider record, and the backend traps the port encodes |
29
31
  | [admin-jobs.md](admin-jobs.md) | Jobs engine — runner fleet, per-channel queue depth, the segmented job table with armed cancel and republishing retry, the dry-run-first control plane, scheduled tasks with a global-only gate, and the thirteen backend corrections the port encodes |
30
32
  | [admin-network-security.md](admin-network-security.md) | Perimeter control — blocked IPs, the firewall log, kernel IP sets and the geofencing page (posture, rules, simulator, evidence, exemptions), with the DSL projection and every backend correction the port encodes |
@@ -0,0 +1,50 @@
1
+ # Fleet Configuration
2
+
3
+ The Operations → Fleet Configuration page uses `/api/account/admin/fleet`.
4
+ Only literal superusers see the section. An empty permission any-of array uses
5
+ the existing superuser bypass and refuses even the global `admin` grant; the
6
+ page checks `is_superuser === true` before fetching. Backend authority remains
7
+ mandatory, including rejection of diagnostic/key-backed sessions and fresh auth.
8
+
9
+ Applications register their schema in django-mojo. The page renders the returned
10
+ section, label, description, default, type and restart requirement; no application
11
+ setting names are hardcoded. Each field defaults to Keep current value. Set value
12
+ and Remove published override are explicit actions. Clearing falls back to base
13
+ configuration/default; it does not promise erasure of a bootstrap secret.
14
+
15
+ Secret inputs start blank, never hold masked placeholders, and blank replacement
16
+ preserves the existing secret. Values stay in the form until an imperative POST;
17
+ secret-bearing writes bypass TanStack MutationCache. Secret current/default fields
18
+ are removed before Query cache storage. The revision-bound publish/restore/apply
19
+ POSTs use `withFreshAuth` and show failures without claiming success. Schema/type
20
+ validation is repeated by the server; browser validation is only feedback.
21
+
22
+ Publication means published to S3, never applied. Apply now starts a fixed
23
+ asynchronous sync operation and polls its ID until terminal operation status. The
24
+ short job completes after dispatching sync; completed job status does not stop
25
+ polling. Each operation read freshly observes activation until healthy or timed
26
+ out; failed, expired or canceled jobs also stop polling. The expected
27
+ node roster includes missing nodes. Installed, restart requested, restarted and
28
+ healthy remain separate evidence. Only positive installed + restarted + health
29
+ results from every expected node with the explicit
30
+ `request_service_jobs_and_dependencies` health scope justify fleet completion.
31
+ Missing or older health scopes remain unconfirmed, including per-node health and
32
+ restart confirmation, and prompt a backend upgrade. Health covers the
33
+ request service and dependencies where enabled, plus the job engine and scheduler
34
+ with their startup-loaded revision. Worker-only nodes need no request service.
35
+ Draining an old engine is not proof that its replacement has loaded the revision.
36
+ Timed-out, failed, superseded and unknown results stay visible. Refresh status observes the
37
+ normal timer workflow even when Apply now was not used.
38
+
39
+ History contains only version IDs and timestamps. Restore publishes the selected
40
+ version as a new revision using the current expected revision. Secrets are neither
41
+ shown nor compared. Concurrent publication rejects the stale revision; refresh
42
+ before retrying. Settings edits are tied to their loaded publication revision.
43
+
44
+ Verify with `npm run verify:admin-fleet`, `npm run typecheck`, `npm run build`, and
45
+ browser checks of both themes. The Settings showcase includes a partial-convergence
46
+ example. Mock Portal uses representative registered settings and an offline node.
47
+
48
+ Django receives this page only after a canonical Portal artifact is built and
49
+ vendored; follow `docs/admin-artifact.md` and the Django vendor tool. Ordinary
50
+ Vite build output is not the canonical artifact.
@@ -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
 
@@ -6,38 +6,117 @@ Import `PushPage`, its individual panels/pages, the canonical push models, API h
6
6
 
7
7
  The page has six possible tabs: Your stats, Global metrics, Devices, Deliveries, Templates, and Config. Stats are authenticated caller data. Every other tab is independently filtered by its exact permission clause, and only the active authorized panel mounts, so hidden/inactive surfaces issue no requests.
8
8
 
9
- `push_surface` is registered with `registerNonFilterParams` at module load. It survives table writes but never becomes a Django lookup or filter pill. Changing tabs replaces the flat URL params with only `push_surface`, intentionally clearing search, sort, paging, and filters that belong to the prior model.
9
+ `push_surface` is registered with `registerNonFilterParams` at module load. It survives table writes but never becomes a Django lookup or filter pill. Changing tabs retains only `push_surface` and the showcase host's `demo` selector, clearing search, sort, paging, and filters belonging to the prior model. Showcase registers `demo` as a non-filter param at boot, so table writes keep the selected demo without sending it as a model lookup.
10
10
 
11
11
  ## Exact permissions
12
12
 
13
- All UI clauses carry the `sys.` namespace and are ANY-of:
13
+ All UI clauses carry the `sys.` namespace. Alternatives within a clause are ANY-of:
14
14
 
15
15
  - Devices view: `view_devices | manage_devices | comms | owner | manage_users`; save: `manage_devices | comms | owner`.
16
16
  - Deliveries view: `view_notifications | manage_notifications | comms | owner | manage_users`; save: `manage_notifications | comms`.
17
17
  - Templates view: `manage_notifications | manage_groups | comms | owner | manage_users`; save: `manage_notifications | manage_groups | comms`.
18
18
  - Config view/save: `manage_push_config | manage_groups | comms`.
19
19
  - Config connection test: `manage_push_config | comms`.
20
+ - Device test: `send_notifications | comms` **AND** the Devices view clause above.
20
21
  - Global metrics: `view_metrics | metrics`, with `account="global"`.
21
22
 
22
23
  `sys.users` is deliberately absent: it is not in `RegisteredDevice.RestMeta.VIEW_PERMS`. The Stats endpoint requires authentication only and always scopes all five keys to `request.user`.
23
24
 
25
+ Device inspection or config management alone never enables a test send. The backend separately enforces global `send_notifications | comms` and visibility of the selected registration; invisible and missing devices both return 404. Config checks require global `manage_push_config | comms`, independently of config editing. Group/member grants, API keys, and key-backed sessions cannot satisfy these test-action gates.
26
+
24
27
  ## Projection and ownership boundaries
25
28
 
26
29
  Every model pins a `basic` or `default` graph, allowlists list params, and positively projects rows before Query or Mutation cache reconciliation. RegisteredDevice never retains `device_token`; Delivery never retains `platform_data`, and its arbitrary `data_payload` is not retained by the UI projection. Related users are reduced to id plus a short display label—never email, phone, metadata, or an expanded user graph. Template JSON objects accept only a bounded shallow scalar projection on reads.
27
30
 
31
+ The additional evidence retained by the UI is:
32
+
33
+ | Model / result | Retained fields and fallback |
34
+ |---|---|
35
+ | `PushDeviceRow` | `is_active` is boolean or null; an older backend with no field shows “Activity unknown”. |
36
+ | `PushConfigRow` | `fcm_project_id` (max 200 characters), `fcm_client_email` (max 254), and boolean/null `has_fcm_credentials`. Missing metadata is “Not reported” / “Presence not reported”; credential presence is not verification. |
37
+ | `PushDeliveryRow` | `push_outcome` allowlists `accepted`, `delivered`, `simulated`, `rejected`, `blocked`, `unknown`, `pending`, `sent`, and `failed`; otherwise null, with display falling back to stored `status`. |
38
+ | `PushTestConfig` | Positive safe-integer `id`, `name` (max 100), `group` (safe-integer id/name up to 120 characters, or explicit null for system default), `fcm_project_id` (max 200), `test_mode`, `is_active`, `has_fcm_credentials`. No service-account email or secret material. |
39
+ | `PushTestResult` | `success`, `outcome`, local `message`, allowlisted/null `error_code`, success-only `message_id` (max 500), positive safe-integer/null `delivery_id` and `device_id`, and projected/null `config`. |
40
+
41
+ Test results live in the open dialog, outside Query and Mutation caches. Provider messages and raw error bodies are replaced by local copy; unknown error codes are discarded. `PushReadiness` retains only `ready`, local `message`, `device_id`, and projected `config`.
42
+
28
43
  `PushDeviceModel` now lives canonically in `admin/messaging/push`. The former `admin/identity/users/models.ts` definition is a compatibility re-export of the same object, so User Detail and the global Push page share one endpoint definition and Query key.
29
44
 
30
45
  ## FCM credentials and actions
31
46
 
32
47
  FCM writes are imperative and cache-free. A service account textarea is an uncontrolled DOM ref and never prefills. Blank means untouched and is omitted. Replacement must parse to an object with `type="service_account"`, `project_id`, `client_email`, and a PEM `private_key`. Clear sends `null` only after explicit confirmation. Empty replacement is impossible, and server responses are sanitized before cache reconciliation.
33
48
 
34
- Connection testing calls only `POST /api/account/devices/push/config/<id>/test` with an empty body. The backend uses its dummy token to validate credentials. The portal never accepts a device token. It also does not expose `/send`, caller `/test`, delivery retry, device registration/unregistration, or any delete action.
49
+ ### Saved configuration check
50
+
51
+ `testPushConfigConnection(id)` calls `POST /api/account/devices/push/config/<id>/test` with `{}`. The backend authenticates with FCM and submits a validation-only message, including when `test_mode=true` or the selected config is inactive. It sends no device notification and does not activate the config. The portal never accepts a raw device token.
52
+
53
+ The adapter accepts success only when the envelope has not failed, `success === true`, `outcome === "validated"`, `validation_only === true`, and `message_id` matches `projects/<project>/messages/<reference>`. Missing/malformed verdicts, simulated legacy responses, and provider rejections cannot display a verified connection. Backend HTTP 400 failures retain safe diagnostic data; the adapter returns a failed `PushTestResult` for the dialog to display.
54
+
55
+ “Check FCM connection” lives in the config header menu. The result and check time remain visible in Overview and Connection for that saved record/revision. Checks lock duplicate checks, editing, and active-state changes while pending. Editing or changing active state clears the result; record revision, permission, caller, or mount changes invalidate late completions. This is evidence from the current view, not a persisted credential-verification flag.
56
+
57
+ ### One registered device
58
+
59
+ The device header's “Send test push” opens a focused dialog. The API helpers use the existing test endpoint:
60
+
61
+ | Helper | Request | Purpose |
62
+ |---|---|---|
63
+ | `fetchPushDeviceReadiness(42)` | `GET /api/account/devices/push/test?device_id=42` | Local readiness; no FCM traffic. |
64
+ | `sendPushDeviceTest(42, title, message)` | `POST /api/account/devices/push/test` with `{"device_id":42,"title":"Push test","message":"This is a test notification."}` | One real notification to the selected registration. |
65
+
66
+ `device_id` here is the numeric database `id`, not the app-provided string `device_id` used for registration. The portal never calls the caller-wide POST variant without a selector. The backend resolves the device owner's active org config, falling back to the active system default; the dialog shows the device, user, config ID/name, scope, and FCM project actually reported by readiness or the send result.
67
+
68
+ Readiness must explicitly match the requested device and include an active config with credentials present and simulation disabled. Inactive registration, push disabled, an opted-out `test` category, missing token/config/credentials, or simulation prevents sending. Readiness does not verify provider credentials or device reachability. Unknown/malformed readiness disables Send rather than guessing.
69
+
70
+ The dialog defaults to title `Push test` and message `This is a test notification.`; inputs must be nonblank and fit 200/1000 characters. A single pending guard prevents duplicate submissions and locks fields, Cancel, Escape, and backdrop dismissal during sending. Each dialog permits one completed attempt; reopen it to obtain fresh readiness before another. Permission loss hides stale results; settled requests still unlock dismissal. Every attempt invalidates device, delivery, and caller-stat queries in `finally`.
71
+
72
+ Device results require explicit `success === true`, `outcome === "accepted"`, a provider message reference, and the requested `device_id`. Handled backend failures may be HTTP 200 with `data.success=false`; inspect the projected result. A failed envelope cannot establish success. Uncertain transport/malformed responses become `unknown`, and known HTTP 400/401/403/404 refusals without structured evidence become blocked. There is no automatic resend.
73
+
74
+ The portal exposes no general `/send`, caller-wide test, delivery retry, device registration/unregistration, or delete action.
75
+
76
+ All four REST models have live `CAN_DELETE = false`; the mock returns 403 without removing or cascading anything. Templates/configs can be created and edited under their save clauses. Device record editing and delivery mutation remain absent from this page.
77
+
78
+ ## Outcomes and delivery evidence
79
+
80
+ Test dialogs keep a visible result label, local explanation, check time, and delivery record ID when available:
81
+
82
+ | Outcome | Meaning |
83
+ |---|---|
84
+ | `validated` | FCM connection verified; no notification delivered. |
85
+ | `accepted` | FCM accepted the notification; the operator must check the selected device for receipt. |
86
+ | `rejected` | FCM refused the request; show the safe diagnostic. |
87
+ | `blocked` | Readiness, credentials, authentication, or request refusal prevented a successful test. |
88
+ | `unknown` | Acceptance is uncertain. Check the device and delivery history before another test; do not label it failed or retry automatically. |
35
89
 
36
- All four REST models have live `CAN_DELETE = false`; the mock returns 403 without removing or cascading anything. Templates/configs can be created and edited under their save clauses. Devices and deliveries remain investigative here.
90
+ Delivery lists/details prefer `push_outcome` over `status`. `simulated` is explicitly shown as no FCM send, even though the stored status is `sent`. `unknown` remains pending; provider acceptance alone does not mark a record delivered. Older `sent` records are described as lacking acceptance/receipt evidence. Status filtering and sorting still use the stored status field, not the derived outcome. The caller-stat tile is “Recorded sent” because its count may include simulations.
37
91
 
38
92
  ## Modal lifecycle convention
39
93
 
40
94
  See [Admin modal policy](admin-modals.md) for header actions, content-only
41
95
  sections, supported lifecycle fields and retained irreversible exceptions.
42
- Config/template active-state controls live in the detail header. Config saves
43
- retain write-only credential handling, and test sends keep their separate gate.
96
+ Config/template active-state controls live in the detail header. Device details
97
+ use Overview/Preferences with active and push-state chips; delivery details use
98
+ Notification/Delivery with outcome explanations. Templates use
99
+ Overview/Notification/Variables; configs use Overview/Connection with scope,
100
+ send-mode, credential-presence, and service-account metadata.
101
+
102
+ Config/template editors keep one pending save guard and protect Escape/backdrop
103
+ dismissal as well as Cancel. Config saves retain write-only credential handling;
104
+ device test sends use their separate permission AND and dismissal lock.
105
+
106
+ ## Showcase and verification
107
+
108
+ Open `#/?demo=admin-push` in Showcase. Its banner explicitly identifies
109
+ local fixtures: connection checks and test pushes there never contact FCM or
110
+ deliver notifications. Fixtures exercise system/org config selection, disabled
111
+ push, simulation blockers, safe metadata, and a selected-device accepted result
112
+ with a delivery row and no delivered timestamp. Switching Push tabs preserves
113
+ `demo=admin-push`; model filtering never consumes that host selector.
114
+
115
+ `npm run verify:admin-push` runs both `scripts/verify-admin-push.mjs` (mock wire,
116
+ permissions, projections, credentials, and target selection) and
117
+ `scripts/verify-push-tests.mjs` (mounted dialog/adapters). The latter covers strict
118
+ provider verdicts, malformed/mismatched responses, revision/permission ownership,
119
+ persistent results, duplicate-send prevention, pending dismissal, and cache
120
+ isolation. JSDOM assertions do not prove native dialog layout or stacking;
121
+ complete browser verification in both themes and a narrow viewport, including
122
+ the device test, configuration check, and nested editors.
@@ -33,6 +33,16 @@ queries `/api/group`, and preserves that scope on save. New settings default to
33
33
  Global. Delete is intentionally absent because live `Setting.RestMeta` does not
34
34
  declare deletion.
35
35
 
36
+ ## Viewing values
37
+
38
+ JSON objects and arrays show a compact type/count summary in the list. Long or
39
+ multiline text shows a single-line preview, capped at 80 characters. Select the
40
+ row to open the full value in the existing detail
41
+ modal. JSON uses the shared `JsonBlock` viewer, expanded initially, with syntax
42
+ highlighting, Show/Hide, Copy, and a scrollable body. Other text preserves line
43
+ breaks in a scrollable area; invalid JSON remains plain text. Secret values stay
44
+ masked in both views.
45
+
36
46
  ## Atomic write contract
37
47
 
38
48
  Use `saveSettingAtomic()` / `buildSettingPayload()` rather than a generic
@@ -0,0 +1,73 @@
1
+ # Sign-in admin (#5547)
2
+
3
+ Import `SigninPage`, `SIGNIN_ADMIN_SECTION`, the permission clause and the API
4
+ helpers from `portal-mojo/admin` (or `portal-mojo/admin/identity`). The section
5
+ registers one global route, `/system/sign-in`, under **Identity & Access**,
6
+ gated by `SIGNIN_ADMIN_PERMISSIONS` = `['sys.manage_settings', 'sys.admin']`
7
+ (superusers pass). Admin global scope masks any outer product group.
8
+
9
+ The page has two parts:
10
+
11
+ - **Look & feel** — the SYSTEM hosted login/registration page: branding,
12
+ layout/appearance/accent color, hero panel, sign-in heading/copy/methods,
13
+ registration, custom CSS. It edits the same keys a group admin edits in the
14
+ group **Configure Auth** dialog; a group's own settings still override these.
15
+ - **Providers** — one card each for Google, Apple and GitHub: an Enabled
16
+ switch, a read-only callback URL with a Copy button, the credential fields,
17
+ a Ready / Needs setup badge, and a link to the provider's console.
18
+
19
+ KISS by design: trusted admins, no step-up auth, no confirmation beyond a
20
+ browser `confirm()` before clearing a stored value.
21
+
22
+ ## Wire contract
23
+
24
+ One endpoint, both methods, django-mojo envelope.
25
+
26
+ `GET /api/account/admin/signin` → `{schema_version, auth, editable, options, providers}`
27
+
28
+ - `auth` is `public_auth_config` of the resolved system config
29
+ (`{theme, login, registration}`).
30
+ - `editable` lists the flat dotted paths the POST accepts. The page renders
31
+ only fields whose path is listed, so the backend can narrow it.
32
+ - `options` carries the allowed tokens for methods, layouts, appearances,
33
+ hero image positions and passkey prompts. Legacy layouts `card` /
34
+ `fullscreen` display as `compact` / `branded-panel`; any other unknown select
35
+ value falls back to the first option with a `console.warn`.
36
+ - `providers[]`: `{name, label, enabled, ready, missing, callback_url,
37
+ console_url, help, fields[]}`; each field is `{key, label, secret,
38
+ multiline, configured, source, value, hint}`. `source` is `admin`,
39
+ `deployment` (shown as "Set in server config") or `none`. Secret fields
40
+ never carry `value`; `hint` (e.g. `…a1b2`) is shown when present.
41
+
42
+ `POST /api/account/admin/signin` returns the same full payload, which the page
43
+ writes straight into its query cache (`SIGNIN_QUERY_KEY`).
44
+
45
+ - Look & feel: `{auth: {"theme.app_title": "…", "login.methods": […]}}` —
46
+ only the paths whose value differs from the loaded payload, sent by the
47
+ explicit **Save** button. Password is locked on in the sign-in methods picker;
48
+ the server rejects a list without it (400).
49
+ - Provider: `{provider, values, enabled}`. In `values`, an omitted key or
50
+ `""` keeps the stored value and `null` clears it. The card's **Save** sends
51
+ only fields the admin typed into; **Clear…** (shown for `source: "admin"`)
52
+ confirms, then sends `null` at once. The **Enabled** switch saves
53
+ immediately with `{provider, enabled}`, which adds/removes the provider from
54
+ both `login.methods` and `registration.methods`.
55
+ - Errors are `{status: false, error}` (HTTP 400). The client rejects them; the
56
+ page shows the message inline and as a toast and keeps the unsaved edits.
57
+
58
+ Secrets are entered in password inputs (the Apple `.p8` key in a textarea)
59
+ and cleared from local state after a successful save.
60
+
61
+ ## Mock
62
+
63
+ `src/client/mock.ts` implements the endpoint: global `manage_settings` /
64
+ `admin` gate (401 anonymous, 403 otherwise), `editable`-only writes with the
65
+ option and password rules, and credential storage. Seed: Apple has Team ID and
66
+ Services ID stored in the Admin (missing Key ID and private key), Google has
67
+ nothing, GitHub is configured by the deployment. Saved look & feel lands in a
68
+ system override that `/api/auth/config` layers between the deployment config
69
+ and group overrides, so the public config reflects saves. An Admin-stored
70
+ credential wins over the deployment value; clearing it falls back to the
71
+ deployment value.
72
+
73
+ Verify with `npm run verify:admin-signin`.
@@ -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`,
package/docs/realtime.md CHANGED
@@ -19,6 +19,11 @@ disable this shared provider.
19
19
  `auth_success`. An auth error closes and suspends the connection until the
20
20
  token value changes.
21
21
  - Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
22
+ A server-initiated `{type:'ping'}` (django-mojo 1.32+ sends one every 20
23
+ seconds and culls a socket silent for 90) is answered at once with
24
+ `{type:'pong'}` while ready, clears the pending `pong` deadline, and is never
25
+ dispatched to consumers. Answering from the message handler keeps the socket
26
+ alive while a background tab throttles the client's own ping timer.
22
27
  Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
23
28
  least 60 seconds, and focus/visibility cannot bypass that deadline.
24
29
  - Desired topics are refcounted and replay after authentication. Operations are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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 };