portal-mojo 0.2.7 → 0.2.9

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 (168) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +7 -2
  4. package/docs/account.md +261 -0
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/auth-pages.md +8 -3
  22. package/docs/auth.md +45 -2
  23. package/docs/calendar.md +1 -1
  24. package/docs/client.md +159 -26
  25. package/docs/collection-multiselect.md +2 -2
  26. package/docs/combobox.md +2 -2
  27. package/docs/dataview.md +1 -1
  28. package/docs/datepicker.md +3 -3
  29. package/docs/daterange.md +1 -1
  30. package/docs/datetimepicker.md +1 -1
  31. package/docs/defineModel.md +40 -7
  32. package/docs/detail-primitives.md +1 -1
  33. package/docs/feedback.md +18 -8
  34. package/docs/file-upload-ux.md +2 -2
  35. package/docs/idioms.md +13 -1
  36. package/docs/loading.md +2 -2
  37. package/docs/location-address.md +2 -1
  38. package/docs/markdown.md +3 -3
  39. package/docs/menus-and-access.md +21 -1
  40. package/docs/multiselect-dropdown.md +1 -1
  41. package/docs/popover.md +1 -1
  42. package/docs/record-feed.md +2 -2
  43. package/docs/taginput.md +2 -1
  44. package/docs/timepicker.md +1 -1
  45. package/docs/timezone-select.md +4 -3
  46. package/docs/user-menu.md +4 -3
  47. package/docs/worldmap.md +2 -2
  48. package/package.json +4 -2
  49. package/src/account/AccountModal.tsx +276 -0
  50. package/src/account/AccountSections.tsx +370 -0
  51. package/src/account/NotificationPreferences.tsx +152 -0
  52. package/src/account/PasskeyList.tsx +144 -0
  53. package/src/account/api.ts +236 -0
  54. package/src/account/credential-models.ts +153 -0
  55. package/src/account/dialogs.tsx +822 -0
  56. package/src/account/index.ts +16 -0
  57. package/src/account/models.ts +76 -0
  58. package/src/account/sections/ApiKeysSection.tsx +138 -0
  59. package/src/admin/core/index.ts +1 -1
  60. package/src/admin/credentials/secret-dialog.tsx +3 -77
  61. package/src/admin/dns/models.ts +1 -1
  62. package/src/admin/domains/infrastructure.ts +12 -2
  63. package/src/admin/domains/security.ts +3 -1
  64. package/src/admin/edge/BlocklistPage.tsx +110 -0
  65. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  66. package/src/admin/edge/VhostDetail.tsx +166 -0
  67. package/src/admin/edge/VhostWizard.tsx +273 -0
  68. package/src/admin/edge/VhostsPage.tsx +47 -0
  69. package/src/admin/edge/api.ts +56 -0
  70. package/src/admin/edge/index.ts +15 -0
  71. package/src/admin/edge/models.ts +395 -0
  72. package/src/admin/edge/permissions.ts +13 -0
  73. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  74. package/src/admin/identity/users/models.ts +16 -128
  75. package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
  76. package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
  77. package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
  78. package/src/admin/identity/users/sections/actions.tsx +10 -103
  79. package/src/admin/index.ts +1 -0
  80. package/src/admin/phonehub/api.ts +5 -3
  81. package/src/admin/public/infrastructure.ts +1 -0
  82. package/src/admin/registry.ts +2 -1
  83. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  84. package/src/admin/storage/file-renditions.ts +12 -0
  85. package/src/admin/storage/index.ts +2 -0
  86. package/src/admin/storage/rendition-settings.ts +264 -0
  87. package/src/charts/exportChart.ts +1 -1
  88. package/src/client/action-result.ts +23 -61
  89. package/src/client/active-group.ts +9 -3
  90. package/src/client/auth.ts +207 -6
  91. package/src/client/client.ts +49 -10
  92. package/src/client/errors.ts +57 -2
  93. package/src/client/group-context.ts +2 -0
  94. package/src/client/group.tsx +112 -20
  95. package/src/client/hooks.ts +7 -3
  96. package/src/client/mock.ts +1429 -20
  97. package/src/client/model.ts +15 -3
  98. package/src/client/renditions-mock.ts +212 -0
  99. package/src/client/scoped.ts +19 -5
  100. package/src/styles/components/account.css +304 -0
  101. package/src/styles/components/admin-assistant.css +28 -0
  102. package/src/styles/components/admin-bouncer.css +53 -0
  103. package/src/styles/components/admin-cloudwatch.css +23 -0
  104. package/src/styles/components/admin-dashboard.css +16 -0
  105. package/src/styles/components/admin-devices.css +260 -0
  106. package/src/styles/components/admin-dns-certificates.css +28 -0
  107. package/src/styles/components/admin-dns-registrar.css +1 -0
  108. package/src/styles/components/admin-dns.css +43 -0
  109. package/src/styles/components/admin-edge.css +1 -0
  110. package/src/styles/components/admin-incidents.css +14 -0
  111. package/src/styles/components/admin-jobs.css +237 -0
  112. package/src/styles/components/admin-media-rendering.css +38 -0
  113. package/src/styles/components/admin-messaging.css +3 -0
  114. package/src/styles/components/admin-monitoring.css +152 -0
  115. package/src/styles/components/admin-network.css +406 -0
  116. package/src/styles/components/admin-phonehub.css +61 -0
  117. package/src/styles/components/admin-push.css +30 -0
  118. package/src/styles/components/admin-rules.css +23 -0
  119. package/src/styles/components/admin-security.css +37 -0
  120. package/src/styles/components/admin-shortlinks.css +8 -0
  121. package/src/styles/components/admin-signin.css +64 -0
  122. package/src/styles/components/admin-storage.css +4 -0
  123. package/src/styles/components/auth.css +162 -0
  124. package/src/styles/components/calendar.css +228 -0
  125. package/src/styles/components/charts-c2.css +249 -0
  126. package/src/styles/components/collection-multiselect.css +114 -0
  127. package/src/styles/components/collection-select.css +63 -0
  128. package/src/styles/components/combobox.css +42 -0
  129. package/src/styles/components/dataview.css +139 -0
  130. package/src/styles/components/datepicker.css +106 -0
  131. package/src/styles/components/daterange.css +119 -0
  132. package/src/styles/components/datetimepicker.css +230 -0
  133. package/src/styles/components/detail-primitives.css +246 -0
  134. package/src/styles/components/detailview.css +69 -0
  135. package/src/styles/components/drawer.css +111 -0
  136. package/src/styles/components/file-upload.css +112 -0
  137. package/src/styles/components/form-wizard.css +11 -0
  138. package/src/styles/components/formview.css +74 -0
  139. package/src/styles/components/group-admin.css +302 -0
  140. package/src/styles/components/guardrail.css +29 -0
  141. package/src/styles/components/idioms.css +84 -0
  142. package/src/styles/components/image-editor.css +80 -0
  143. package/src/styles/components/loading.css +200 -0
  144. package/src/styles/components/location-address.css +32 -0
  145. package/src/styles/components/markdown.css +192 -0
  146. package/src/styles/components/multiselect-dropdown.css +92 -0
  147. package/src/styles/components/popover.css +35 -0
  148. package/src/styles/components/record-feed.css +161 -0
  149. package/src/styles/components/right-panel.css +37 -0
  150. package/src/styles/components/tabs.css +26 -0
  151. package/src/styles/components/taginput.css +132 -0
  152. package/src/styles/components/timepicker.css +282 -0
  153. package/src/styles/components/user-admin.css +189 -0
  154. package/src/styles/components/user-menu.css +98 -0
  155. package/src/styles/components/worldmap.css +97 -0
  156. package/src/styles/core.css +807 -0
  157. package/src/styles/index.css +69 -0
  158. package/src/styles/required-tokens.json +32 -0
  159. package/src/ui/FileField.tsx +12 -7
  160. package/src/ui/FormWizard.tsx +9 -3
  161. package/src/ui/ThemeProvider.tsx +1 -1
  162. package/src/ui/UserMenu.tsx +6 -2
  163. package/src/ui/detail/index.ts +2 -2
  164. package/src/ui/index.ts +4 -2
  165. package/src/ui/loading.tsx +1 -1
  166. package/src/ui/modal.tsx +76 -0
  167. package/src/ui/secret-dialog.tsx +83 -0
  168. package/src/ui/toast.tsx +21 -3
