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.
- package/README.md +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +7 -2
- package/docs/account.md +261 -0
- package/docs/admin-bouncer.md +3 -3
- package/docs/admin-credentials.md +4 -3
- package/docs/admin-devices-geoip.md +1 -1
- package/docs/admin-dns-certificates.md +2 -2
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-edge.md +210 -0
- package/docs/admin-identity-directories-sources.json +2 -2
- package/docs/admin-identity-directories.md +9 -0
- package/docs/admin-identity-users.md +2 -2
- package/docs/admin-jobs.md +4 -4
- package/docs/admin-media-rendering.md +142 -0
- package/docs/admin-modals.md +7 -0
- package/docs/admin-monitoring.md +4 -4
- package/docs/admin-network-security.md +2 -2
- package/docs/admin-phonehub.md +4 -1
- package/docs/admin-storage.md +5 -2
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/calendar.md +1 -1
- package/docs/client.md +159 -26
- package/docs/collection-multiselect.md +2 -2
- package/docs/combobox.md +2 -2
- package/docs/dataview.md +1 -1
- package/docs/datepicker.md +3 -3
- package/docs/daterange.md +1 -1
- package/docs/datetimepicker.md +1 -1
- package/docs/defineModel.md +40 -7
- package/docs/detail-primitives.md +1 -1
- package/docs/feedback.md +18 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +13 -1
- package/docs/loading.md +2 -2
- package/docs/location-address.md +2 -1
- package/docs/markdown.md +3 -3
- package/docs/menus-and-access.md +21 -1
- package/docs/multiselect-dropdown.md +1 -1
- package/docs/popover.md +1 -1
- package/docs/record-feed.md +2 -2
- package/docs/taginput.md +2 -1
- package/docs/timepicker.md +1 -1
- package/docs/timezone-select.md +4 -3
- package/docs/user-menu.md +4 -3
- package/docs/worldmap.md +2 -2
- package/package.json +4 -2
- package/src/account/AccountModal.tsx +276 -0
- package/src/account/AccountSections.tsx +370 -0
- package/src/account/NotificationPreferences.tsx +152 -0
- package/src/account/PasskeyList.tsx +144 -0
- package/src/account/api.ts +236 -0
- package/src/account/credential-models.ts +153 -0
- package/src/account/dialogs.tsx +822 -0
- package/src/account/index.ts +16 -0
- package/src/account/models.ts +76 -0
- package/src/account/sections/ApiKeysSection.tsx +138 -0
- package/src/admin/core/index.ts +1 -1
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/domains/infrastructure.ts +12 -2
- package/src/admin/domains/security.ts +3 -1
- package/src/admin/edge/BlocklistPage.tsx +110 -0
- package/src/admin/edge/UpstreamsPage.tsx +113 -0
- package/src/admin/edge/VhostDetail.tsx +166 -0
- package/src/admin/edge/VhostWizard.tsx +273 -0
- package/src/admin/edge/VhostsPage.tsx +47 -0
- package/src/admin/edge/api.ts +56 -0
- package/src/admin/edge/index.ts +15 -0
- package/src/admin/edge/models.ts +395 -0
- package/src/admin/edge/permissions.ts +13 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
- package/src/admin/identity/users/models.ts +16 -128
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
- package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
- package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
- package/src/admin/identity/users/sections/actions.tsx +10 -103
- package/src/admin/index.ts +1 -0
- package/src/admin/phonehub/api.ts +5 -3
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/registry.ts +2 -1
- package/src/admin/storage/MediaRenderingPage.tsx +258 -0
- package/src/admin/storage/file-renditions.ts +12 -0
- package/src/admin/storage/index.ts +2 -0
- package/src/admin/storage/rendition-settings.ts +264 -0
- package/src/charts/exportChart.ts +1 -1
- package/src/client/action-result.ts +23 -61
- package/src/client/active-group.ts +9 -3
- package/src/client/auth.ts +207 -6
- package/src/client/client.ts +49 -10
- package/src/client/errors.ts +57 -2
- package/src/client/group-context.ts +2 -0
- package/src/client/group.tsx +112 -20
- package/src/client/hooks.ts +7 -3
- package/src/client/mock.ts +1429 -20
- package/src/client/model.ts +15 -3
- package/src/client/renditions-mock.ts +212 -0
- package/src/client/scoped.ts +19 -5
- package/src/styles/components/account.css +304 -0
- package/src/styles/components/admin-assistant.css +28 -0
- package/src/styles/components/admin-bouncer.css +53 -0
- package/src/styles/components/admin-cloudwatch.css +23 -0
- package/src/styles/components/admin-dashboard.css +16 -0
- package/src/styles/components/admin-devices.css +260 -0
- package/src/styles/components/admin-dns-certificates.css +28 -0
- package/src/styles/components/admin-dns-registrar.css +1 -0
- package/src/styles/components/admin-dns.css +43 -0
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-incidents.css +14 -0
- package/src/styles/components/admin-jobs.css +237 -0
- package/src/styles/components/admin-media-rendering.css +38 -0
- package/src/styles/components/admin-messaging.css +3 -0
- package/src/styles/components/admin-monitoring.css +152 -0
- package/src/styles/components/admin-network.css +406 -0
- package/src/styles/components/admin-phonehub.css +61 -0
- package/src/styles/components/admin-push.css +30 -0
- package/src/styles/components/admin-rules.css +23 -0
- package/src/styles/components/admin-security.css +37 -0
- package/src/styles/components/admin-shortlinks.css +8 -0
- package/src/styles/components/admin-signin.css +64 -0
- package/src/styles/components/admin-storage.css +4 -0
- package/src/styles/components/auth.css +162 -0
- package/src/styles/components/calendar.css +228 -0
- package/src/styles/components/charts-c2.css +249 -0
- package/src/styles/components/collection-multiselect.css +114 -0
- package/src/styles/components/collection-select.css +63 -0
- package/src/styles/components/combobox.css +42 -0
- package/src/styles/components/dataview.css +139 -0
- package/src/styles/components/datepicker.css +106 -0
- package/src/styles/components/daterange.css +119 -0
- package/src/styles/components/datetimepicker.css +230 -0
- package/src/styles/components/detail-primitives.css +246 -0
- package/src/styles/components/detailview.css +69 -0
- package/src/styles/components/drawer.css +111 -0
- package/src/styles/components/file-upload.css +112 -0
- package/src/styles/components/form-wizard.css +11 -0
- package/src/styles/components/formview.css +74 -0
- package/src/styles/components/group-admin.css +302 -0
- package/src/styles/components/guardrail.css +29 -0
- package/src/styles/components/idioms.css +84 -0
- package/src/styles/components/image-editor.css +80 -0
- package/src/styles/components/loading.css +200 -0
- package/src/styles/components/location-address.css +32 -0
- package/src/styles/components/markdown.css +192 -0
- package/src/styles/components/multiselect-dropdown.css +92 -0
- package/src/styles/components/popover.css +35 -0
- package/src/styles/components/record-feed.css +161 -0
- package/src/styles/components/right-panel.css +37 -0
- package/src/styles/components/tabs.css +26 -0
- package/src/styles/components/taginput.css +132 -0
- package/src/styles/components/timepicker.css +282 -0
- package/src/styles/components/user-admin.css +189 -0
- package/src/styles/components/user-menu.css +98 -0
- package/src/styles/components/worldmap.css +97 -0
- package/src/styles/core.css +807 -0
- package/src/styles/index.css +69 -0
- package/src/styles/required-tokens.json +32 -0
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +4 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +21 -3
package/docs/client.md
CHANGED
|
@@ -34,6 +34,19 @@ status, semantic `error_code`, and structured safe failure evidence. Nothing out
|
|
|
34
34
|
parses envelopes; `mojoCall(path, {method, params, body})` is the typed
|
|
35
35
|
escape hatch for protocol modules and returns the unwrapped `Envelope`.
|
|
36
36
|
|
|
37
|
+
**Replacement tokens.** An envelope may carry `tokens: {access_token,
|
|
38
|
+
refresh_token}` beside `data`. django-mojo sends it when the request ended every
|
|
39
|
+
other session of the caller's own account — today a save that changed the
|
|
40
|
+
caller's own password (for example an admin using "Set password" on their own
|
|
41
|
+
row). The tokens the request was sent with are already dead, so the boundary
|
|
42
|
+
stores the new pair through the auth hook `tokensReplaced`, in the storage the
|
|
43
|
+
session already lives in. Callers do nothing; the realtime socket reconnects
|
|
44
|
+
with the new token. No `tokens` means no change. A token refresh that was in
|
|
45
|
+
flight when the session was replaced is discarded when it answers, success or
|
|
46
|
+
401: it was for the old session, and it neither overwrites the new pair nor
|
|
47
|
+
reports it unauthorized. The same guard keeps a late refresh from signing a
|
|
48
|
+
user back in after a sign-out.
|
|
49
|
+
|
|
37
50
|
`FetchOpts.beforeSend` is an optional synchronous final validity check, invoked
|
|
38
51
|
after the auth gate refreshes credentials and before headers/network activity.
|
|
39
52
|
The packaged app uses it to recheck its source-generation binding at the cookie
|
|
@@ -45,6 +58,19 @@ shape. Modern envelopes use numeric top-level `code` plus a string semantic
|
|
|
45
58
|
envelopes always reject. Structured `data` can contain deliberately safe
|
|
46
59
|
recovery evidence, but callers must still avoid logging it indiscriminately.
|
|
47
60
|
|
|
61
|
+
A handler's **flat refusal inside an HTTP 200** — a top-level
|
|
62
|
+
`success:false`, as a model action answers — rejects too, on every call
|
|
63
|
+
through the boundary (`mojoCall`, `mojoList`, `mojoGet`, `mojoSave`,
|
|
64
|
+
`mojoDelete`, `mojoMetrics`, `mojoScopedCall`). It throws
|
|
65
|
+
**`ActionRefusedError`**, a `MojoError` with `status: 200`, `message` = the
|
|
66
|
+
server's `error` (or `message`) text, `errorCode` = the string refusal
|
|
67
|
+
`code`, and `data`/`result.payload` = the full reply; a raw call's label is
|
|
68
|
+
its request path. A wrapped `{status:true, data:{success:false}}` is **data**
|
|
69
|
+
here — a successful POST can legitimately carry it. A call whose flat
|
|
70
|
+
`success:false` is a real answer (a connection test) passes
|
|
71
|
+
**`refusal: 'return'`** and reads the reply itself with `readActionResult`.
|
|
72
|
+
See [Upgrading to 0.3](#upgrading-to-03).
|
|
73
|
+
|
|
48
74
|
## Functions
|
|
49
75
|
|
|
50
76
|
| Fn | Wire | Returns |
|
|
@@ -62,6 +88,11 @@ desc; filters are Django lookups (`field`, `field__in=a,b`,
|
|
|
62
88
|
triple is `dr_field/dr_start/dr_end`; datetimes serialize as epoch seconds;
|
|
63
89
|
unknown params are silently ignored by the server.
|
|
64
90
|
|
|
91
|
+
`mojoGet`, `mojoSave` and `mojoDelete` take an optional last argument,
|
|
92
|
+
`{ unscoped?: boolean }` — the record-route opt-out from a registered
|
|
93
|
+
endpoint scope (see "Record routes vs custom views" below). `defineModel`
|
|
94
|
+
passes it for you.
|
|
95
|
+
|
|
65
96
|
The executable mock coerces comparisons narrowly: both operands must be
|
|
66
97
|
fully numeric for numeric comparison; temporal comparison only recognizes
|
|
67
98
|
canonical `YYYY[-MM[-DD]]` or full ISO datetime shapes; everything else is
|
|
@@ -77,15 +108,21 @@ answer 440. Ordinary requests and real transports are unaffected.
|
|
|
77
108
|
|
|
78
109
|
django-mojo POST_SAVE_ACTION handlers can refuse **inside an HTTP 200**, in
|
|
79
110
|
two wire shapes — flat `{success:false, code, error}` or wrapped
|
|
80
|
-
`{status:true, data:{success:false, …}}
|
|
81
|
-
|
|
82
|
-
|
|
111
|
+
`{status:true, data:{success:false, …}}`. The unwrap boundary rejects the
|
|
112
|
+
flat shape for every call (see the envelope section above); the wrapped shape
|
|
113
|
+
can be ordinary data on a successful POST, so only the action layer, which
|
|
114
|
+
knows it posted an action, reads it as a refusal. `mojoAction` and
|
|
115
|
+
`useAction` pass `refusal: 'return'` to unwrap and read the reply
|
|
116
|
+
themselves, so their error names the action rather than the path (envelope
|
|
117
|
+
`status:false` is a different failure and already rejects). Never
|
|
118
|
+
hand-sniff these shapes; there is one reader:
|
|
83
119
|
|
|
84
120
|
- `readActionResult(body)` → `{ok, code, error, payload}`. `payload` merges
|
|
85
121
|
the envelope with the action dict (action fields win), so one-shot
|
|
86
122
|
secrets/counters read from one place whichever direction they rode.
|
|
87
123
|
Payload `status:false` (the `revoke_sessions` spelling) also refuses;
|
|
88
|
-
bodies with no flag resolve `ok: true`.
|
|
124
|
+
bodies with no flag resolve `ok: true`. A refusal with no string `error`
|
|
125
|
+
takes its text from a string `message`; numeric codes are ignored.
|
|
89
126
|
- `mojoAction(endpoint, id, action, payload?)` — the raw-path primitive:
|
|
90
127
|
POSTs `{[action]: payload ?? true}` to `endpoint/<id>` under
|
|
91
128
|
`withFreshAuth`, normalizes the reply, and **REJECTS with
|
|
@@ -98,15 +135,18 @@ normalizer + cache maintenance; see defineModel.md "Action refusals" for
|
|
|
98
135
|
`refusal: 'reject' | 'return'`); **raw one-off action POST →
|
|
99
136
|
`mojoAction`**; **never** parse `success`/`status` off an action body at a
|
|
100
137
|
call site. Diagnostics whose flag is a result datum (connection testers)
|
|
101
|
-
either declare `refusal:'return'` on the model or
|
|
102
|
-
|
|
138
|
+
either declare `refusal:'return'` on the model or, posted raw, pass
|
|
139
|
+
`refusal: 'return'` to `mojoCall` and read the verdict with a comment.
|
|
103
140
|
|
|
104
141
|
## Endpoint scoping — the middle tier
|
|
105
142
|
|
|
106
|
-
The model layer
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`
|
|
143
|
+
The model layer needs no scope on record routes — `<endpoint>/<id>` gets,
|
|
144
|
+
saves, deletes and actions send none, because the server binds the row's own
|
|
145
|
+
group (see "Record routes vs custom views" below). Everything hand-rolled
|
|
146
|
+
with `useQuery` + `mojoCall` historically forgot the scope param — the largest
|
|
147
|
+
bug class in the first consumer audit (eight files, every one a raw read or
|
|
148
|
+
verb missing its `group`). The middle tier makes the raw path as safe as the
|
|
149
|
+
blessed one:
|
|
110
150
|
apps *declare* their scoped endpoint families once, and every helper —
|
|
111
151
|
plus a dev tripwire in the transport itself — enforces the declaration.
|
|
112
152
|
|
|
@@ -130,13 +170,62 @@ query, below); `required: false` families only inject when a group is active.
|
|
|
130
170
|
|
|
131
171
|
### Where the group id comes from
|
|
132
172
|
|
|
133
|
-
`GroupProvider` **owns** the active group
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
read
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
`GroupProvider` **owns** the active group. Three rules decide which group a
|
|
174
|
+
request carries:
|
|
175
|
+
|
|
176
|
+
1. **Hooks read the group context.** `useScopedQuery`, `useCan` and
|
|
177
|
+
`CollectionSelect` take the group from `GroupContext` at render time.
|
|
178
|
+
Under a mounted `GroupProvider`, `useScopedQuery` uses the context and
|
|
179
|
+
nothing else: a null context group means *no group*, never "whatever the
|
|
180
|
+
signal still holds". Only with no provider mounted at all does it fall
|
|
181
|
+
back to the signal.
|
|
182
|
+
2. **Plain functions read the signal.** `mojoScopedCall` and `mojoRpc` have
|
|
183
|
+
no React context, so the provider mirrors its *resolved* group into a
|
|
184
|
+
module-level signal (`setActiveGroupSignal` / `getActiveGroupId`). It
|
|
185
|
+
writes the signal in the **layout phase** of the commit, before any child
|
|
186
|
+
query starts its fetch, so the first request after a group switch already
|
|
187
|
+
carries the new group.
|
|
188
|
+
3. **An explicit `group` id wins everywhere**, on hooks and plain functions
|
|
189
|
+
alike. `group: null` differs: on `useScopedQuery` it means "unscoped by
|
|
190
|
+
choice"; on `mojoScopedCall` and `mojoRpc` a null `group` is treated as
|
|
191
|
+
not passed and the signal is used.
|
|
192
|
+
|
|
193
|
+
Do not read `localStorage.active_group_id` yourself: the URL `?group=` param
|
|
194
|
+
BEATS the stored id (group.tsx resolution order), and only the provider
|
|
195
|
+
computes that resolution.
|
|
196
|
+
|
|
197
|
+
**Recommended default**
|
|
198
|
+
|
|
199
|
+
- Reads: `useScopedQuery`.
|
|
200
|
+
- Writes and other calls from event handlers: `mojoRpc` / `mojoScopedCall`.
|
|
201
|
+
- A query you write by hand with `useQuery`: put the group id in its key
|
|
202
|
+
(`['things', group?.id]`). The package cannot key a hand-rolled query for
|
|
203
|
+
you, and a key without the group serves one group's rows to the next.
|
|
204
|
+
|
|
205
|
+
**While the group is resolving.** At boot, or on a switch the cache was not
|
|
206
|
+
seeded for (`?group=`, or the fallback after a failed load), the provider
|
|
207
|
+
reports `loading` with a null group. `useScopedQuery` holds a registered
|
|
208
|
+
query, required or optional, **disabled** for that window, so nothing goes
|
|
209
|
+
out under the previous group or unscoped. A query given an explicit `group`
|
|
210
|
+
option is not held: it has nothing to wait for. A plain `mojoScopedCall` in the
|
|
211
|
+
same window sees an empty signal: a required family throws in dev and an
|
|
212
|
+
optional one goes out unscoped, exactly as at boot. Gate on `loading` or
|
|
213
|
+
render under `RequiresGroup`.
|
|
214
|
+
|
|
215
|
+
**Upgrading.** A blanket "invalidate everything when the group changes" is
|
|
216
|
+
no longer needed for `useScopedQuery`, nor for hand-rolled queries whose key
|
|
217
|
+
carries the group id.
|
|
218
|
+
|
|
219
|
+
**Caveats**
|
|
220
|
+
|
|
221
|
+
- A child's own `useLayoutEffect` runs before the provider's and still sees
|
|
222
|
+
the previous group in the signal. Use a hook, or pass `group` explicitly.
|
|
223
|
+
- A fetch started during render (`useSuspenseQuery`, `fetchOptimistic`) runs
|
|
224
|
+
before the commit, so the signal is not yet current. Pass `group`
|
|
225
|
+
explicitly.
|
|
226
|
+
- Signed out with a stored group, the provider is not `loading`, so an
|
|
227
|
+
optional family can fire once unscoped and again after sign-in. Scoped
|
|
228
|
+
product routes sit behind `RequireAuth`; nothing is held for this case.
|
|
140
229
|
|
|
141
230
|
### `useScopedQuery<T>(path, params?, {read?, group?, ...queryOpts})`
|
|
142
231
|
|
|
@@ -146,7 +235,8 @@ request params, and the group id rides the query key —
|
|
|
146
235
|
cached rows, while invalidation by `[path]` prefix keeps working. Keep that
|
|
147
236
|
key discipline if you write a raw scoped query by hand.
|
|
148
237
|
|
|
149
|
-
|
|
238
|
+
While the provider is still resolving its group, a registered query with no
|
|
239
|
+
explicit `group` is held disabled (see above). When a REQUIRED scope has no active group, the query renders **disabled** —
|
|
150
240
|
and a disabled query has *neither data nor error*. Do not draw an empty box
|
|
151
241
|
or a spinner that never resolves: state the wait honestly at the call site
|
|
152
242
|
("select a brand to load …").
|
|
@@ -161,14 +251,21 @@ or a spinner that never resolves: state the wait honestly at the call site
|
|
|
161
251
|
`withFreshAuth` with the scope injected; resolves `env.data as T`.
|
|
162
252
|
Action-shaped `{key: payload}` saves belong to `mojoAction`, not here.
|
|
163
253
|
|
|
164
|
-
### `mojoAction`
|
|
254
|
+
### `mojoAction` sends no scope
|
|
255
|
+
|
|
256
|
+
`mojoAction(endpoint, id, action, payload?)` posts exactly
|
|
257
|
+
`{[action]: payload}` to `<endpoint>/<id>` and never adds a scope key, on any
|
|
258
|
+
family, with or without an active group. Two reasons:
|
|
259
|
+
|
|
260
|
+
- A REST record route needs no group. django-mojo authorizes the request
|
|
261
|
+
against the **row's own** group, whatever the caller sends.
|
|
262
|
+
- A `group` in the body is not only a selector there: the save handler
|
|
263
|
+
writes it as the row's `group` field, and its presence turns the action
|
|
264
|
+
into a full field save. An action on a record read outside the active
|
|
265
|
+
group could move that record to the active group.
|
|
165
266
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
injected into the action POST body alongside `{[action]: payload}` — the
|
|
169
|
-
wallet-verbs class: actions the backend refuses for brand-scoped operators
|
|
170
|
-
without `group`. With nothing registered (the base admin) the injection is
|
|
171
|
-
inert.
|
|
267
|
+
The call is declared `unscoped: true`, so it passes the dev tripwire on a
|
|
268
|
+
registered-REQUIRED family. Earlier versions injected the scope here.
|
|
172
269
|
|
|
173
270
|
### The dev tripwire — and `unscoped: true`
|
|
174
271
|
|
|
@@ -182,6 +279,25 @@ for the rare genuinely-global call to a scoped family, mark it
|
|
|
182
279
|
skip the check (`import.meta.env.DEV` only); it is a development tripwire,
|
|
183
280
|
not a runtime gate.
|
|
184
281
|
|
|
282
|
+
### Record routes vs custom views
|
|
283
|
+
|
|
284
|
+
Two kinds of URL end in an id, and only the caller knows which it has:
|
|
285
|
+
|
|
286
|
+
- **REST record routes** — the model's own `<endpoint>/<id>` (get, save,
|
|
287
|
+
delete, POST_SAVE_ACTIONS). The server binds the row's group; send no
|
|
288
|
+
scope. `defineModel`'s record hooks, the generic `useModel` and
|
|
289
|
+
`useSaveModel` (with an id) and `mojoAction` already declare these
|
|
290
|
+
`unscoped: true`. A raw call to one does the same:
|
|
291
|
+
`mojoGet(endpoint, id, { unscoped: true })`.
|
|
292
|
+
- **Custom views on a record path** — `requests/<id>/resend`, a download by
|
|
293
|
+
id, any view the backend gates with a group permission decorator. These
|
|
294
|
+
read the group from the request and **do** need it: keep using `mojoRpc` /
|
|
295
|
+
`mojoScopedCall` / `useScopedQuery`, which inject it.
|
|
296
|
+
|
|
297
|
+
No path rule can tell the two apart, so the tripwire does not try. A
|
|
298
|
+
collection create (`POST <endpoint>`) is not a record route: the group on
|
|
299
|
+
the request decides where the new row lands, so pass it in the body.
|
|
300
|
+
|
|
185
301
|
## Mock admin contracts
|
|
186
302
|
|
|
187
303
|
The mock carries measured GroupView/admin transports, not UI-shaped
|
|
@@ -313,7 +429,7 @@ confirm `completed` before the task returns success. Completed scalar
|
|
|
313
429
|
## `mojoQueryDefaults()`
|
|
314
430
|
|
|
315
431
|
Spread into the app's `QueryClient` defaults. Provides: no retry on 4xx
|
|
316
|
-
`MojoError`s (deterministic failures), `networkMode: 'always'` under the
|
|
432
|
+
`MojoError`s or `ActionRefusedError` (deterministic failures), `networkMode: 'always'` under the
|
|
317
433
|
mock ('online' live), and `refetchOnWindowFocus: false` (freshness comes
|
|
318
434
|
from explicit refresh + opt-in autoRefresh — focus storms read as phantom
|
|
319
435
|
fetches).
|
|
@@ -325,3 +441,20 @@ fetches).
|
|
|
325
441
|
the semantic wire code, and `data` preserves safe structured evidence.
|
|
326
442
|
- `AuthRequiredError` — thrown by the pre-request gate when a request needs
|
|
327
443
|
a session that doesn't exist (synthetic 401; no network was touched).
|
|
444
|
+
|
|
445
|
+
## Upgrading to 0.3
|
|
446
|
+
|
|
447
|
+
0.3.0 is a breaking minor release: a raw call through the unwrap boundary
|
|
448
|
+
now **rejects** a flat HTTP-200 refusal (top-level `success:false`) with
|
|
449
|
+
`ActionRefusedError` instead of resolving it.
|
|
450
|
+
|
|
451
|
+
- Find raw `mojoCall` / `mojoScopedCall` sites that read a top-level
|
|
452
|
+
`.success`. Those refusal checks are now dead code: delete them. Their
|
|
453
|
+
`catch` receives `ActionRefusedError`, which is a `MojoError`, so existing
|
|
454
|
+
`instanceof MojoError` handling keeps working.
|
|
455
|
+
- A call whose flat `success:false` is a real answer, such as a connection
|
|
456
|
+
test, adds `refusal: 'return'` and keeps reading the reply.
|
|
457
|
+
- Reads of a wrapped `data.success` are unaffected.
|
|
458
|
+
- `err.errorCode` carries the refusal code and `err.data` the full reply, so
|
|
459
|
+
the server's reason is no longer lost.
|
|
460
|
+
- `defineModel` actions and `mojoAction` behave as before.
|
|
@@ -113,5 +113,5 @@ Sorting is the server's default unless `defaultParams` carries `sort`.
|
|
|
113
113
|
a permanently empty picker in global context.
|
|
114
114
|
- The dropdown's trigger/footer classes (`.multiselect-trigger`,
|
|
115
115
|
`.multiselect-footer`, `.multiselect-done`) live in
|
|
116
|
-
`multiselect-dropdown.css` —
|
|
117
|
-
`collection-multiselect.css
|
|
116
|
+
`multiselect-dropdown.css` — `portal-mojo/styles.css` imports it alongside
|
|
117
|
+
`collection-multiselect.css`.
|
package/docs/combobox.md
CHANGED
|
@@ -101,5 +101,5 @@ strings.
|
|
|
101
101
|
to the label, duplicating every row — not carried).
|
|
102
102
|
- The dropdown is mounted in the shared top-layer Popover, so overflow
|
|
103
103
|
containers and native dialogs do not clip it.
|
|
104
|
-
- Styles live in `
|
|
105
|
-
themes)
|
|
104
|
+
- Styles live in `packages/portal-mojo/src/styles/components/combobox.css` (tokens only, both
|
|
105
|
+
themes) and ship in `portal-mojo/styles.css`.
|
package/docs/dataview.md
CHANGED
|
@@ -11,7 +11,7 @@ Point it at any record and get a sensible detail grid. Field **names** and
|
|
|
11
11
|
**values** together pick the renderer, nested objects become nested grids,
|
|
12
12
|
and anything that isn't a scalar lands in a JSON block with copy and
|
|
13
13
|
collapse. Port of web-mojo `src/core/views/data/DataView.js` (1,153 lines).
|
|
14
|
-
Styles: `
|
|
14
|
+
Styles: `packages/portal-mojo/src/styles/components/dataview.css`. Demo: **Develop → Components →
|
|
15
15
|
DataView**.
|
|
16
16
|
|
|
17
17
|
## DataView vs KnownFieldsCard
|
package/docs/datepicker.md
CHANGED
|
@@ -144,8 +144,8 @@ lean on the per-precision default.
|
|
|
144
144
|
|
|
145
145
|
## Styling
|
|
146
146
|
|
|
147
|
-
`
|
|
148
|
-
box, inline host. The grid itself is `
|
|
149
|
-
surface is `
|
|
147
|
+
`packages/portal-mojo/src/styles/components/datepicker.css` — trigger, clear ✕, popover content
|
|
148
|
+
box, inline host. The grid itself is `components/calendar.css` and the floating
|
|
149
|
+
surface is `components/popover.css`; this file adds nothing they already cover.
|
|
150
150
|
Trigger metrics follow the house control (`.input`): `--surface2` fill,
|
|
151
151
|
`--line` border, 8px radius, accent focus ring. Tokens only, both themes.
|
package/docs/daterange.md
CHANGED
|
@@ -164,7 +164,7 @@ carry them when the registry lands:
|
|
|
164
164
|
`inline` is fluid to its host. Two panes + rail ≈ 850px — don't mount
|
|
165
165
|
two-pane popovers inside narrow scroll containers, and keep dialogs on
|
|
166
166
|
`months={1}`.
|
|
167
|
-
- Styling lives in `
|
|
167
|
+
- Styling lives in `packages/portal-mojo/src/styles/components/daterange.css` (trigger
|
|
168
168
|
`.mojo-daterange-*`, rail `.mojo-calendar-preset*`), tokens only, both
|
|
169
169
|
themes; the calendar grid ships in `calendar.css`, the popover shell in
|
|
170
170
|
`popover.css`.
|
package/docs/datetimepicker.md
CHANGED
|
@@ -222,7 +222,7 @@ not `new Date(epochSeconds)` without `× 1000`.
|
|
|
222
222
|
|
|
223
223
|
## Styling
|
|
224
224
|
|
|
225
|
-
`
|
|
225
|
+
`packages/portal-mojo/src/styles/components/datetimepicker.css` — layout glue only
|
|
226
226
|
(`mojo-datetime-*`: `trigger` / `trigger-text` / `trigger-clear` /
|
|
227
227
|
`popover-inner` / `row` / `cal-col` / `time-col` / `time-head` / `tz-row` /
|
|
228
228
|
`tz-label` / `tz-host` / `foot`), tokens only, both themes. The grid, the
|
package/docs/defineModel.md
CHANGED
|
@@ -28,11 +28,11 @@ export const UserModel = defineModel<User>({
|
|
|
28
28
|
| Member | Behavior |
|
|
29
29
|
|---|---|
|
|
30
30
|
| `useList(params?, {enabled?})` | `useQuery` list under key `[endpoint, params]` with `keepPreviousData`; `enabled:false` suppresses permission-hidden background reads |
|
|
31
|
-
| `useOne(id \| null)` | one record, key `[endpoint, 'one', id]`; disabled while `id` is null |
|
|
32
|
-
| `useSave()` | mutation `{id, changes}`; `id: null` creates. Handles one fresh-auth 440/retry, then writes the returned row into the one-record cache and invalidates `[endpoint]`. REJECTS on failure |
|
|
33
|
-
| `useDelete()` | mutation `{id}`; removes the one-record cache entry + invalidates |
|
|
34
|
-
| `useAction(name)` | mutation for ONE declared POST_SAVE_ACTION (below), with one fresh-auth 440/retry. THROWS at render time for an undeclared name |
|
|
35
|
-
| `fetchOne(queryClient, id)` | imperative fetch through the same cache key — dedupes with mounted `useOne`s (prefetch pattern) |
|
|
31
|
+
| `useOne(id \| null)` | one record, key `[endpoint, 'one', id]`; disabled while `id` is null. Sends no group scope |
|
|
32
|
+
| `useSave()` | mutation `{id, changes}`; `id: null` creates. Handles one fresh-auth 440/retry, then writes the returned row into the one-record cache and invalidates `[endpoint]`. REJECTS on failure. With an id it sends no group scope; a create sends only your `changes` |
|
|
33
|
+
| `useDelete()` | mutation `{id}`; removes the one-record cache entry + invalidates. Sends no group scope |
|
|
34
|
+
| `useAction(name)` | mutation for ONE declared POST_SAVE_ACTION (below), with one fresh-auth 440/retry. THROWS at render time for an undeclared name. The body is exactly `{[key]: payload}`; sends no group scope |
|
|
35
|
+
| `fetchOne(queryClient, id)` | imperative fetch through the same cache key — dedupes with mounted `useOne`s (prefetch pattern). Sends no group scope |
|
|
36
36
|
| `invalidate(queryClient)` | invalidate everything under `[endpoint]` |
|
|
37
37
|
| `keys.root / keys.list(params) / keys.one(id)` | key builders for targeted cache surgery |
|
|
38
38
|
|
|
@@ -45,6 +45,35 @@ only on final success. Delete remains unwrapped, as do direct `mojoCall`
|
|
|
45
45
|
mutations; callers opt those in only when the endpoint is both sensitive and
|
|
46
46
|
safe to retry once.
|
|
47
47
|
|
|
48
|
+
## Group scope
|
|
49
|
+
|
|
50
|
+
In a group portal an app registers its scoped endpoint families
|
|
51
|
+
(`registerEndpointScope`, [client.md](client.md)). The model layer treats
|
|
52
|
+
its two kinds of call differently:
|
|
53
|
+
|
|
54
|
+
- **Record calls send no group.** `useOne`, `fetchOne`, `useSave` with an
|
|
55
|
+
id, `useDelete` and `useAction` all hit `<endpoint>/<id>`. django-mojo
|
|
56
|
+
authorizes those against the row's own group, so a client group adds
|
|
57
|
+
nothing — and a `group` in a save or action body is written to the row as
|
|
58
|
+
its `group` field. The hooks declare these calls `unscoped: true`, so a
|
|
59
|
+
registered-REQUIRED family never blocks them in development.
|
|
60
|
+
- **A create sends only your `changes`.** `useSave` with `id: null` posts to
|
|
61
|
+
the collection, where the request's group decides where the row lands.
|
|
62
|
+
Put the group in `changes` yourself; on a REQUIRED family the dev tripwire
|
|
63
|
+
stops a create that carries none.
|
|
64
|
+
|
|
65
|
+
List reads (`useList`) are scoped only by the params you pass.
|
|
66
|
+
|
|
67
|
+
**Upgrading.** Record calls used to be stopped by the dev tripwire on a
|
|
68
|
+
REQUIRED family, and `mojoAction` used to inject the group. If your portal
|
|
69
|
+
grew a local wrapper to get past that — one that adds the group to an
|
|
70
|
+
action or save on a record, or refuses to send when the active group
|
|
71
|
+
changed since the record was read — delete it and call `M.useAction(verb)`
|
|
72
|
+
(or `mojoAction`) directly. Remove any hand-added `group` from record-path
|
|
73
|
+
bodies and params: it is at best ignored and at worst rewrites the row's
|
|
74
|
+
group. Custom views on a record path (`<id>/resend`) are not model calls and
|
|
75
|
+
keep their scope through `mojoRpc`.
|
|
76
|
+
|
|
48
77
|
## POST_SAVE_ACTIONS
|
|
49
78
|
|
|
50
79
|
Backend contract (`mojo/models/rest.py on_rest_save`): an action is a key
|
|
@@ -75,8 +104,12 @@ reaches the client in one of two wire shapes:
|
|
|
75
104
|
```
|
|
76
105
|
|
|
77
106
|
This is NOT the envelope failure: envelope-level `status:false` already
|
|
78
|
-
rejects at the unwrap boundary (`MojoError`).
|
|
79
|
-
|
|
107
|
+
rejects at the unwrap boundary (`MojoError`). Since 0.3 unwrap also rejects
|
|
108
|
+
the flat shape for raw calls; `useAction` opts out of that check
|
|
109
|
+
(`refusal: 'return'` on its own request) so the error names the action and
|
|
110
|
+
honours the model's declared mode. No behaviour change for models. The
|
|
111
|
+
action-refusal layer sits above the boundary and is handled by `useAction`
|
|
112
|
+
automatically:
|
|
80
113
|
|
|
81
114
|
- **`refusal: 'reject'` (default)** — a `success:false` (or payload
|
|
82
115
|
`status:false`) reply REJECTS with **`ActionRefusedError`**, so a refusal
|
|
@@ -10,7 +10,7 @@ Six small pieces admin record pages are composed from, ported from
|
|
|
10
10
|
web-mojo's `src/core/views/data/*` primitives plus its admin metadata
|
|
11
11
|
section. They are presentational and stateless (except `MetadataSection`,
|
|
12
12
|
which writes) — drop them inside a `DetailView` section, a `panel`, or a
|
|
13
|
-
`modal.detail`. Styles: `
|
|
13
|
+
`modal.detail`. Styles: `packages/portal-mojo/src/styles/components/detail-primitives.css`.
|
|
14
14
|
|
|
15
15
|
**All three trusted-HTML slots are gone.** web-mojo rendered
|
|
16
16
|
`StatusPanel.meta`, `FlowStrip.value/hint` and `Timeline.detail` as
|
package/docs/feedback.md
CHANGED
|
@@ -17,12 +17,21 @@ is a Promise that resolves when its dialog closes.
|
|
|
17
17
|
| `modal.detail((close) => <DetailView …/>)` | detail-sized shell for record views |
|
|
18
18
|
| `modal.drawer<T>({title, content\|render, …})` | `T \| null` — right slide-over (below) |
|
|
19
19
|
| `formModal({title, fields, initial?, submitText?, intro?})` | `FormData \| null` (see forms.md) |
|
|
20
|
+
| `modal.closeAll()` | closes every open modal/drawer; each await resolves `null` |
|
|
20
21
|
|
|
21
22
|
Patterns:
|
|
22
23
|
- Danger confirms: `danger: true` styles the confirm button destructively.
|
|
23
24
|
- Batch prepare: open a `formModal` INSIDE a flow to collect once-per-batch
|
|
24
25
|
input; resolve null to cancel the whole thing.
|
|
25
26
|
- Stacked dialogs are fine (disable-reason form over a detail modal).
|
|
27
|
+
- `canDismiss` also covers the dialog closing natively without a cancelable
|
|
28
|
+
`cancel` (Chromium's second Escape with no click between, Android back):
|
|
29
|
+
a locked modal reopens with focus inside; an unlocked one resolves `null`.
|
|
30
|
+
A `dismissable: false` drawer reopens the same way.
|
|
31
|
+
- The stack ends with the session: `'logout'` / `'unauthorized'` auth events,
|
|
32
|
+
or the signed-in uid changing (another tab), close every open dialog
|
|
33
|
+
(`null`), so a host remounted after sign-in never brings a previous user's
|
|
34
|
+
dialog back.
|
|
26
35
|
|
|
27
36
|
## confirmGuardrail — the "are you sure, and here is why" stop
|
|
28
37
|
|
|
@@ -64,10 +73,10 @@ disable, member `manage_group` / `manage_members` grants (through
|
|
|
64
73
|
which of the watched keys changed (null/'' normalised), so a save confirms
|
|
65
74
|
ONLY the fields that break things and passes silently otherwise.
|
|
66
75
|
|
|
67
|
-
Styles: `
|
|
76
|
+
Styles: `packages/portal-mojo/src/styles/components/guardrail.css` (`.guardrail`,
|
|
68
77
|
`.guardrail-effect/-why/-why-head/-undo/-type`, `.guardrail-icon`) over
|
|
69
|
-
`--bad/--bad-soft` (danger) and `--warn/--warn-soft` (`danger: false`).
|
|
70
|
-
|
|
78
|
+
`--bad/--bad-soft` (danger) and `--warn/--warn-soft` (`danger: false`). It
|
|
79
|
+
ships in `portal-mojo/styles.css`; the app declares the tokens (README).
|
|
71
80
|
|
|
72
81
|
## modal.drawer
|
|
73
82
|
|
|
@@ -103,7 +112,7 @@ title · meta) is web-mojo's `Modal.drawer()` carried over.
|
|
|
103
112
|
otherwise there is no exit at all, so it falls back to dismissable with a
|
|
104
113
|
`console.warn` (house rule: degenerate input → default + warn).
|
|
105
114
|
- An unknown `width` warns and falls back to 480px.
|
|
106
|
-
- Styles: `
|
|
115
|
+
- Styles: `packages/portal-mojo/src/styles/components/drawer.css` (`.mojo-drawer`, `.drawer-panel`,
|
|
107
116
|
`.drawer-head/-eyebrow/-title/-meta`, `.drawer-body`) — tokens only, both
|
|
108
117
|
themes. `DRAWER_EXIT_MS` in `modal.tsx` must match the closing keyframes.
|
|
109
118
|
|
|
@@ -130,10 +139,11 @@ and drawers), then returns to the page when the last modal closes. Modal-local
|
|
|
130
139
|
ancestry keeps Undo and progress Cancel usable despite native dialog inertness.
|
|
131
140
|
Moving the host preserves card state and timers and does not focus the toast.
|
|
132
141
|
Mount it once; do not add per-dialog toast hosts or solve this with `z-index`.
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
and background,
|
|
136
|
-
|
|
142
|
+
The toast CSS ships in `portal-mojo/styles.css`
|
|
143
|
+
(`packages/portal-mojo/src/styles/core.css`): it resets the popover's UA
|
|
144
|
+
inset/margin/border/padding and background, hides closed popovers, and keeps
|
|
145
|
+
only the cards pointer-active. An app that still carries a copied theme keeps
|
|
146
|
+
its old toast rules until it deletes the copy.
|
|
137
147
|
The showcase's **Toasts inside modal** demo covers errors, Undo, progress,
|
|
138
148
|
and nested opening/closing. `node scripts/verify-toast-modal.mjs` checks the
|
|
139
149
|
mounted lifecycle; native visibility and interaction require browser checks.
|
package/docs/file-upload-ux.md
CHANGED
|
@@ -140,7 +140,7 @@ npm run verify:record-attachments
|
|
|
140
140
|
|
|
141
141
|
It covers pure validation, lazy concurrency, capacity, stable/safe snapshots,
|
|
142
142
|
consumer/destination dedupe, real progress updates, callback-only retry,
|
|
143
|
-
truthful cancellation, known-id recovery, and
|
|
144
|
-
|
|
143
|
+
truthful cancellation, known-id recovery, and the package stylesheet importing
|
|
144
|
+
the upload CSS. The form contract adds strict relation normalization, explicit
|
|
145
145
|
attach states, owner-response reconciliation, orphan retention, safe previews,
|
|
146
146
|
avatar #1488 semantics, and cache-boundary sanitization.
|
package/docs/idioms.md
CHANGED
|
@@ -47,7 +47,7 @@ Behavior contract (dom.js `armedButton` parity):
|
|
|
47
47
|
live trigger). Fail-safe cost is one extra click, never an unintended fire.
|
|
48
48
|
- Clicks `stopPropagation()` — safe inside clickable rows / expand rows.
|
|
49
49
|
|
|
50
|
-
Styling: `
|
|
50
|
+
Styling: `packages/portal-mojo/src/styles/components/idioms.css` — `.armed-btn` (rest) /
|
|
51
51
|
`.armed-btn.is-armed` (armed, pulsing; static under
|
|
52
52
|
`prefers-reduced-motion`). Tokens only, both themes; the dark theme swaps
|
|
53
53
|
the armed text color for contrast against the light --bad.
|
|
@@ -66,6 +66,18 @@ handle.dismiss(); // optional early resolution
|
|
|
66
66
|
- `onUndo` typically fires the inverse mutation; surface ITS failure with a
|
|
67
67
|
normal `toast.error` — the undo toast itself is done by then.
|
|
68
68
|
|
|
69
|
+
## toast.action / actionToast
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
toast.action('Your other sessions stay signed in.', 'Sign out everywhere else', () => revokeOthers(), { timeout: 10000 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The undo card with a named button: a follow-up OFFER after something
|
|
76
|
+
already happened (not an inverse). Clicking dismisses at once and calls
|
|
77
|
+
`onAction`; otherwise the card auto-dismisses after `timeout` (default
|
|
78
|
+
10000ms). Returns `{ dismiss }`. Surface the action's own outcome with a
|
|
79
|
+
normal toast.
|
|
80
|
+
|
|
69
81
|
## toast.progress / progressToast
|
|
70
82
|
|
|
71
83
|
```ts
|
package/docs/loading.md
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
The "we're fetching / working" family. It **complements the skeleton
|
|
13
13
|
silhouette**, which stays the default for anything whose shape is already
|
|
14
14
|
known (see the decision table below). Live demo: portal → Develop →
|
|
15
|
-
Components → **Loaders**. Styling: `
|
|
15
|
+
Components → **Loaders**. Styling: `packages/portal-mojo/src/styles/components/loading.css` —
|
|
16
16
|
tokens only, both themes.
|
|
17
17
|
|
|
18
18
|
Ported from web-mojo `src/core/views/feedback/BusyIndicator.js` (its only
|
|
@@ -210,7 +210,7 @@ clicked away mid-save, they keep where they went.
|
|
|
210
210
|
restores focus to the element the inert switch blurred (see above). Its
|
|
211
211
|
veil is `pointer-events: none`: `inert` already stopped the clicks, and a
|
|
212
212
|
veil that swallowed them would break text selection after release.
|
|
213
|
-
- **Reduced motion:** the spinner does not spin. `
|
|
213
|
+
- **Reduced motion:** the spinner does not spin. `core.css` kills every
|
|
214
214
|
animation with a universal `!important` rule — right for a skeleton
|
|
215
215
|
(shimmer stops), wrong for a spinner (the ring would freeze mid-rotation
|
|
216
216
|
with one colored quarter and read as a stuck control). `loading.css`
|
package/docs/location-address.md
CHANGED
|
@@ -73,4 +73,5 @@ field in that batch back to its shared server snapshot.
|
|
|
73
73
|
|
|
74
74
|
The suggestion list uses the shared `Popover`, which is the sole owner of
|
|
75
75
|
outside-mousedown and Escape dismissal. Styles live in
|
|
76
|
-
`
|
|
76
|
+
`src/styles/components/location-address.css` (shipped in
|
|
77
|
+
`portal-mojo/styles.css`) and use design tokens in both themes.
|
package/docs/markdown.md
CHANGED
|
@@ -24,8 +24,8 @@ tab or a cold first frame still shows the document.
|
|
|
24
24
|
| `debounceMs` | `0` | Wait this long after `source` stops changing before calling the server. Set it for text that grows token by token (a streaming assistant reply) so one reply is one request. |
|
|
25
25
|
| `className` | — | Extra classes on the wrapper. `md` is always present. |
|
|
26
26
|
|
|
27
|
-
Renders one `<div class="md">`. Styling
|
|
28
|
-
`
|
|
27
|
+
Renders one `<div class="md">`. Styling ships in `portal-mojo/styles.css` —
|
|
28
|
+
`packages/portal-mojo/src/styles/components/markdown.css`, tokens only, both themes; both render
|
|
29
29
|
paths emit the same element set, so one stylesheet dresses both.
|
|
30
30
|
|
|
31
31
|
### No loading jump, by construction
|
|
@@ -175,7 +175,7 @@ that is what lets a cache hit paint on the first frame.
|
|
|
175
175
|
| `client/markdown-sanitize.ts` | the allowlist sanitizer — the security boundary |
|
|
176
176
|
| `client/markdown-parse.ts` | markdown → block AST, plus the HTML emitter the **mock** uses |
|
|
177
177
|
| `client/markdown-fallback.tsx` | AST → React elements (the fallback path) |
|
|
178
|
-
| `
|
|
178
|
+
| `packages/portal-mojo/src/styles/components/markdown.css` | `.md` styling, tokens only, both themes |
|
|
179
179
|
|
|
180
180
|
## Fallback coverage
|
|
181
181
|
|
package/docs/menus-and-access.md
CHANGED
|
@@ -140,7 +140,7 @@ converging it onto flat sections is a named follow-up.
|
|
|
140
140
|
## Group context (A3)
|
|
141
141
|
|
|
142
142
|
Product portals place `<GroupProvider>` at the app root. `useActiveGroup() → {group, member,
|
|
143
|
-
loading, setActiveGroup, clearActiveGroup}`. The active group id rides the
|
|
143
|
+
loading, accessDenied, setActiveGroup, clearActiveGroup}`. The active group id rides the
|
|
144
144
|
REAL `?group=` search param (hash-safe, deep-linkable) with localStorage
|
|
145
145
|
fallback; activating a group fetches the caller's member record
|
|
146
146
|
(`/api/group/<id>/member`) whose permissions feed `useCan`.
|
|
@@ -148,3 +148,23 @@ fallback; activating a group fetches the caller's member record
|
|
|
148
148
|
embedded-parent rows, kind chips); `RequiresGroup` wraps pages that need a
|
|
149
149
|
group and shows the pick-a-group state otherwise. Do not install these in the
|
|
150
150
|
standalone Admin shell; they belong to group-scoped product navigation.
|
|
151
|
+
|
|
152
|
+
A cached group and membership remain resolved during network errors and server
|
|
153
|
+
5xx responses. The URL, stored selection and active-group signal stay in place.
|
|
154
|
+
An unresolved initial group still follows the URL/stored fallback chain.
|
|
155
|
+
Group 401/403/404 suppress the cached group immediately and run that fallback;
|
|
156
|
+
membership 401/403/404 keep the group identity but expose `member: null` and
|
|
157
|
+
`accessDenied: true`. These definitive denials do not retry.
|
|
158
|
+
Once denied, a later network failure or selecting an old switcher row cannot
|
|
159
|
+
restore cached grants. Recovery requires successful guarded group and member
|
|
160
|
+
reads; membership-only denial requires a successful member read.
|
|
161
|
+
|
|
162
|
+
A private workspace must treat `accessDenied === true` as a boundary: cancel
|
|
163
|
+
pending work, hide cached private results and remove temporary preview URLs.
|
|
164
|
+
Removing `member` only removes member-derived grants; user/system grants may
|
|
165
|
+
still satisfy `useCan`. This flag is not a loading state. Global Admin clears
|
|
166
|
+
it along with the group/member context. The server checks every request.
|
|
167
|
+
Provider requests check their candidate group, selection generation and authenticated actor both
|
|
168
|
+
before dispatch and after resolving, and consume TanStack's cancellation signal.
|
|
169
|
+
Provider teardown invalidates pending request closures even if another query
|
|
170
|
+
observer keeps their cache key alive.
|
|
@@ -107,7 +107,7 @@ element the user just clicked.
|
|
|
107
107
|
construction.
|
|
108
108
|
3. `null`/`undefined` are the only "nothing selected" — `0` and `''` are
|
|
109
109
|
selections.
|
|
110
|
-
4. Both themes, tokens only: `
|
|
110
|
+
4. Both themes, tokens only: `packages/portal-mojo/src/styles/components/multiselect-dropdown.css`
|
|
111
111
|
(reuses `.tbl-check`, `.btn`, `.field-*`; the popover shell supplies the
|
|
112
112
|
surface/border/shadow).
|
|
113
113
|
|
package/docs/popover.md
CHANGED
|
@@ -101,7 +101,7 @@ it (source parity).
|
|
|
101
101
|
|
|
102
102
|
## Styling
|
|
103
103
|
|
|
104
|
-
`.mojo-popover` lives in `
|
|
104
|
+
`.mojo-popover` lives in `packages/portal-mojo/src/styles/components/popover.css` — tokens
|
|
105
105
|
only, both themes. It neutralizes the UA `[popover]` stylesheet
|
|
106
106
|
(`inset: 0` centering, `margin: auto`, border, padding, Canvas colors)
|
|
107
107
|
and restores the mission-control look (`.filter-menu` family: `--surface`
|
package/docs/record-feed.md
CHANGED
|
@@ -221,8 +221,8 @@ surfaces local rejection through the same alert region.
|
|
|
221
221
|
|
|
222
222
|
## Styling
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
`src/styles/components/record-feed.css` ships in `portal-mojo/styles.css`;
|
|
225
|
+
the app declares the tokens. It uses only the mission-control
|
|
226
226
|
tokens and supports both `data-theme` values. Override the default bounded
|
|
227
227
|
height with `--record-feed-height` on a wrapper/component class.
|
|
228
228
|
|