portal-mojo 0.2.8 → 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 +6 -2
- package/docs/account.md +4 -3
- 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/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 +9 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +1 -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 +3 -2
- package/src/account/AccountModal.tsx +3 -2
- package/src/account/index.ts +2 -2
- package/src/admin/core/index.ts +1 -1
- 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/sections/actions.tsx +3 -3
- 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 +18 -4
- 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 +958 -14
- 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/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +3 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/toast.tsx +1 -1
|
@@ -4,63 +4,24 @@
|
|
|
4
4
|
//
|
|
5
5
|
// The wire (verified against django-mojo rest.py + consumer measurement,
|
|
6
6
|
// wmx-admin-v2 annex): an action handler's return dict reaches the client in
|
|
7
|
-
// one of two shapes —
|
|
7
|
+
// one of two shapes, always inside an HTTP 200 —
|
|
8
8
|
// flat {success: false, code, error, ...} (JsonResponse verbatim)
|
|
9
9
|
// wrapped {status: true, data: {success: false, ...}} (save-and-respond)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// action
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// unwrap rejects the FLAT shape for every call (#5922); a wrapped
|
|
11
|
+
// `success:false` can be ordinary data on a successful POST, so only this
|
|
12
|
+
// layer, which knows it posted an action, reads it as a refusal. One oddball
|
|
13
|
+
// spells the flag `status` instead of `success` inside the action payload
|
|
14
|
+
// (`revoke_sessions`); envelope-level `status: false` is a different thing
|
|
15
|
+
// entirely and already rejects in unwrap. One-shot secrets differ by
|
|
16
|
+
// direction: top-level on create, inside the action payload on rotate —
|
|
17
|
+
// `payload` merges both so callers read one place. The reader and the error
|
|
18
|
+
// live in errors.ts (cycle-free, so unwrap can use them) and are re-exported
|
|
19
|
+
// here unchanged.
|
|
16
20
|
import { mojoCall } from './client';
|
|
17
21
|
import { withFreshAuth } from './auth';
|
|
18
|
-
import {
|
|
19
|
-
import { getActiveGroupId } from './active-group';
|
|
20
|
-
import { endpointScopeFor, endpointScopeValue } from './endpoint-scope';
|
|
22
|
+
import { ActionRefusedError, readActionResult, type ActionResult } from './errors';
|
|
21
23
|
|
|
22
|
-
export
|
|
23
|
-
/** False when the handler refused inside the 200. */
|
|
24
|
-
ok: boolean;
|
|
25
|
-
/** Semantic refusal code (`WRONG_STATUS`, `MISSING_DETAILS`, …). */
|
|
26
|
-
code?: string;
|
|
27
|
-
/** Server-provided human error text. */
|
|
28
|
-
error?: string;
|
|
29
|
-
/** Envelope ⊕ action dict (action fields win) — one-shot secrets,
|
|
30
|
-
* counts etc. read from here regardless of wire shape. */
|
|
31
|
-
payload: Record<string, unknown>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function readActionResult(out: unknown): ActionResult {
|
|
35
|
-
const env = (typeof out === 'object' && out !== null ? out : {}) as Record<string, unknown>;
|
|
36
|
-
const data = (typeof env.data === 'object' && env.data !== null ? env.data : {}) as Record<string, unknown>;
|
|
37
|
-
const payload = { ...env, ...data };
|
|
38
|
-
const refused = env.success === false
|
|
39
|
-
|| data.success === false
|
|
40
|
-
|| data.status === false; // the `status`-spelling oddball (revoke_sessions)
|
|
41
|
-
const code = typeof payload.code === 'string' && payload.code ? payload.code : undefined;
|
|
42
|
-
const error = typeof payload.error === 'string' && payload.error ? payload.error : undefined;
|
|
43
|
-
return { ok: !refused, code, error, payload };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A refused action. `status` is 200 on purpose — the HTTP layer succeeded;
|
|
48
|
-
* the HANDLER said no. `errorCode` carries the refusal code, `data` the
|
|
49
|
-
* merged payload (may hold evidence like the current row status).
|
|
50
|
-
*/
|
|
51
|
-
export class ActionRefusedError extends MojoError {
|
|
52
|
-
result: ActionResult;
|
|
53
|
-
constructor(action: string, result: ActionResult) {
|
|
54
|
-
super(
|
|
55
|
-
result.error ?? (result.code ? `${action} refused (${result.code})` : `${action} was refused by the server`),
|
|
56
|
-
200,
|
|
57
|
-
result.code,
|
|
58
|
-
result.payload,
|
|
59
|
-
);
|
|
60
|
-
this.name = 'ActionRefusedError';
|
|
61
|
-
this.result = result;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
24
|
+
export { readActionResult, ActionRefusedError, type ActionResult } from './errors';
|
|
64
25
|
|
|
65
26
|
/**
|
|
66
27
|
* The raw-path action primitive: POST `{[action]: payload}` to
|
|
@@ -69,6 +30,11 @@ export class ActionRefusedError extends MojoError {
|
|
|
69
30
|
* send `true` (django-mojo dispatches on the key's presence; handlers
|
|
70
31
|
* treat a non-dict value as flag-only).
|
|
71
32
|
*
|
|
33
|
+
* Sends NO scope key, on any family (#5923): this is a REST record route, so
|
|
34
|
+
* the server binds the row's own group, and a `group` in the body would be
|
|
35
|
+
* saved as the row's `group` field. The call is declared `unscoped: true`, so
|
|
36
|
+
* a registered-required family does not trip the dev tripwire.
|
|
37
|
+
*
|
|
72
38
|
* For model-bound calls prefer `defineModel(...).useAction` — it rides the
|
|
73
39
|
* same normalizer and additionally maintains the record caches.
|
|
74
40
|
*/
|
|
@@ -78,16 +44,12 @@ export async function mojoAction(
|
|
|
78
44
|
action: string,
|
|
79
45
|
payload?: unknown,
|
|
80
46
|
): Promise<ActionResult> {
|
|
81
|
-
|
|
82
|
-
// injected into the action body — the wallet-verbs class: actions the
|
|
83
|
-
// backend refuses for brand-scoped operators without it.
|
|
84
|
-
const path = `${endpoint}/${id}`;
|
|
85
|
-
const reg = endpointScopeFor(path);
|
|
86
|
-
const gid = reg ? getActiveGroupId() : null;
|
|
87
|
-
const scope = reg && gid != null ? { [reg.key]: endpointScopeValue(reg, gid) } : {};
|
|
88
|
-
const body = await withFreshAuth(() => mojoCall(path, {
|
|
47
|
+
const body = await withFreshAuth(() => mojoCall(`${endpoint}/${id}`, {
|
|
89
48
|
method: 'POST',
|
|
90
|
-
body: {
|
|
49
|
+
body: { [action]: payload ?? true },
|
|
50
|
+
unscoped: true,
|
|
51
|
+
// Read the refusal here, so the error names the action, not the path.
|
|
52
|
+
refusal: 'return',
|
|
91
53
|
}));
|
|
92
54
|
const result = readActionResult(body);
|
|
93
55
|
if (!result.ok) throw new ActionRefusedError(action, result);
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// Active-group signal — GroupProvider OWNS the active group (context stays
|
|
2
2
|
// the React-side source of truth); this cell mirrors it so plain functions
|
|
3
|
-
// (mojoRpc, mojoScopedCall
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// (mojoRpc, mojoScopedCall) can resolve the current scope without threading
|
|
4
|
+
// an id through every call site. Same subscribable-module-state idiom as
|
|
5
|
+
// ui/active-persona.ts (#1609).
|
|
6
6
|
//
|
|
7
7
|
// Do NOT read localStorage's `active_group_id` here instead — the URL
|
|
8
8
|
// `?group=` param BEATS the stored id (group.tsx resolution order), and only
|
|
9
9
|
// the provider computes that. The signal is written from the provider's
|
|
10
10
|
// activation path, so it always reflects the resolved truth.
|
|
11
|
+
//
|
|
12
|
+
// The provider writes it in the LAYOUT phase (#5918), so anything that reads
|
|
13
|
+
// it from a passive effect — every TanStack Query fetch — sees the committed
|
|
14
|
+
// group. A child's own layout effect, or a fetch started during render
|
|
15
|
+
// (suspense queries), runs earlier and still sees the previous one: pass
|
|
16
|
+
// `group` explicitly there.
|
|
11
17
|
|
|
12
18
|
let activeGroupId: number | null = null;
|
|
13
19
|
const listeners = new Set<() => void>();
|
package/src/client/auth.ts
CHANGED
|
@@ -198,16 +198,23 @@ async function doRefresh(): Promise<boolean> {
|
|
|
198
198
|
return false;
|
|
199
199
|
}
|
|
200
200
|
const persistent = sessionIsPersistent();
|
|
201
|
+
// The answer is for the session whose refresh token was sent. When the
|
|
202
|
+
// stored one is no longer that token, the session was replaced while this
|
|
203
|
+
// was in flight: a save that changed the password (tokensReplaced), a new
|
|
204
|
+
// sign-in, a sign-out. A late pair is signed with a key the server has
|
|
205
|
+
// dropped, and a late 401 is about a token nobody holds any more; either
|
|
206
|
+
// one, acted on, would undo the session that replaced it.
|
|
207
|
+
const superseded = (): boolean => getRefreshToken() !== refresh;
|
|
208
|
+
const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
|
|
201
209
|
try {
|
|
202
210
|
const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
|
|
203
|
-
|
|
204
|
-
// adopted, or logout): never write the old session's pair over it.
|
|
205
|
-
if (getRefreshToken() !== refresh) return getRefreshToken() != null;
|
|
211
|
+
if (superseded()) return sessionUsable();
|
|
206
212
|
const pkg = body.data as { access_token: string; refresh_token?: string };
|
|
207
213
|
setTokens(pkg.access_token, pkg.refresh_token, persistent);
|
|
208
214
|
emitAuth('refreshed');
|
|
209
215
|
return true;
|
|
210
216
|
} catch (error) {
|
|
217
|
+
if (superseded()) return sessionUsable();
|
|
211
218
|
const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : 0;
|
|
212
219
|
if (status === 401 || status === 403) {
|
|
213
220
|
emitAuth('unauthorized');
|
|
@@ -847,7 +854,9 @@ export function handleAuthCodeFromURL(): Promise<AuthUser | null> {
|
|
|
847
854
|
function scrubUrl(params: URLSearchParams, hash: string): void {
|
|
848
855
|
const remaining = params.toString();
|
|
849
856
|
const clean = window.location.pathname + (remaining ? `?${remaining}` : '') + hash;
|
|
850
|
-
|
|
857
|
+
// Keep the entry's state: react-router's {usr, key, idx} lives there and
|
|
858
|
+
// useBlocker needs it on Back (#5925).
|
|
859
|
+
window.history.replaceState(window.history.state, '', clean);
|
|
851
860
|
}
|
|
852
861
|
|
|
853
862
|
// ── Hosted auth pages (the django-mojo /auth "bouncer" pages) ─────────
|
|
@@ -928,6 +937,11 @@ export function initAuth(): void {
|
|
|
928
937
|
const token = getAccessToken();
|
|
929
938
|
return token ? `Bearer ${token}` : null;
|
|
930
939
|
},
|
|
940
|
+
tokensReplaced(tokens) {
|
|
941
|
+
// Same storage the session already lives in (remember-me), read
|
|
942
|
+
// before setTokens clears both.
|
|
943
|
+
setTokens(tokens.access_token, tokens.refresh_token, sessionIsPersistent());
|
|
944
|
+
},
|
|
931
945
|
});
|
|
932
946
|
if (getRefreshToken()) startAutoRefresh();
|
|
933
947
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// - Envelope: {status, ...} — status:false carries error/message. Unwrapped
|
|
5
5
|
// HERE, at exactly one boundary. A failed save REJECTS (MojoError); it is
|
|
6
6
|
// never resolved as success. (web-mojo's Model.save() never-rejects trap
|
|
7
|
-
// is deliberately not carried forward.)
|
|
7
|
+
// is deliberately not carried forward.) A handler's flat refusal inside
|
|
8
|
+
// the 200 — top-level `success:false` — rejects too (ActionRefusedError,
|
|
9
|
+
// #5922) unless the call passes `refusal: 'return'`.
|
|
8
10
|
// - Lists: {status, data: T[], count, size, start} → MojoList<T>.
|
|
9
11
|
// - Singles: {status, data: T} → T.
|
|
10
12
|
// - Paging: start/size. Sort: 'field' | '-field'. Filters: Django lookups.
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
//
|
|
16
18
|
// With VITE_MOJO_API unset the transport is the in-memory mock; set it to a
|
|
17
19
|
// django-mojo origin and the same code talks to the real backend.
|
|
18
|
-
import { MojoError } from './errors';
|
|
20
|
+
import { ActionRefusedError, MojoError, readActionResult } from './errors';
|
|
19
21
|
import { assertScoped } from './endpoint-scope';
|
|
20
22
|
import { DUID_HEADER, getDuid } from './duid';
|
|
21
23
|
import type { MojoList, Params } from './types';
|
|
@@ -48,7 +50,8 @@ export function apiOrigin(): string {
|
|
|
48
50
|
* TanStack Query defaults tuned for the mojo protocol — spread these into the
|
|
49
51
|
* app's QueryClient:
|
|
50
52
|
* · 4xx MojoErrors never retry (a 404/401/403 is deterministic — retrying
|
|
51
|
-
* it only delays the error surfacing, e.g. the group-context fallback)
|
|
53
|
+
* it only delays the error surfacing, e.g. the group-context fallback);
|
|
54
|
+
* nor does an inside-the-200 refusal (the same request gets the same no)
|
|
52
55
|
* · networkMode 'always' under the mock (an in-memory backend can't be
|
|
53
56
|
* offline; online-pausing would wedge failures forever), 'online' for
|
|
54
57
|
* real backends.
|
|
@@ -59,6 +62,7 @@ export function mojoQueryDefaults() {
|
|
|
59
62
|
networkMode: (usingMockTransport() ? 'always' : 'online') as 'always' | 'online',
|
|
60
63
|
retry: (failureCount: number, error: unknown): boolean => {
|
|
61
64
|
if (error instanceof MojoError && error.status >= 400 && error.status < 500) return false;
|
|
65
|
+
if (error instanceof ActionRefusedError) return false;
|
|
62
66
|
return failureCount < 1;
|
|
63
67
|
},
|
|
64
68
|
// No refetch storm on window focus (web-mojo parity: freshness
|
|
@@ -91,6 +95,12 @@ export interface FetchOpts {
|
|
|
91
95
|
/** Explicit opt-out from a registered endpoint scope (scoped.ts) — for
|
|
92
96
|
* genuinely global calls inside a scoped family. Greppable, never silent. */
|
|
93
97
|
unscoped?: boolean;
|
|
98
|
+
/** A flat `success:false` inside the 200 is a handler's refusal: by
|
|
99
|
+
* default ('reject') unwrap rejects it with ActionRefusedError. 'return'
|
|
100
|
+
* resolves it as a result, for calls whose `success:false` is a real
|
|
101
|
+
* answer (a connection test) or that read the refusal themselves (the
|
|
102
|
+
* action layer). Greppable, never silent. */
|
|
103
|
+
refusal?: 'reject' | 'return';
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
export interface Envelope {
|
|
@@ -103,6 +113,18 @@ export interface Envelope {
|
|
|
103
113
|
count?: number;
|
|
104
114
|
size?: number;
|
|
105
115
|
start?: number;
|
|
116
|
+
/**
|
|
117
|
+
* A replacement session pair, beside `data`. django-mojo sends it when the
|
|
118
|
+
* request ended every other session of the caller's own account — today a
|
|
119
|
+
* save that changed the caller's own password (User.on_rest_save_and_respond).
|
|
120
|
+
* The tokens the request was sent with are already dead.
|
|
121
|
+
*/
|
|
122
|
+
tokens?: SessionTokens;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SessionTokens {
|
|
126
|
+
access_token: string;
|
|
127
|
+
refresh_token?: string;
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
/** Installed by auth (initAuth). The transport stays auth-agnostic. */
|
|
@@ -111,6 +133,9 @@ export interface AuthHooks {
|
|
|
111
133
|
preRequest(path: string): Promise<void>;
|
|
112
134
|
/** Authorization header value, or null when no session exists. */
|
|
113
135
|
authHeader(): string | null;
|
|
136
|
+
/** The server replaced this session's tokens (Envelope.tokens): store them.
|
|
137
|
+
* Optional: a harness that installs its own hooks holds no stored session. */
|
|
138
|
+
tokensReplaced?(tokens: SessionTokens): void;
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
let authHooks: AuthHooks | null = null;
|
|
@@ -178,6 +203,18 @@ async function unwrap(path: string, opts: FetchOpts): Promise<Envelope> {
|
|
|
178
203
|
body.data,
|
|
179
204
|
);
|
|
180
205
|
}
|
|
206
|
+
// Stored here, at the one boundary, so every caller is covered: a save
|
|
207
|
+
// that changed the caller's own password comes back with the only tokens
|
|
208
|
+
// that still work. Dropping them would sign the user out on the next call.
|
|
209
|
+
if (body.tokens && typeof body.tokens.access_token === 'string' && body.tokens.access_token) {
|
|
210
|
+
authHooks?.tokensReplaced?.(body.tokens);
|
|
211
|
+
}
|
|
212
|
+
// Flat only: a top-level success:false is always the handler's own reply
|
|
213
|
+
// (model actions answer flat). A wrapped data.success:false can be
|
|
214
|
+
// ordinary data on a successful POST, so it stays data here.
|
|
215
|
+
if ((body as { success?: unknown }).success === false && opts.refusal !== 'return') {
|
|
216
|
+
throw new ActionRefusedError(path, readActionResult(body));
|
|
217
|
+
}
|
|
181
218
|
return body;
|
|
182
219
|
}
|
|
183
220
|
|
|
@@ -200,8 +237,8 @@ export async function mojoList<T>(endpoint: string, params: Params = {}): Promis
|
|
|
200
237
|
};
|
|
201
238
|
}
|
|
202
239
|
|
|
203
|
-
export async function mojoGet<T>(endpoint: string, id: number | string): Promise<T> {
|
|
204
|
-
const body = await unwrap(`${endpoint}/${id}`, {});
|
|
240
|
+
export async function mojoGet<T>(endpoint: string, id: number | string, opts: { unscoped?: boolean } = {}): Promise<T> {
|
|
241
|
+
const body = await unwrap(`${endpoint}/${id}`, { unscoped: opts.unscoped });
|
|
205
242
|
return body.data as T;
|
|
206
243
|
}
|
|
207
244
|
|
|
@@ -226,10 +263,12 @@ export async function mojoMetrics(params: Params): Promise<MetricsResponse> {
|
|
|
226
263
|
};
|
|
227
264
|
}
|
|
228
265
|
|
|
229
|
-
/** Create (no id) or update (with id). Rejects on any failure.
|
|
230
|
-
|
|
266
|
+
/** Create (no id) or update (with id). Rejects on any failure. `unscoped`
|
|
267
|
+
* is the record-route opt-out from a registered scope (FetchOpts.unscoped);
|
|
268
|
+
* mojoGet and mojoDelete take the same option. */
|
|
269
|
+
export async function mojoSave<T>(endpoint: string, id: number | string | null, changes: Record<string, unknown>, opts: { unscoped?: boolean } = {}): Promise<T> {
|
|
231
270
|
const path = id == null ? endpoint : `${endpoint}/${id}`;
|
|
232
|
-
const body = await unwrap(path, { method: 'POST', body: changes });
|
|
271
|
+
const body = await unwrap(path, { method: 'POST', body: changes, unscoped: opts.unscoped });
|
|
233
272
|
return body.data as T;
|
|
234
273
|
}
|
|
235
274
|
|
|
@@ -238,8 +277,8 @@ export async function mojoSave<T>(endpoint: string, id: number | string | null,
|
|
|
238
277
|
* envelope's only non-boolean status; it passes the unwrap's `=== false`
|
|
239
278
|
* failure check by design). Failures reject like every other call.
|
|
240
279
|
*/
|
|
241
|
-
export async function mojoDelete(endpoint: string, id: number | string): Promise<void> {
|
|
242
|
-
await unwrap(`${endpoint}/${id}`, { method: 'DELETE' });
|
|
280
|
+
export async function mojoDelete(endpoint: string, id: number | string, opts: { unscoped?: boolean } = {}): Promise<void> {
|
|
281
|
+
await unwrap(`${endpoint}/${id}`, { method: 'DELETE', unscoped: opts.unscoped });
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
/** Trigger a browser download of an in-memory file. Shared by safe client exports. */
|
package/src/client/errors.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Error types for the mojo client
|
|
2
|
-
//
|
|
1
|
+
// Error types for the mojo client, and the one reader for inside-the-200
|
|
2
|
+
// refusals. Own module so client.ts, auth.ts and action-result.ts can all
|
|
3
|
+
// import them without a dependency cycle: client.ts's unwrap builds its
|
|
4
|
+
// refusal error from readActionResult (#5922).
|
|
3
5
|
|
|
4
6
|
export class MojoError extends Error {
|
|
5
7
|
status: number;
|
|
@@ -31,3 +33,56 @@ export class AuthRequiredError extends MojoError {
|
|
|
31
33
|
this.reason = 'unauthorized';
|
|
32
34
|
}
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
export interface ActionResult {
|
|
38
|
+
/** False when the handler refused inside the 200. */
|
|
39
|
+
ok: boolean;
|
|
40
|
+
/** Semantic refusal code (`WRONG_STATUS`, `MISSING_DETAILS`, …). */
|
|
41
|
+
code?: string;
|
|
42
|
+
/** Server-provided human error text. */
|
|
43
|
+
error?: string;
|
|
44
|
+
/** Envelope ⊕ action dict (action fields win) — one-shot secrets,
|
|
45
|
+
* counts etc. read from here regardless of wire shape. */
|
|
46
|
+
payload: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The one reader for django-mojo refusals inside an HTTP 200: flat
|
|
51
|
+
* `{success:false}`, wrapped `{data:{success:false}}`, and the payload
|
|
52
|
+
* `status:false` spelling (`revoke_sessions`). A refusal with no string
|
|
53
|
+
* `error` falls back to a string `message` — diagnostics and provider
|
|
54
|
+
* refusals put the human text there.
|
|
55
|
+
*/
|
|
56
|
+
export function readActionResult(out: unknown): ActionResult {
|
|
57
|
+
const env = (typeof out === 'object' && out !== null ? out : {}) as Record<string, unknown>;
|
|
58
|
+
const data = (typeof env.data === 'object' && env.data !== null ? env.data : {}) as Record<string, unknown>;
|
|
59
|
+
const payload = { ...env, ...data };
|
|
60
|
+
const refused = env.success === false
|
|
61
|
+
|| data.success === false
|
|
62
|
+
|| data.status === false; // the `status`-spelling oddball (revoke_sessions)
|
|
63
|
+
const code = typeof payload.code === 'string' && payload.code ? payload.code : undefined;
|
|
64
|
+
const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
65
|
+
const error = text(payload.error) ?? (refused ? text(payload.message) : undefined);
|
|
66
|
+
return { ok: !refused, code, error, payload };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A refused action or call. `status` is 200 on purpose — the HTTP layer
|
|
71
|
+
* succeeded; the HANDLER said no. `errorCode` carries the refusal code,
|
|
72
|
+
* `data` the merged payload (may hold evidence like the current row status).
|
|
73
|
+
* `action` labels the message: the action name from the action layer, the
|
|
74
|
+
* request path from unwrap.
|
|
75
|
+
*/
|
|
76
|
+
export class ActionRefusedError extends MojoError {
|
|
77
|
+
result: ActionResult;
|
|
78
|
+
constructor(action: string, result: ActionResult) {
|
|
79
|
+
super(
|
|
80
|
+
result.error ?? (result.code ? `${action} refused (${result.code})` : `${action} was refused by the server`),
|
|
81
|
+
200,
|
|
82
|
+
result.code,
|
|
83
|
+
result.payload,
|
|
84
|
+
);
|
|
85
|
+
this.name = 'ActionRefusedError';
|
|
86
|
+
this.result = result;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -20,6 +20,8 @@ export interface GroupContextValue {
|
|
|
20
20
|
member: MemberLike | null;
|
|
21
21
|
/** True while boot resolution or a switch is in flight. */
|
|
22
22
|
loading: boolean;
|
|
23
|
+
/** Definitive group/member read denial. Consumers must fence cached private workspaces. */
|
|
24
|
+
accessDenied?: boolean;
|
|
23
25
|
setActiveGroup(group: Group): void;
|
|
24
26
|
clearActiveGroup(): void;
|
|
25
27
|
}
|
package/src/client/group.tsx
CHANGED
|
@@ -8,15 +8,23 @@
|
|
|
8
8
|
// · on activation the user's member record for that group is fetched
|
|
9
9
|
// (`/api/group/<id>/member`) — group-context permissions flow from it
|
|
10
10
|
// · URL-group failure falls back to the stored group, then to none
|
|
11
|
-
import { useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
12
|
-
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
13
|
-
import { mojoCall,
|
|
11
|
+
import { useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react';
|
|
12
|
+
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
13
|
+
import { mojoCall, mojoQueryDefaults, MojoError } from './client';
|
|
14
|
+
import { getAuthSnapshot } from './auth';
|
|
14
15
|
import { useAuthSnapshot } from './me';
|
|
15
16
|
import type { MemberLike } from './me';
|
|
16
17
|
import { GroupContext, type Group, type GroupContextValue } from './group-context';
|
|
17
18
|
import { setActiveGroupSignal } from './active-group';
|
|
18
19
|
|
|
19
20
|
const STORAGE_KEY = 'active_group_id';
|
|
21
|
+
const denied = (error: unknown) => error instanceof MojoError && [401, 403, 404].includes(error.status);
|
|
22
|
+
const temporary = (error: unknown) => !(error instanceof MojoError) || error.status === 0 || error.status >= 500 || [408, 429].includes(error.status);
|
|
23
|
+
const retry = (count: number, error: unknown) => !(error instanceof Error && error.name === 'AbortError') && mojoQueryDefaults().queries.retry(count, error);
|
|
24
|
+
// Query keeps old data when a refetch fails, and its latest error can change
|
|
25
|
+
// from 403 to 503. A known denial outlives either that error or a switcher row.
|
|
26
|
+
// Share its lifetime with the cache, including provider remounts.
|
|
27
|
+
const denials = new WeakMap<QueryClient, Map<string, { group: boolean; member: boolean }>>();
|
|
20
28
|
|
|
21
29
|
function readUrlGroupParam(): string | null {
|
|
22
30
|
if (typeof window === 'undefined') return null;
|
|
@@ -28,7 +36,9 @@ function writeUrlGroupParam(id: number | null): void {
|
|
|
28
36
|
if (id == null) params.delete('group');
|
|
29
37
|
else params.set('group', String(id));
|
|
30
38
|
const qs = params.toString();
|
|
31
|
-
|
|
39
|
+
// Keep the entry's state: react-router's {usr, key, idx} lives there and
|
|
40
|
+
// useBlocker needs it on Back (#5925).
|
|
41
|
+
window.history.replaceState(window.history.state, '', window.location.pathname + (qs ? `?${qs}` : '') + (window.location.hash || ''));
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
function loadStoredGroupId(): number | null {
|
|
@@ -62,67 +72,149 @@ export function GroupProvider({ children }: { children: ReactNode }) {
|
|
|
62
72
|
if (Number.isFinite(urlId) && urlId > 0) return urlId;
|
|
63
73
|
return loadStoredGroupId();
|
|
64
74
|
});
|
|
75
|
+
const current = useRef({ groupId, uid: auth.uid, authenticated: auth.authenticated, generation: 0 });
|
|
76
|
+
const mounted = useRef(true);
|
|
77
|
+
if (current.current.groupId !== groupId || current.current.uid !== auth.uid || current.current.authenticated !== auth.authenticated)
|
|
78
|
+
current.current = { groupId, uid: auth.uid, authenticated: auth.authenticated, generation: current.current.generation + 1 };
|
|
79
|
+
const deniedGroups = denials.get(queryClient) ?? new Map<string, { group: boolean; member: boolean }>();
|
|
80
|
+
denials.set(queryClient, deniedGroups);
|
|
81
|
+
const [, rerender] = useReducer((value: number) => value + 1, 0);
|
|
82
|
+
const denialFor = (id: number, uid: typeof auth.uid) => {
|
|
83
|
+
const key = JSON.stringify([uid, id]);
|
|
84
|
+
let denial = deniedGroups.get(key);
|
|
85
|
+
if (!denial) deniedGroups.set(key, denial = { group: false, member: false });
|
|
86
|
+
return denial;
|
|
87
|
+
};
|
|
88
|
+
const select = (id: number | null) => {
|
|
89
|
+
current.current = { ...current.current, groupId: id, generation: current.current.generation + 1 };
|
|
90
|
+
setGroupId(id);
|
|
91
|
+
rerender();
|
|
92
|
+
};
|
|
93
|
+
const assert = (id: number, uid: typeof auth.uid, generation: number, signal: AbortSignal) => {
|
|
94
|
+
signal.throwIfAborted();
|
|
95
|
+
const actor = getAuthSnapshot();
|
|
96
|
+
if (!mounted.current || !actor.authenticated || actor.uid !== uid || current.current.uid !== uid || current.current.groupId !== id || current.current.generation !== generation)
|
|
97
|
+
throw new DOMException('Group or account changed', 'AbortError');
|
|
98
|
+
};
|
|
65
99
|
|
|
66
100
|
const groupQuery = useQuery({
|
|
67
101
|
queryKey: ['group', groupId, auth.uid],
|
|
68
|
-
queryFn: () =>
|
|
102
|
+
queryFn: async ({ signal }) => {
|
|
103
|
+
const id = groupId!, uid = auth.uid, generation = current.current.generation;
|
|
104
|
+
const check = () => assert(id, uid, generation, signal);
|
|
105
|
+
check();
|
|
106
|
+
try {
|
|
107
|
+
const result = await mojoCall(`/api/group/${id}`, { signal, beforeSend: check });
|
|
108
|
+
check();
|
|
109
|
+
denialFor(id, uid).group = false;
|
|
110
|
+
return result.data as Group;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
check();
|
|
113
|
+
if (denied(error)) Object.assign(denialFor(id, uid), { group: true, member: true });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
69
117
|
enabled: auth.authenticated && groupId != null,
|
|
70
118
|
staleTime: 5 * 60_000,
|
|
119
|
+
retry,
|
|
71
120
|
});
|
|
72
121
|
|
|
73
122
|
// web-mojo's fallback chain: a failing URL/stored group falls back to the
|
|
74
123
|
// stored one (when different), else the context clears entirely.
|
|
75
124
|
useEffect(() => {
|
|
76
|
-
if (!groupQuery.isError || groupId == null) return;
|
|
125
|
+
if (!groupQuery.isError || groupId == null || (groupQuery.data && temporary(groupQuery.error))) return;
|
|
77
126
|
const stored = loadStoredGroupId();
|
|
78
127
|
if (stored != null && stored !== groupId) {
|
|
79
128
|
console.warn(`[portal-mojo] active group ${groupId} failed to load; falling back to stored group ${stored}`);
|
|
80
129
|
writeUrlGroupParam(stored);
|
|
81
|
-
|
|
130
|
+
select(stored);
|
|
82
131
|
} else {
|
|
83
132
|
console.warn(`[portal-mojo] active group ${groupId} failed to load; clearing group context`);
|
|
84
133
|
clearStoredGroupId();
|
|
85
134
|
writeUrlGroupParam(null);
|
|
86
|
-
|
|
135
|
+
select(null);
|
|
87
136
|
}
|
|
88
|
-
}, [groupQuery.isError, groupId]);
|
|
137
|
+
}, [groupQuery.isError, groupQuery.data, groupQuery.error, groupId]);
|
|
89
138
|
|
|
90
|
-
const
|
|
139
|
+
const groupDenied = groupId != null && (denialFor(groupId, auth.uid).group || denied(groupQuery.error));
|
|
140
|
+
const group = auth.authenticated && groupId != null && !groupDenied ? (groupQuery.data ?? null) : null;
|
|
91
141
|
|
|
92
142
|
// Mirror the RESOLVED group into the module signal (scoped.ts's plain-fn
|
|
93
143
|
// consumers). The loaded id, not the candidate — a failing/loading group
|
|
94
144
|
// must not scope requests. Cleared on unmount.
|
|
95
|
-
|
|
145
|
+
//
|
|
146
|
+
// LAYOUT timing is load-bearing (#5918). A child's query starts its fetch
|
|
147
|
+
// from a passive effect, and React runs child passive effects before this
|
|
148
|
+
// provider's, so a passive write here left the first request after a
|
|
149
|
+
// switch carrying the previous group. A layout effect runs for committed
|
|
150
|
+
// trees only and before every passive effect.
|
|
151
|
+
useLayoutEffect(() => {
|
|
96
152
|
setActiveGroupSignal(group?.id ?? null);
|
|
97
153
|
}, [group?.id]);
|
|
98
|
-
|
|
154
|
+
useLayoutEffect(() => {
|
|
155
|
+
mounted.current = true;
|
|
156
|
+
return () => {
|
|
157
|
+
mounted.current = false;
|
|
158
|
+
current.current.generation++;
|
|
159
|
+
setActiveGroupSignal(null);
|
|
160
|
+
};
|
|
161
|
+
}, []);
|
|
99
162
|
|
|
100
163
|
const memberQuery = useQuery({
|
|
101
164
|
queryKey: ['group-member', group?.id, auth.uid],
|
|
102
|
-
queryFn: async (
|
|
165
|
+
queryFn: async ({ signal }) => {
|
|
166
|
+
const id = group!.id, uid = auth.uid, generation = current.current.generation;
|
|
167
|
+
const check = () => assert(id, uid, generation, signal);
|
|
168
|
+
check();
|
|
169
|
+
try {
|
|
170
|
+
const result = await mojoCall(`/api/group/${id}/member`, { signal, beforeSend: check });
|
|
171
|
+
check();
|
|
172
|
+
denialFor(id, uid).member = false;
|
|
173
|
+
return result.data as MemberLike;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
check();
|
|
176
|
+
if (denied(error)) denialFor(id, uid).member = true;
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
103
180
|
enabled: auth.authenticated && group != null,
|
|
104
181
|
staleTime: 5 * 60_000,
|
|
182
|
+
retry,
|
|
105
183
|
});
|
|
106
184
|
|
|
185
|
+
const memberDenied = groupId != null && (denialFor(groupId, auth.uid).member || denied(memberQuery.error));
|
|
107
186
|
const value = useMemo<GroupContextValue>(() => ({
|
|
108
187
|
group,
|
|
109
|
-
member: (group != null ? memberQuery.data : null) ?? null,
|
|
188
|
+
member: (group != null && !memberDenied ? memberQuery.data : null) ?? null,
|
|
189
|
+
accessDenied: groupDenied || memberDenied,
|
|
110
190
|
loading: auth.authenticated && groupId != null
|
|
111
191
|
&& (groupQuery.isLoading || (group != null && memberQuery.isLoading)),
|
|
112
192
|
setActiveGroup(next: Group) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
193
|
+
const groupKey = ['group', next.id, auth.uid];
|
|
194
|
+
const memberKey = ['group-member', next.id, auth.uid];
|
|
195
|
+
const denial = denialFor(next.id, auth.uid);
|
|
196
|
+
if (denial.group || denial.member || denied(queryClient.getQueryState(groupKey)?.error) || denied(queryClient.getQueryState(memberKey)?.error)) {
|
|
197
|
+
// Selecting an old row cannot re-authorize a denied context.
|
|
198
|
+
// Forget both cached reads; keep the latch until each actual
|
|
199
|
+
// guarded request succeeds.
|
|
200
|
+
void queryClient.cancelQueries({ queryKey: groupKey, exact: true });
|
|
201
|
+
void queryClient.cancelQueries({ queryKey: memberKey, exact: true });
|
|
202
|
+
queryClient.removeQueries({ queryKey: groupKey, exact: true });
|
|
203
|
+
queryClient.removeQueries({ queryKey: memberKey, exact: true });
|
|
204
|
+
} else {
|
|
205
|
+
// An authorized switcher row avoids an ordinary refetch flash.
|
|
206
|
+
queryClient.setQueryData(groupKey, next);
|
|
207
|
+
}
|
|
116
208
|
saveStoredGroupId(next.id);
|
|
117
209
|
writeUrlGroupParam(next.id);
|
|
118
|
-
|
|
210
|
+
select(next.id);
|
|
119
211
|
},
|
|
120
212
|
clearActiveGroup() {
|
|
121
213
|
clearStoredGroupId();
|
|
122
214
|
writeUrlGroupParam(null);
|
|
123
|
-
|
|
215
|
+
select(null);
|
|
124
216
|
},
|
|
125
|
-
}), [group, memberQuery.data, memberQuery.isLoading, groupQuery.isLoading, groupId, auth.authenticated, auth.uid, queryClient]);
|
|
217
|
+
}), [group, memberQuery.data, memberDenied, memberQuery.isLoading, groupDenied, groupQuery.isLoading, groupId, auth.authenticated, auth.uid, queryClient]);
|
|
126
218
|
|
|
127
219
|
return <GroupContext.Provider value={value}>{children}</GroupContext.Provider>;
|
|
128
220
|
}
|
package/src/client/hooks.ts
CHANGED
|
@@ -32,7 +32,8 @@ export function useModel<T>(endpoint: string, id: number | string | null, saniti
|
|
|
32
32
|
return useQuery({
|
|
33
33
|
queryKey: [endpoint, 'one', id],
|
|
34
34
|
queryFn: async () => {
|
|
35
|
-
|
|
35
|
+
// A REST record route: the server binds the row's own group (#5923).
|
|
36
|
+
const row = await mojoGet<T>(endpoint, id!, { unscoped: true });
|
|
36
37
|
return sanitizeRow ? sanitizeRow(row) : row;
|
|
37
38
|
},
|
|
38
39
|
enabled: id != null,
|
|
@@ -43,8 +44,11 @@ export function useModel<T>(endpoint: string, id: number | string | null, saniti
|
|
|
43
44
|
export function useSaveModel<T>(endpoint: string) {
|
|
44
45
|
const qc = useQueryClient();
|
|
45
46
|
return useMutation({
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// With an id this is a REST record route: the server binds the row's
|
|
48
|
+
// own group (#5923). A create still sends only the caller's changes.
|
|
49
|
+
mutationFn: ({ id, changes }: { id: number | string | null; changes: Record<string, unknown> }) => id == null
|
|
50
|
+
? mojoSave<T>(endpoint, id, changes)
|
|
51
|
+
: mojoSave<T>(endpoint, id, changes, { unscoped: true }),
|
|
48
52
|
onSuccess: () => qc.invalidateQueries({ queryKey: [endpoint] }),
|
|
49
53
|
});
|
|
50
54
|
}
|