@@ -0,0 +1,210 @@
1
+ # Edge Admin
2
+
3
+ ```ts
4
+ import {
5
+ EDGE_ADMIN_SECTION, VhostsPage, VhostDetail, VhostWizard, openVhostWizard,
6
+ UpstreamsPage, BlocklistPage, EdgeVhostModel, EdgeRouteModel,
7
+ EdgeUpstreamModel, EdgeBlocklistModel, buildVhostPayload,
8
+ } from 'portal-mojo/admin';
9
+ ```
10
+
11
+ The same names are on `portal-mojo/admin/infrastructure`.
12
+
13
+ `EDGE_ADMIN_SECTION` contributes Infrastructure routes at `edge/vhosts` and
14
+ `edge/upstreams` (or the same paths under an embedded `/system` mount). The
15
+ fleet blocklist is a route of Network Security, at
16
+ `security/network/edge-blocklist`. There are no record routes: a vhost opens in
17
+ `modal.detail`.
18
+
19
+ Consumer contract: django-mojo `mojo/apps/edge/`. The mock in
20
+ `client/mock.ts` returns the same graphs and the same refusals, word for word
21
+ where the server has words.
22
+
23
+ ## Permissions
24
+
25
+ - Vhosts, routes, upstreams — view: `sys.view_dns | sys.manage_dns | sys.security`.
26
+ - Vhosts, routes — change or delete: `sys.manage_dns | sys.security`.
27
+ - Upstreams — switch on or off: `sys.manage_dns | sys.security`.
28
+ - Upstreams — declare or retire: `me.is_superuser === true`. The server gate
29
+ is `require_platform_admin`, which also refuses API keys.
30
+ - Blocklist — view: `sys.view_security | sys.manage_security | sys.security`
31
+ (`EDGE_BLOCKLIST_VIEW_PERMS`). This is wider than `SECURITY_VIEW_PERMS`: the
32
+ server lets `manage_security` view, and manage does not imply view on the
33
+ client, so the clause is spelled out.
34
+ - Blocklist — change or delete: `sys.manage_security | sys.security`.
35
+
36
+ A section gate and a route gate both apply. A security-only operator is denied
37
+ every DNS-gated section, which is why the blocklist does not live under Edge.
38
+ The blocklist has no group: a member-scoped grant and `?group=` open nothing.
39
+
40
+ **House rows.** A vhost on a group-less domain is platform property. It never
41
+ appears in a non-superuser's list, even with a global grant, and its detail,
42
+ its routes and creating one are refused to everyone else. The vhost graphs
43
+ carry `domain: basic`, which has no `group`, so house-ness cannot be read off a
44
+ vhost row. The wizard's domain picker passes `group__isnull: false` for
45
+ non-superusers so a house domain is never offered to someone who would be
46
+ refused at Finish.
47
+
48
+ ## Endpoints
49
+
50
+ | Address | Verbs | Notes |
51
+ |---|---|---|
52
+ | `/api/edge/vhost` (+`/<id>`) | list, read, create, update, delete | `list` graph for tables, `default` for one row |
53
+ | `/api/edge/route` (+`/<id>`) | list, read, create, update, delete | `site_api` vhosts only; filter `vhost` |
54
+ | `/api/edge/upstream` (+`/<id>`) | list, read, update | update writes `is_enabled` only; no create, no delete |
55
+ | `/api/edge/upstream/declare` | POST | `{name, kind, host+port \| socket_path, group?}`; platform administrators |
56
+ | `/api/edge/upstream/retire` | POST | `{upstream}`; disables, keeps the row; platform administrators |
57
+ | `/api/edge/blocklist` (+`/<id>`) | list, read, create, update, delete | fleet-wide |
58
+
59
+ ## The four kinds
60
+
61
+ The wizard never shows a kind dropdown over one long form. Its first step is
62
+ four cards in plain words; the following steps show only that shape's settings.
63
+
64
+ | Kind | Card | Needs | May carry | Refuses |
65
+ |---|---|---|---|---|
66
+ | `api` | API host | `upstream` | `quiet_paths`, `serve_static` | `spa`, `redirect_to` |
67
+ | `site` | Static site or SPA | — | `spa` | `upstream`, `quiet_paths`, `serve_static`, `redirect_to` |
68
+ | `site_api` | Site + API paths | routes | `spa`, `serve_static`, `quiet_paths` | whole-host `upstream`, `redirect_to` |
69
+ | `redirect` | Redirect | `redirect_to` | — | everything else |
70
+
71
+ `buildVhostPayload(draft, { create })` is the one place a draft becomes a
72
+ request. It sends each knob the chosen kind does not carry as its empty value,
73
+ so a draft that visited another shape cannot leak that shape's settings, and
74
+ an edit that changes kind clears what the old kind held. It never sends
75
+ `mojosec_policy`. A vhost that carries an edge evidence policy therefore cannot
76
+ change kind, or its single-page-app switch on a `site`, from here: the server
77
+ requires the policy's response class to match and refuses, and its message is
78
+ shown.
79
+
80
+ Shared rules, each mirrored by a validator in `edge/models.ts`:
81
+
82
+ - `label`: empty serves the bare domain, `*` the wildcard, otherwise one DNS
83
+ label. The address (`server_name`) is derived, never typed.
84
+ - `body_size_mb`: whole number, 1 to 4096, default 50. Not offered for a
85
+ redirect.
86
+ - `pool`: must be one the deployment declares (`EDGE_POOLS`, default
87
+ `["default"]`). The refusal names the declared set.
88
+ - `domain`: set once. An edit shows it read-only and never sends it.
89
+ - `redirect_to`: a host name. No scheme, path, port or wildcard.
90
+ - A route prefix or quiet path starts with `/`, uses letters, digits, `.`,
91
+ `_`, `-` and `/`, and has no `//` or `..`. A route prefix cannot be `/`.
92
+
93
+ ## What is checked when
94
+
95
+ The server checks most things on every save. Two things are checked **only
96
+ when the row is enabled**:
97
+
98
+ - the certificate belongs to the vhost's domain and covers its address;
99
+ - no other enabled row has the same domain and label.
100
+
101
+ So a disabled row may hold a certificate that does not cover it yet, and may
102
+ share a name with a live row. That is how a replacement is staged: create it
103
+ disabled, then disable the old one and enable the new one. The wizard warns
104
+ when the chosen certificate does not cover the address (`certificateCovers`,
105
+ which knows that a wildcard covers exactly one label and never the apex) and
106
+ still lets the row be saved disabled.
107
+
108
+ ## The create order for `site_api`
109
+
110
+ A quiet path on a `site_api` vhost must sit under one of its route prefixes,
111
+ and the server reads those prefixes from the stored row. A create that carries
112
+ quiet paths is therefore always refused with "none declared". The only order
113
+ that works is:
114
+
115
+ 1. create the vhost, **disabled** and without quiet paths;
116
+ 2. create its routes, one at a time;
117
+ 3. save the vhost once more with the quiet paths and the enablement the user
118
+ chose.
119
+
120
+ The wizard does exactly this. Step 3 always runs, so the enable-time checks
121
+ happen at the end and a failure part-way never leaves a live vhost with no
122
+ routes. If a step is refused the wizard stays open and says which step and
123
+ what already exists. Finishing again continues from there: it never posts the
124
+ vhost a second time and never repeats a route that landed.
125
+
126
+ The server will store an enabled `site_api` vhost with no routes. The wizard
127
+ does not: at least one valid route is required at the settings step and again
128
+ at review.
129
+
130
+ In edit mode routes are read-only, with a pointer to the detail view, which
131
+ owns adding, changing and deleting them. Changing a `site_api` vhost to
132
+ another kind while it has routes is blocked with the count; the server refuses
133
+ it too. Until the stored routes have been read, or if that read fails, the
134
+ wizard does not judge quiet paths against them and leaves that to the server.
135
+
136
+ ## Vhost detail
137
+
138
+ The detail follows the admin modal contract (`admin-modals.md`). The header
139
+ switch enables and disables. Edit and Delete are in the header menu, shown
140
+ only with the manage grant. Delete asks for confirmation, names the address,
141
+ says its routes go with it, and points at Disable as the choice that can be
142
+ undone. A refused enable shows the server's message in the overview. Routes
143
+ are content: the Routes section lists them and owns add, change and delete.
144
+
145
+ ## Upstreams
146
+
147
+ An upstream is a destination a vhost may proxy to. Only `is_enabled` can be
148
+ written on one; its name and destination are fixed when it is declared. The
149
+ list shows the active group's upstreams and the shared ones. Retiring disables
150
+ the row and keeps it: vhosts and routes that reach it stop being served and
151
+ are not repointed. The retire control names that consequence before it acts.
152
+ The wizard's upstream pickers list enabled upstreams only.
153
+
154
+ ## Blocklist
155
+
156
+ An entry matches an IP network or a user-agent pattern, in one of four modes:
157
+ `log` (recorded, still served — the default), `enforce` (refused), `allow` (an
158
+ exemption) and `off`. Create an entry in `log`, watch the edge watch log for
159
+ it, then switch it to `enforce`. A change reaches the fleet in about ten
160
+ minutes.
161
+
162
+ An `ip` value is stored as its network: `10.1.2.3/8` is saved as `10.0.0.0/8`.
163
+ The editor shows the saved value from the response.
164
+
165
+ ## Not built here
166
+
167
+ - **Claim-reserved.** The plan for this page asked for a superuser control to
168
+ claim a reserved name. django-mojo removed the reserved-name mechanism, its
169
+ field and its action (commit `bc2c57b1`, in every release since v1.7.1), so
170
+ there is nothing for such a control to call.
171
+ - **`mojosec_policy`.** Shown read-only on the vhost detail. It is set by the
172
+ platform and never sent from here.
173
+ - **Web apps, releases and alias addresses.** An alias address appears in the
174
+ vhost list like any other row; `alias_of` is in no graph, so it cannot be
175
+ told apart, and a refused edit shows the server's message.
176
+
177
+ ## Pitfalls
178
+
179
+ - Do not send `quiet_paths` on a `site_api` create. See the create order.
180
+ - Do not treat a saved, enabled row as proof the certificate covers it from
181
+ the client check alone; the server's answer on save is the proof.
182
+ - The mock's messages for database uniqueness (a second enabled vhost on one
183
+ name, a repeated route prefix, a repeated blocklist value, a repeated
184
+ upstream name) are the mock's own words. The server reports these from a
185
+ database constraint, and its wording was not measured.
186
+ - The server parses a blocklist address with Python's `ipaddress` and compiles
187
+ a user-agent pattern with Python's `re`, and the mock follows Python, not
188
+ JavaScript: `010.1.2.3` and `\q` are refused, `10.0.0.0/255.0.0.0`,
189
+ `(?i)bot` and `a*+` are accepted. The reason after "does not compile" is the
190
+ mock's own wording. The editor's own check is only a first pass and never
191
+ compiles the pattern; the server's answer on save is the verdict.
192
+ - A vhost update that names an upstream or certificate that does not exist is
193
+ refused by the mock with the same words as a missing one. The server's
194
+ wording for an unknown id was not measured.
195
+ - The declare-upstream dialog and the blocklist editor refuse Escape and the
196
+ backdrop while their write is in flight, and the editor's save and delete
197
+ share one pending state. `npm run verify:admin-edge` holds each write open
198
+ and checks this.
199
+
200
+ ## Themes and showcase
201
+
202
+ Styles live in `src/styles/components/admin-edge.css` and ship in
203
+ `portal-mojo/styles.css`; the app declares the tokens. They are built from
204
+ tokens only, so both themes render from one sheet. The showcase still carries
205
+ its own identical copy until it adopts the package stylesheet. The showcase
206
+ demo (Develop → Components → Admin → Edge) runs the shipped pages against the
207
+ mock under five identities: platform administrator, DNS manager, DNS viewer,
208
+ security manager and security viewer.
209
+
210
+ `npm run verify:admin-edge` is the executable contract.
@@ -25,8 +25,8 @@
25
25
  "source_path": "apps/portal/src/pages/ApiKeysPage.tsx",
