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.
Files changed (149) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +6 -2
  4. package/docs/account.md +4 -3
  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/calendar.md +1 -1
  22. package/docs/client.md +159 -26
  23. package/docs/collection-multiselect.md +2 -2
  24. package/docs/combobox.md +2 -2
  25. package/docs/dataview.md +1 -1
  26. package/docs/datepicker.md +3 -3
  27. package/docs/daterange.md +1 -1
  28. package/docs/datetimepicker.md +1 -1
  29. package/docs/defineModel.md +40 -7
  30. package/docs/detail-primitives.md +1 -1
  31. package/docs/feedback.md +9 -8
  32. package/docs/file-upload-ux.md +2 -2
  33. package/docs/idioms.md +1 -1
  34. package/docs/loading.md +2 -2
  35. package/docs/location-address.md +2 -1
  36. package/docs/markdown.md +3 -3
  37. package/docs/menus-and-access.md +21 -1
  38. package/docs/multiselect-dropdown.md +1 -1
  39. package/docs/popover.md +1 -1
  40. package/docs/record-feed.md +2 -2
  41. package/docs/taginput.md +2 -1
  42. package/docs/timepicker.md +1 -1
  43. package/docs/timezone-select.md +4 -3
  44. package/docs/user-menu.md +4 -3
  45. package/docs/worldmap.md +2 -2
  46. package/package.json +3 -2
  47. package/src/account/AccountModal.tsx +3 -2
  48. package/src/account/index.ts +2 -2
  49. package/src/admin/core/index.ts +1 -1
  50. package/src/admin/dns/models.ts +1 -1
  51. package/src/admin/domains/infrastructure.ts +12 -2
  52. package/src/admin/domains/security.ts +3 -1
  53. package/src/admin/edge/BlocklistPage.tsx +110 -0
  54. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  55. package/src/admin/edge/VhostDetail.tsx +166 -0
  56. package/src/admin/edge/VhostWizard.tsx +273 -0
  57. package/src/admin/edge/VhostsPage.tsx +47 -0
  58. package/src/admin/edge/api.ts +56 -0
  59. package/src/admin/edge/index.ts +15 -0
  60. package/src/admin/edge/models.ts +395 -0
  61. package/src/admin/edge/permissions.ts +13 -0
  62. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  63. package/src/admin/identity/users/sections/actions.tsx +3 -3
  64. package/src/admin/index.ts +1 -0
  65. package/src/admin/phonehub/api.ts +5 -3
  66. package/src/admin/public/infrastructure.ts +1 -0
  67. package/src/admin/registry.ts +2 -1
  68. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  69. package/src/admin/storage/file-renditions.ts +12 -0
  70. package/src/admin/storage/index.ts +2 -0
  71. package/src/admin/storage/rendition-settings.ts +264 -0
  72. package/src/charts/exportChart.ts +1 -1
  73. package/src/client/action-result.ts +23 -61
  74. package/src/client/active-group.ts +9 -3
  75. package/src/client/auth.ts +18 -4
  76. package/src/client/client.ts +49 -10
  77. package/src/client/errors.ts +57 -2
  78. package/src/client/group-context.ts +2 -0
  79. package/src/client/group.tsx +112 -20
  80. package/src/client/hooks.ts +7 -3
  81. package/src/client/mock.ts +958 -14
  82. package/src/client/model.ts +15 -3
  83. package/src/client/renditions-mock.ts +212 -0
  84. package/src/client/scoped.ts +19 -5
  85. package/src/styles/components/account.css +304 -0
  86. package/src/styles/components/admin-assistant.css +28 -0
  87. package/src/styles/components/admin-bouncer.css +53 -0
  88. package/src/styles/components/admin-cloudwatch.css +23 -0
  89. package/src/styles/components/admin-dashboard.css +16 -0
  90. package/src/styles/components/admin-devices.css +260 -0
  91. package/src/styles/components/admin-dns-certificates.css +28 -0
  92. package/src/styles/components/admin-dns-registrar.css +1 -0
  93. package/src/styles/components/admin-dns.css +43 -0
  94. package/src/styles/components/admin-edge.css +1 -0
  95. package/src/styles/components/admin-incidents.css +14 -0
  96. package/src/styles/components/admin-jobs.css +237 -0
  97. package/src/styles/components/admin-media-rendering.css +38 -0
  98. package/src/styles/components/admin-messaging.css +3 -0
  99. package/src/styles/components/admin-monitoring.css +152 -0
  100. package/src/styles/components/admin-network.css +406 -0
  101. package/src/styles/components/admin-phonehub.css +61 -0
  102. package/src/styles/components/admin-push.css +30 -0
  103. package/src/styles/components/admin-rules.css +23 -0
  104. package/src/styles/components/admin-security.css +37 -0
  105. package/src/styles/components/admin-shortlinks.css +8 -0
  106. package/src/styles/components/admin-signin.css +64 -0
  107. package/src/styles/components/admin-storage.css +4 -0
  108. package/src/styles/components/auth.css +162 -0
  109. package/src/styles/components/calendar.css +228 -0
  110. package/src/styles/components/charts-c2.css +249 -0
  111. package/src/styles/components/collection-multiselect.css +114 -0
  112. package/src/styles/components/collection-select.css +63 -0
  113. package/src/styles/components/combobox.css +42 -0
  114. package/src/styles/components/dataview.css +139 -0
  115. package/src/styles/components/datepicker.css +106 -0
  116. package/src/styles/components/daterange.css +119 -0
  117. package/src/styles/components/datetimepicker.css +230 -0
  118. package/src/styles/components/detail-primitives.css +246 -0
  119. package/src/styles/components/detailview.css +69 -0
  120. package/src/styles/components/drawer.css +111 -0
  121. package/src/styles/components/file-upload.css +112 -0
  122. package/src/styles/components/form-wizard.css +11 -0
  123. package/src/styles/components/formview.css +74 -0
  124. package/src/styles/components/group-admin.css +302 -0
  125. package/src/styles/components/guardrail.css +29 -0
  126. package/src/styles/components/idioms.css +84 -0
  127. package/src/styles/components/image-editor.css +80 -0
  128. package/src/styles/components/loading.css +200 -0
  129. package/src/styles/components/location-address.css +32 -0
  130. package/src/styles/components/markdown.css +192 -0
  131. package/src/styles/components/multiselect-dropdown.css +92 -0
  132. package/src/styles/components/popover.css +35 -0
  133. package/src/styles/components/record-feed.css +161 -0
  134. package/src/styles/components/right-panel.css +37 -0
  135. package/src/styles/components/tabs.css +26 -0
  136. package/src/styles/components/taginput.css +132 -0
  137. package/src/styles/components/timepicker.css +282 -0
  138. package/src/styles/components/user-admin.css +189 -0
  139. package/src/styles/components/user-menu.css +98 -0
  140. package/src/styles/components/worldmap.css +97 -0
  141. package/src/styles/core.css +807 -0
  142. package/src/styles/index.css +69 -0
  143. package/src/styles/required-tokens.json +32 -0
  144. package/src/ui/FormWizard.tsx +9 -3
  145. package/src/ui/ThemeProvider.tsx +1 -1
  146. package/src/ui/detail/index.ts +2 -2
  147. package/src/ui/index.ts +3 -2
  148. package/src/ui/loading.tsx +1 -1
  149. 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
- // — always inside an HTTP 200, so the transport's unwrap does NOT reject.
11
- // One oddball spells the flag `status` instead of `success` inside the
12
- // action payload (`revoke_sessions`); envelope-level `status: false` is a
13
- // different thing entirely and already rejects in unwrap. One-shot secrets
14
- // differ by direction: top-level on create, inside the action payload on
15
- // rotate — `payload` below merges both so callers read one place.
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 { MojoError } from './errors';
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 interface ActionResult {
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
- // Scope-registered families (endpoint-scope.ts, #1936) get the group
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: { ...scope, [action]: payload ?? true },
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, mojoAction's scope injection) can resolve the
4
- // current scope without threading an id through every call site. Same
5
- // subscribable-module-state idiom as ui/active-persona.ts (#1609).
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>();
@@ -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
- // The session changed while this POST was in flight (a new login was
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
- window.history.replaceState({}, '', clean);
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
  }
@@ -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
- export async function mojoSave<T>(endpoint: string, id: number | string | null, changes: Record<string, unknown>): Promise<T> {
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. */
@@ -1,5 +1,7 @@
1
- // Error types for the mojo client. Own module so client.ts and auth.ts can
2
- // both import them without a dependency cycle.
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
  }
@@ -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, mojoGet } from './client';
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
- window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + (window.location.hash || ''));
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: () => mojoGet<Group>('/api/group', groupId!),
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
- setGroupId(stored);
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
- setGroupId(null);
135
+ select(null);
87
136
  }
88
- }, [groupQuery.isError, groupId]);
137
+ }, [groupQuery.isError, groupQuery.data, groupQuery.error, groupId]);
89
138
 
90
- const group = groupId != null ? (groupQuery.data ?? null) : null;
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
- useEffect(() => {
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
- useEffect(() => () => setActiveGroupSignal(null), []);
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 () => (await mojoCall(`/api/group/${group!.id}/member`)).data as MemberLike,
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
- // Seed the cache with the row the switcher already holds — no
114
- // refetch flash on switch.
115
- queryClient.setQueryData(['group', next.id, auth.uid], next);
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
- setGroupId(next.id);
210
+ select(next.id);
119
211
  },
120
212
  clearActiveGroup() {
121
213
  clearStoredGroupId();
122
214
  writeUrlGroupParam(null);
123
- setGroupId(null);
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
  }
@@ -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
- const row = await mojoGet<T>(endpoint, id!);
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
- mutationFn: ({ id, changes }: { id: number | string | null; changes: Record<string, unknown> }) =>
47
- mojoSave<T>(endpoint, id, changes),
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
  }