26
26
  "source_sha256": "3d338cf77c3110b72a9361565c1cced10a7fccc52c486e3dafda119d66c676b2",
27
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."
28
+ "package_sha256": "0c1e1efd34f515e4b5dc994ce34be1446eb13d1486deea3e9641565215edb6ad",
29
+ "adaptation": "Expanded row facts and actions are presented in a native DetailView modal that observes the current API key record. Every list and export is pinned to the signed-in user with fixedParams, and the table is not mounted before that user is known."
30
30
  },
31
31
  {
32
32
  "source_path": "apps/portal/src/pages/GroupDetail.tsx",
@@ -54,6 +54,15 @@ revoke remains armed because it rotates the secret and cannot be undone. There
54
54
  is no delete action. This page does not use the separate group credential
55
55
  endpoint.
56
56
 
57
+ The page lists the signed-in user's own keys only. The endpoint serves every
58
+ user's keys to a caller holding `users` or `manage_users` who sends no `user`,
59
+ so the page passes `fixedParams={{ user: me.id }}`: every list and export
60
+ request carries it, and it cannot be cleared from the URL, a saved view or a
61
+ preset. Until the signed-in user is loaded the page shows a placeholder and
62
+ mounts no table, so no unscoped list or export can go out. An administrator
63
+ reads another user's keys per user, in that user's detail under Users. Plain
64
+ users reach their own keys in "My account".
65
+
57
66
  The Personal API Keys route was added to the package identity section registry
58
67
  alongside Users, Members, Groups, Credentials and Sign-in. Compatibility page
59
68
  paths in the reference app re-export the package-owned pages, and its menu and
@@ -1,7 +1,7 @@
1
1
  # Admin Users
2
2
 
3
- Reusable global User administration for django-mojo. Consumers must include
4
- the semantic `user-admin.css` stylesheet used by the portal and showcase.
3
+ Reusable global User administration for django-mojo. Its semantic
4
+ `user-admin.css` stylesheet ships in `portal-mojo/styles.css`.
5
5
 
6
6
  ```tsx
7
7
  import {
@@ -52,7 +52,7 @@ stuck work, purge history — plus the cron-style scheduled tasks whose backend
52
52
  django-mojo has always shipped and whose admin page web-mojo never wired up.
53
53
 
54
54
  Demo: showcase → Admin → **Jobs engine**. Styles:
55
- `apps/{portal,showcase}/src/theme/admin-jobs.css` (byte-identical).
55
+ `packages/portal-mojo/src/styles/components/admin-jobs.css`.
56
56
  Verifier: `npm run verify:admin-jobs`.
57
57
 
58
58
  Wire facts below come from django-mojo `mojo/apps/jobs/` (`models/job.py`,
@@ -424,6 +424,6 @@ from hand-built `JobsStats` objects (and, for `JobRunnersStrip`, a demo-only
424
424
  branched for the demo.
425
425
 
426
426
  Styles are semantic `jobs-*` classes, tokens only, both themes:
427
- `apps/{portal,showcase}/src/theme/admin-jobs.css`, imported from each app's
428
- `theme.css`. The verifier asserts the two copies stay byte-identical and that
429
- neither contains a raw hex colour.
427
+ `packages/portal-mojo/src/styles/components/admin-jobs.css`, imported by the
428
+ package's `src/styles/index.css`. The verifier asserts that import and that
429
+ the file contains no raw hex colour.
@@ -0,0 +1,142 @@
1
+ # Media rendering admin
2
+
3
+ ```ts
4
+ import {
5
+ MediaRenderingPage, readRenditionOptions, findCategoryRows, valueToDraft, draftToValue,
6
+ validateDraft, routeRenditionError, saveCategoryValue, resetCategory, fieldType,
7
+ renditionRoleLabel,
8
+ } from 'portal-mojo/admin/infrastructure';
9
+ ```
10
+
11
+ Storage → Media rendering edits how django-mojo's fileman builds thumbnails,
12
+ previews and transcodes, and which of those run after an upload. It is a form
13
+ over three ordinary `Setting` rows — `FILEMAN_RENDITIONS_IMAGE`, `_VIDEO`,
14
+ `_DOCUMENT` — built entirely from the backend's descriptor (django-mojo #7728,
15
+ `docs/web_developer/fileman/renditions.md`). Nothing about roles, defaults,
16
+ fields, choices or limits is hardcoded: a role the framework adds appears
17
+ without a portal release.
18
+
19
+ ## Route and access
20
+
21
+ `STORAGE_ADMIN_SECTION` registers `storage/rendering` (label "Media
22
+ rendering") with the Settings page's gate, `sys.manage_settings | sys.groups`
23
+ — the page writes Setting rows, so it carries the Setting write audience, not
24
+ the storage one. The section audience includes those two grants so a
25
+ settings-only operator can reach the page; the other storage routes keep their
26
+ own clauses. Member grants never satisfy the gate.
27
+
28
+ The descriptor itself (`GET /api/fileman/renditions/options`) is readable by
29
+ `manage_settings | manage_files | files | groups`; a `files`-only operator can
30
+ read it elsewhere but cannot open this editor.
31
+
32
+ Global scope only. There is no group selector; group-scoped overrides stay on
33
+ the generic Settings page.
34
+
35
+ ## Wire contract
36
+
37
+ **Read:** `GET /api/fileman/renditions/options` → `data.categories.<image|video|document>`:
38
+
39
+ | Field | Used for |
40
+ |---|---|
41
+ | `key` | the Setting key to write |
42
+ | `defaults` | one row per role, in served order; the greyed values |
43
+ | `automatic_default` | the "runs on upload" default set |
44
+ | `override` | the global **database** row, parsed, or `null`. A deployment-file value is applied but not reported here |
45
+ | `effective` | defaults with the override (and any file value) applied; shown only as a read-only "In effect: …" hint where it differs from the draft |
46
+ | `role_kinds`, `fields`, `choices`, `limits` | which fields a role renders, their enum values and inclusive numeric bounds |
47
+
48
+ `data.engine` is read-only on this page (`ffmpeg`; the key stays on Settings
49
+ until a second engine exists).
50
+
51
+ **Row lookup:** `findCategoryRows(key)` lists `/api/settings?key=<KEY>&group__isnull=true`
52
+ with raw `mojoList` — `SettingModel.normalizeListParams` strips `key`, so the
53
+ model's list hook cannot do this. More than one global row for a key disables
54
+ Save and Reset with a notice naming the ids: `Setting.Meta.ordering = ["key"]`
55
+ leaves the server's `.first()` undefined among duplicates, so editing one could
56
+ change a row the renderer never reads.
57
+
58
+ **Write:** the whole category as one object — `POST /api/settings` to create
59
+ (`{key, is_secret: false, group: null, value}`) or `POST /api/settings/<id>`
60
+ to update (`{value}`). The value is sent as an object; the server stores it as
61
+ JSON text. Imperative `mojoSave` under `withFreshAuth`; a failure rejects.
62
+
63
+ **Reset = write `{}`.** The live `Setting` model declares no `CAN_DELETE`, so
64
+ `DELETE /api/settings/<id>` answers 403 — the backend doc's DELETE instruction
65
+ does not work live. `{}` is the documented "defaults" value and the endpoint
66
+ reports it as `override: null`; the row stays, harmlessly. Reset is disabled
67
+ when there is no row or the override is already null.
68
+
69
+ ## Mapper invariants (`rendition-settings.ts`)
70
+
71
+ - **Draft seeds from `defaults ⊕ override`**, never from `effective`. Seeding
72
+ from `effective` would mark a deployment-file value as changed on an
73
+ untouched form and copy it into a DB row on the first Save.
74
+ - **`draftToValue` emits deltas against the defaults**, not against the
75
+ previous override: a field restored to its default disappears from the row.
76
+ Empty role objects are omitted; `{}` means defaults. Save is disabled when
77
+ the emitted value equals the current override.
78
+ - **One typing rule, `fieldType(category, kind, name)`**: `choice` when
79
+ `choices[kind][name]` exists, else `number` when the name is in `limits`
80
+ (checked in that order — the server lists a bound for every option name it
81
+ limits anywhere in the category, so document `quality`, a choice on pdf
82
+ roles, also appears under `limits`), `boolean`
83
+ when a default of that kind holds a boolean (today `audio`), else `string`.
84
+ Inputs coerce on change through `coerceFieldValue`; `<input type=number>`
85
+ strings never reach the payload (the server refuses `"300"`).
86
+ - **`_automatic`** is compared as a set and emitted only when it differs from
87
+ `automatic_default`, in default order with additions appended in `defaults`
88
+ key order (the renderer runs roles in list order). Roles the defaults do not
89
+ declare are dropped on read, as the server's `merge` does.
90
+ - **The mp4-only trio** (`codec`, `crf`, `preset`) is the one rule not in the
91
+ descriptor: it is hidden and stripped from the payload while a role's format
92
+ (draft, else default) is not `mp4`. Switching the format back restores the
93
+ draft values. Format wins over codec, exactly as the server validates.
94
+ - A field with no default (image `format`/`quality` — renderer class
95
+ attributes, not `default_renditions`) renders unset with a "renderer
96
+ default" placeholder; setting it emits it, clearing it omits it again.
97
+
98
+ ## Validation and the 400 slot
99
+
100
+ `validateDraft` mirrors the server's checks with the server's wording (whole
101
+ number / between min and max / one of … / `2000k or 2M` / `HH:MM:SS`). It is
102
+ **advisory**: inputs carry `min`/`max`/`step`, the hint shows inline as the
103
+ admin types, but Save stays enabled and the server decides. Gating on the
104
+ client copy would make the 400 path unreachable and drift from the server.
105
+
106
+ A refused save routes through `routeRenditionError(message, knownRoles)`:
107
+
108
+ | Message | Lands |
109
+ |---|---|
110
+ | `<role>.<option>: …` | under that field, same slot as the hint |
111
+ | `<role>: …` | under that role's row |
112
+ | `_automatic: …` | beside the "runs on upload" switches |
113
+ | anything else, or a role not on screen | the panel-level alert |
114
+
115
+ Never a generic toast.
116
+
117
+ ## Mock
118
+
119
+ `client/renditions-mock.ts` copies the renderers' `default_renditions`,
120
+ automatic roles, `OPTION_SCHEMA`, limits and the error grammar verbatim.
121
+ `describeRenditions(globalRows)` serves the descriptor from the mock's global
122
+ Setting rows; `validateRenditionSetting(key, value)` runs on every
123
+ `/api/settings` create/update whose key is a rendition key and answers
124
+ `{status: false, error, error_code: 400}` before the row mutates. DELETE stays
125
+ 403. The mock seeds `FILEMAN_RENDITIONS_VIDEO = {"video_hevc": {"crf": 24}}`
126
+ so a changed field sits beside greyed defaults on first load. Personas with
127
+ the gate: `security.manager@nativemojo.com`, `showcase.operator@nativemojo.com`
128
+ (password `mojo`).
129
+
130
+ ## Pitfalls
131
+
132
+ - `DocumentRenderer` on django-mojo `main` (2026-10-10) has no
133
+ `config_category`, so `FILEMAN_RENDITIONS_DOCUMENT` is validated and
134
+ described but not yet applied by the renderer. The Documents tab ships per
135
+ contract; the override takes effect once the renderer honours it.
136
+ - Changing options never touches existing files: open a file under Storage →
137
+ Files and use **Regenerate renditions** to see the result.
138
+ - `video_hevc` is never automatic unless `_automatic` names it; H.265 costs
139
+ several times the H.264 encode time and does not play in every browser, so
140
+ keep `video_mp4` as the compatibility rendition (the Video tab says so).
141
+
142
+ Verification: `npm run verify:admin-media-rendering`.
@@ -62,6 +62,7 @@ the inventory using `node scripts/verify-admin-modals.mjs --write-inventory`.
62
62
  | Storage/shortlinks | Bucket, FileManager, File, shortlink; owner/backend/test/CORS/upload/rendition/share | FileManager/shortlink/share active state; no File disable promise; bucket empty only |
63
63
  | Email/support | Domain, mailbox, sent message, template, contact; credentials/onboarding/audit/reconcile/test/template/status | Preserve evidence; separate mailbox directions and support status |
64
64
  | Phone/Push | Number, SMS, config, device, delivery, template; lookup/test/config editors | Config/template lifecycle; number/SMS/device/delivery evidence retained |
65
+ | Edge | Vhost with nested routes; shape-first wizard, route editor, upstream declare/retire, blocklist entry editor | Vhost and upstream enabled state; upstreams retire and are never deleted; vhost, route and blocklist deletion are retained operations |
65
66
  | Assistant/settings | Conversation, Skill, Setting; feed continuation, scoped memory, typed settings editor | Owner-only continuation; Skill active selection; memory is scoped editing |
66
67
 
67
68
  ## Resource lifecycle matrix
@@ -84,6 +85,9 @@ the inventory using `node scripts/verify-admin-modals.mjs --write-inventory`.
84
85
  | Push config / template | Existing active selection with separate manage/test grants |
85
86
  | Assistant conversation | Retain history; no arbitrary REST save or invented archive |
86
87
  | Assistant Skill | Existing `is_active` save requires `sys.view_admin`; authoritative reload after mutation |
88
+ | Edge vhost | Enable/disable from the header; Edit and Delete in the header menu. Disabling keeps the row and is how a replacement is staged |
89
+ | Edge upstream | `is_enabled` only. Retire (platform administrators) disables and keeps the row; server `CAN_DELETE=False` |
90
+ | Edge blocklist entry | Mode is the lifecycle: `off` parks an entry without losing it. Deletion is a retained operation |
87
91
  | Other logs/devices/history | Read-only by default, even where low-level REST supports DELETE |
88
92
 
89
93
  The public low-level exported delete APIs remain compatible. Built-in controls
@@ -101,6 +105,9 @@ conversation archival require backend work before new lifecycle UI is added.
101
105
  | DNS record-set removal | DNS manage gate, provider capability and record coordinator. Preview all values, apex/service impact and exact name/type; stale-state preflight and reconcile failures; no domain deletion |
102
106
  | Empty bucket | `sys.manage_aws OR sys.files`, then fresh auth; header to focused armed confirmation and exact case-sensitive bucket name. Removes objects, versions, markers and multipart uploads; retains bucket. Display partial/unknown outcomes and always refresh |
103
107
  | Jobs retention purge | `sys.manage_jobs OR sys.jobs`; dry-run estimate bound to normalized days/status plus generation. Editing/replacing preview resets arming. Ignore stale/unmounted responses; lock inputs/dismissal during execution. Report actual cutoff and cascade-inclusive counts; rejection requires another preview |
108
+ | Edge vhost deletion | `sys.manage_dns OR sys.security`; house vhosts are platform administrators only. Header menu to a focused confirmation naming the address. Stops serving the name and deletes its routes with it; the confirmation points at Disable as the reversible choice |
109
+ | Edge route removal | Same grant, inside the vhost's Routes content with an armed control naming the prefix. Requests under the prefix stop being proxied. A quiet path left without a covering route makes the vhost's next save fail, and the server's message is shown |
110
+ | Edge blocklist entry removal | `sys.manage_security OR sys.security`; armed control in the entry's editor. The entry leaves every node within about ten minutes. `off` is the reversible choice |
104
111
  | Queue/consumer cleanup | Jobs manage grant, exact channel/scope and armed confirmation; queue clear sends `confirm:yes` only after consent and may cancel pending database jobs. Report partial outcomes |
105
112
 
106
113
  Other non-row removals retain their scoped editor semantics: rate-limit override
@@ -93,7 +93,7 @@ current contract.
93
93
  ## Styling and showcase
94
94
 
95
95
  Monitoring components use semantic `monitoring-*` classes and application
96
- tokens only. A consuming app must include `admin-monitoring.css`. This repo's
97
- portal and showcase theme entries do so. The showcase's Admin Monitoring demo
98
- contains static request, response, JSON, and HTML-looking plain-text cases for
99
- both-theme verification without mutating a backend.
96
+ tokens only. `admin-monitoring.css` ships in `portal-mojo/styles.css`. The
97
+ showcase's Admin Monitoring demo contains static request, response, JSON, and
98
+ HTML-looking plain-text cases for both-theme verification without mutating a
99
+ backend.
@@ -33,8 +33,8 @@ Perimeter control: block an abusive IP right now, whitelist a customer's NAT,
33
33
  maintain country and datacenter CIDR sets synced to the firewall fleet, set
34
34
  geographic policy — and prove the policy does what you think with a built-in
35
35
  simulator before it bites real traffic. Demo: showcase → Admin → **Network
36
- security**. Styles: `apps/{portal,showcase}/src/theme/admin-network.css`
37
- (byte-identical).
36
+ security**. Styles:
37
+ `packages/portal-mojo/src/styles/components/admin-network.css`.
38
38
 
39
39
  ## Surfaces
40
40
 
@@ -13,7 +13,10 @@ Import `PhoneHubPage`, the three individual pages, models, permission clauses, a
13
13
  - `PhoneConfigModel` reads sanitized scalar/default-graph rows. Saves and `test_connection` use imperative, cache-free calls. The editor follows web-mojo's provider-conditional layout: selecting Twilio, AWS SNS, or Mojo Remote shows and submits only that provider's connection fields. Mojo Remote uses `https://api.mojoverify.com` when the saved URL is unset or the editor is switched to Mojo; an explicitly configured custom URL is preserved. Secret inputs are DOM refs, not React or Query state: blank means untouched and is omitted; replacement must be non-empty; clear sends `null` only after a separate confirmation. Switching providers resets pending clears, and hidden provider credentials remain untouched. Responses are allowlisted before cache reconciliation. This surface edits existing provider credentials; it does not provision Django user/group API keys.
14
14
 
15
15
  Connection tests read the provider's `success` verdict, not the outer REST
16
- `status`. A failed or missing verdict is an error; `test_mode` is a warning
16
+ `status`. The real wire answers the test flat (the action dict verbatim, as
17
+ the mock now does too), so the call passes `refusal: 'return'`: a failed
18
+ verdict is an answer for inline display, not a rejected request. A failed or
19
+ missing verdict is an error; `test_mode` is a warning
17
20
  that the provider was not contacted. Results persist inside the detail modal
18
21
  on both sections, alongside a toast. Scope is labeled in the header and
19
22
  Connection section. Editing is disabled while a connection test is pending, so an old response
@@ -8,8 +8,11 @@ import {
8
8
  ```
9
9
 
10
10
  `STORAGE_ADMIN_SECTION` contributes global/no-group Infrastructure routes at
11
- `storage/buckets`, `storage/backends`, and `storage/files` (or the same paths
12
- under an embedded `/system` mount). There are no record routes: buckets,
11
+ `storage/buckets`, `storage/backends`, `storage/files` and `storage/rendering`
12
+ (or the same paths under an embedded `/system` mount). `storage/rendering` is
13
+ the Media rendering editor — see [admin-media-rendering.md](admin-media-rendering.md);
14
+ it carries the Settings gate (`sys.manage_settings | sys.groups`), which the
15
+ section audience therefore includes. There are no record routes: buckets,
13
16
  backends, nested files, and files use `modal.detail`.
14
17
 
15
18
  ## Permissions
@@ -13,6 +13,8 @@ import {
13
13
  // Fresh-auth (step-up) challenge surface
14
14
  REAUTH_STATUS, isReauthRequired, setFreshAuthHandler, requestFreshAuth,
15
15
  withFreshAuth, sessionIsPersistent,
16
+ // shared passkey error copy (LoginPage, MfaPanel, FreshAuthHost)
17
+ passkeyErrorMessage,
16
18
  type MfaChallenge, type FreshAuthHandler, type MfaCompletionOptions,
17
19
  } from 'portal-mojo/client';
18
20
 
@@ -80,6 +82,8 @@ answers with a full TokenGrant).
80
82
  - **Passkey**: `loginWithPasskey(username?, {remember})` behind `isPasskeySupported()`;
81
83
  no username → discoverable-credential prompt. The ceremony is fully built;
82
84
  REAL-authenticator verification is deferred (mock validates shape only).
85
+ Failures read through `passkeyErrorMessage()` (one copy for login, the MFA
86
+ passkey step and the fresh-auth modal).
83
87
  The remember choice is explicit because MFA and fresh-auth passkey paths
84
88
  must not promote a sessionStorage session into localStorage.
85
89
 
@@ -193,9 +197,10 @@ the first matching 440, retry success, and later normal calls reproducible.
193
197
  deployment-specific providers (`GET /api/auth/config` → `login.methods`),
194
198
  and no way to exercise the round-trip against the mock or without real
195
199
  provider credentials. Hosted pages cover OAuth deployments today.
196
- - **Passkey real-authenticator verification** — the ceremony is fully
197
- built on auth.ts; verifying against a real authenticator + a live
198
- django-mojo passkey registration is Ian's post-merge pass.
200
+ - **Passkey real-authenticator verification** — login and registration
201
+ ceremonies are built on auth.ts (`loginWithPasskey`, `registerPasskey` —
202
+ the AccountModal's Add a passkey, see [account.md](account.md)).
203
+ Registration ceremony built; real-authenticator check pending.
199
204
 
200
205
  ## Pitfalls
201
206
 
package/docs/auth.md CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  sendMagicLink, loginWithMagicToken, handleMagicTokenFromURL,
8
8
  forgotPassword, resetPasswordWithCode, resetPasswordWithToken,
9
9
  loginWithPasskey, isPasskeySupported,
10
+ // self-service, signed in (the AccountModal's wire — see account.md)
11
+ registerPasskey, isPasskeyRegistrationSupported, suggestPasskeyName,
12
+ passkeyErrorMessage, confirmEmailChange, revokeOtherSessions,
10
13
  } from 'portal-mojo/client';
11
14
  ```
12
15
 
@@ -17,6 +20,7 @@ initAuth(); // installs the pre-request gate + Authorizati
17
20
  void handleAuthCodeFromURL(); // returning from the hosted /auth pages (no-op otherwise)
18
21
  onAuth('login', () => queryClient.invalidateQueries()); // identity changed →
19
22
  onAuth('logout', () => queryClient.invalidateQueries()); // every cached answer is suspect
23
+ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issued session
20
24
  ```
21
25
 
22
26
  ## Session mechanics
@@ -27,8 +31,24 @@ onAuth('logout', () => queryClient.invalidateQueries()); // every cached answe
27
31
  - `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
28
32
  - `logout()` is client-side (django-mojo has no logout endpoint) and emits
29
33
  `'logout'`.
30
- - Events for `onAuth`: `'login' | 'logout' | 'refreshed' | 'refresh-failed'
31
- | 'unauthorized'`.
34
+ - Every flow that answers with a login grant adopts it only when it carries
35
+ an `access_token` + `refresh_token` pair; anything else (e.g. django-mojo's
36
+ `forced_password_response` after `auth_key` rotated) rejects with
37
+ `GRANT_SIGN_IN_AGAIN` ("Sign in again to continue") and leaves storage
38
+ untouched. Adoption waits for an in-flight refresh, and a refresh that
39
+ completes after the stored session changed (new login, logout, another
40
+ tab) is discarded instead of written over it.
41
+ - Events for `onAuth`: `'login' | 'rotated' | 'logout' | 'refreshed' |
42
+ 'refresh-failed' | 'unauthorized'`.
43
+ - `'login'` — an explicit, completed sign-in (password, MFA step, passkey,
44
+ magic link, password reset, hosted-auth exchange).
45
+ - `'rotated'` — the SAME user's session re-issued by a credential change
46
+ (`revokeOtherSessions()`, `confirmEmailChange()`): new tokens in the same
47
+ storage, but NOT a login — never treat it as one (the packaged Admin
48
+ source session reopens a logout tombstone only on `'login'`). Invalidate
49
+ caches on it where you do on `'login'`.
50
+ - `'logout'` / `'unauthorized'` — the session ended; the toolkit modal
51
+ stack closes every open dialog on both.
32
52
 
33
53
  Both localStorage and sessionStorage keep the existing token keys. Refresh
34
54
  preserves the chosen storage, including sessions with only a valid refresh token
@@ -84,3 +104,26 @@ magic-link (`sendMagicLink` → `loginWithMagicToken`), password reset (code
84
104
  and token variants), passkeys (`loginWithPasskey`; ceremony fully ported,
85
105
  mock validates shape only). All reject with the server's message on
86
106
  failure.
107
+
108
+ ## Self-service credential changes (signed in)
109
+
110
+ - `isPasskeyRegistrationSupported()` — `PublicKeyCredential` **and**
111
+ `navigator.credentials.create` (`isPasskeySupported()` only checks `get`).
112
+ - `registerPasskey(name?)` — fresh-auth `POST /api/account/passkeys/register/begin`
113
+ → `navigator.credentials.create` → fresh-auth `…/register/complete
114
+ {challenge_id, credential, friendly_name}`. `transports` rides at the
115
+ credential's top level; `id === rawId`. Returns the saved row; callers
116
+ refresh `PasskeyModel` and `me`. Default name `suggestPasskeyName()`.
117
+ - `passkeyErrorMessage(err)` — one copy for every passkey ceremony (login,
118
+ step-up, registration): NotAllowedError → "Passkey prompt was dismissed",
119
+ InvalidStateError → already registered, SecurityError → wrong domain/HTTPS,
120
+ AbortError → cancelled; anything else (server text) as written.
121
+ - `confirmEmailChange(code)` — `POST /api/auth/email/change/confirm`; the
122
+ server rotates `auth_key` and answers a new login, adopted into the
123
+ storage this session already uses (`sessionIsPersistent()`).
124
+ - `revokeOtherSessions()` — fresh-auth `POST /api/auth/sessions/revoke`;
125
+ every other session dies, the returned login is adopted so this one stays.
126
+
127
+ Registration ceremony built; real-authenticator check pending. The rest of
128
+ the account wire (TOTP, recovery codes, phone, verify, preferences) is in
129
+ [account.md](account.md).
package/docs/calendar.md CHANGED
@@ -101,7 +101,7 @@ Parse fns return `null` on invalid input, never throw; format fns return
101
101
 
102
102
  ## Styling
103
103
 
104
- `apps/portal/src/theme/calendar.css` — engine anatomy only
104
+ `packages/portal-mojo/src/styles/components/calendar.css` — engine anatomy only
105
105
  (`mojo-calendar-*`: pane/head/nav/grid/weekday/cell + state classes), all
106
106
  colors via theme tokens; component tokens `--mojo-cal-anchor-fill`,
107
107
  `--mojo-cal-range-fill`, `--mojo-cal-range-fill-hover` (accent tints: