portal-mojo 0.2.10 → 0.2.12

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.
@@ -1,7 +1,7 @@
1
1
  # ModelTable — the server-driven table
2
2
 
3
3
  ```ts
4
- import { ModelTable, ExpandingSearch, type Column, type Preset, type BatchAction, type RowId } from 'portal-mojo/ui';
4
+ import { ModelTable, ExpandingSearch, type Column, type Preset, type BatchAction, type RowId, type ModelTableQuery, type ModelTableQueryAdapterProps } from 'portal-mojo/ui';
5
5
  ```
6
6
 
7
7
  ## Row identity
@@ -14,7 +14,7 @@ integer-id table is unaffected; nothing narrows an id back to `number`.
14
14
 
15
15
  Every sort, filter, search and page is a **wire param the server answers**
16
16
  — there is no client-side row work (rule #1). Table state lives in the
17
- URL-synced params store. All features below are opt-in props; a bare
17
+ params store, synced to the URL by default. All features below are opt-in props; a bare
18
18
  `<ModelTable model={M} title columns />` renders the base table.
19
19
 
20
20
  ```tsx
@@ -37,6 +37,36 @@ URL-synced params store. All features below are opt-in props; a bare
37
37
  />
38
38
  ```
39
39
 
40
+ ## Local params for dialogs and multiple tables
41
+
42
+ `paramsMode?: 'url' | 'local'` defaults to `'url'`. Use `'local'` for a table
43
+ inside a dialog or beside another table on the same route:
44
+
45
+ ```tsx
46
+ <ModelTable model={ScheduleModel} title="Schedule" columns={columns}
47
+ paramsMode="local" queryAdapter={ScopedRows}
48
+ fixedParams={{ kiosk: kioskId }} defaultParams={{ size: 5 }} />
49
+ ```
50
+
51
+ A local instance starts on page 1 using its own defaults. It does not inherit
52
+ the page's search, sort, filters or page size, and its controls never change
53
+ the URL or another table's state. It uses the same params store, normalization,
54
+ fixed-scope rules, controls and query adapter contract. No nested router is
55
+ needed; it remains inside the application's existing router. Choose the mode
56
+ at mount; unmount or change the table's React `key` to start fresh.
57
+
58
+ `persistState` remains **false by default** in both modes. If explicitly enabled
59
+ for a local table, saved state overrides its defaults without URL precedence
60
+ or URL writes. Its default storage key is `local::${endpoint}`; supply a distinct
61
+ `persistKey` for each independent saved view. Fixed params stay excluded from
62
+ saved state. A scope change resets that instance to page 1 and clears selection;
63
+ use a new React `key` when the entire local view should reset too. Local state
64
+ does not itself isolate private rows: the query adapter still owns actor/scope
65
+ checks and stale-data clearing.
66
+
67
+ Components → **Private table query** includes two independent local tables
68
+ beside the URL-backed table. Hide and reopen them to test fresh mounting.
69
+
40
70
  ## Columns
41
71
 
42
72
  `Column<T> = { key, label, sortable?, align?: 'start'|'center'|'end',
@@ -89,6 +119,54 @@ filter.
89
119
  - **Presets must not carry fixed keys** — their writes are scrubbed from
90
120
  filter state, so such a preset never matches as active.
91
121
 
122
+ ## Controlled query adapter
123
+
124
+ `queryAdapter?: ComponentType<ModelTableQueryAdapterProps<T>>` replaces the
125
+ built-in list query **entirely**. The default `useModelList` is not mounted:
126
+ there is no generic query observer, fetch, or cache entry. Toolbar, filters,
127
+ params state, persistence, paging, selection and row rendering stay shared.
128
+
129
+ ```tsx
130
+ // Define once, OUTSIDE the page's render; inline component identities remount.
131
+ function ScopedRows({ params, enabled, children }: ModelTableQueryAdapterProps<Row>) {
132
+ const query = usePrivateRows(params, enabled); // your scoped request hook
133
+ return children(query);
134
+ }
135
+ <ModelTable model={RowModel} columns={columns} title="Records"
136
+ queryAdapter={ScopedRows} fixedParams={{ group: groupId }} />
137
+ ```
138
+
139
+ The adapter receives:
140
+
141
+ - `params: Params`: the selected URL or local params, with model normalization applied and
142
+ `fixedParams` merged **last**. Do not create a second table-state store.
143
+ - `enabled: boolean`: false until persisted state restores, or while
144
+ `fixedParams === null`. The adapter must honor this gate.
145
+ - `children(query)`: render it with `{data?: {rows: T[], count: number},
146
+ status: 'pending' | 'success' | 'error', isFetching: boolean,
147
+ isPlaceholderData?: boolean, error?: unknown, refetch: () => unknown}`.
148
+
149
+ The adapter owns sanitization, actor/scope identity, cancellation, final-send
150
+ checks, cache choice and stale-response rejection. Media capability rows can
151
+ stay in component state. Clear `data` synchronously when scope is invalid or
152
+ changes; never return the previous actor's rows as current data. Keeping
153
+ `data` on an ordinary background error preserves its authorized rows and
154
+ shows a Retry alert. Clearing it removes those rows immediately. Pending
155
+ scope hides rows and disables Refresh even if a misbehaving adapter retains data.
156
+
157
+ Refresh and completed batches call the adapter's `refetch`. `autoRefresh`
158
+ remains opt-in; no timer is introduced by controlled mode. Controlled exports
159
+ require an explicit `exporter`; the generic download path is never used.
160
+ Avoid persisting sensitive URL filter values; `persistState` is an explicit
161
+ host choice. Live demo: Components → **Private table query**.
162
+
163
+ ## Row activation
164
+
165
+ With `onRowClick`, the whole row is focusable and responds to click, Enter and
166
+ Space. Native nested links, buttons, inputs and other interactive controls
167
+ keep their own action; their click/key events do not also activate the row.
168
+ The table retains native row/cell semantics.
169
+
92
170
  ## Filters (see also forms.md dialog types)
93
171
 
94
172
  `FilterDef` types: `text` (`__icontains`), `select`, `multiselect`
@@ -189,7 +267,10 @@ bounded, immediately sanitized, allowlisted client exports.
189
267
  Uncached loads (cold, page/sort/filter changes — `isPlaceholderData`)
190
268
  render the column-matched skeleton silhouette; cached pages render
191
269
  instantly; background refetches never blank the table. Errors render the
192
- server's message with a Retry. Empty-with-filters offers Clear filters.
270
+ server's message with a Retry and replace the rows for default callers. Only
271
+ a controlled adapter may retain authorized current data on a background error,
272
+ showing an alert alongside those rows; clearing its data shows the full error
273
+ state. Empty-with-filters offers Clear filters.
193
274
 
194
275
  ## Search
195
276
 
package/docs/account.md CHANGED
@@ -163,6 +163,16 @@ wrong domain, cancelled; server text as written).
163
163
  `is_dob_verified`, `requires_password_change`, secrets, `has_password`,
164
164
  `has_passkey`, ….
165
165
 
166
+ Profile actions retain their initiating account through auth refresh and fresh-auth
167
+ retry. A caller change rejects before the final send and before publishing a
168
+ response; returning to the original account does not revive an old action.
169
+ This covers personal autosave, photo attachment, email/phone request and
170
+ confirmation, verification, and linked-sign-in unlink. Email confirmation
171
+ also checks before adopting its replacement session, including any wait for an
172
+ in-flight refresh. `MeSaveModel` accepts a null id for the current caller;
173
+ a supplied id must match that caller. Profile dialogs retain the opening
174
+ account across their request/confirmation steps.
175
+
166
176
  So never post the `me` row back as-is — `MeSaveModel` posts only the changed
167
177
  fields. `has_password` is in the `me` graph; passwordless users set a first
168
178
  password through `/api/auth/forgot`.
package/docs/params.md CHANGED
@@ -8,11 +8,11 @@ import {
8
8
  ```
9
9
 
10
10
  **THE architecture rule:** one flat params object owns search, sort,
11
- filters and paging for a table. It is URL-synced (real search string, hash
12
- router safe), drives the server query, the pills, preset matching, and
11
+ filters and paging for a table. It is URL-synced by default (real search string,
12
+ hash router safe), drives the server query, the pills, preset matching, and
13
13
  persistence. Components read it; nothing else holds table state.
14
14
 
15
- ## `useTableParams(defaults?) → TableParamsApi`
15
+ ## `useTableParams(defaults?, defaultFilters?, excludeKeys?, mode?) → TableParamsApi`
16
16
 
17
17
  State: `search`, `sort` (`'field' | '-field' | ''`, default `'-created'`
18
18
  unless `defaults.sort`), `page` (1-based), `size`.
@@ -31,8 +31,22 @@ unless `defaults.sort`), `page` (1-based), `size`.
31
31
  | `setPage(n)` / `setSize(n)` | paging (`PAGE_SIZES = [5,10,25,50,100]`) |
32
32
  | `applySaved(saved)` | one-shot rehydrate from a persistence blob (below) |
33
33
 
34
- Every write goes through the URL (`replace`), so table state deep-links and
35
- survives reloads by construction.
34
+ With the default `mode: 'url'`, every write goes through the URL (`replace`), so table state deep-links and
35
+ survives reloads by construction. A size equal to the caller's effective
36
+ `defaults.size` (10 when omitted) can be omitted from the URL; every other size
37
+ is explicit. Thus choosing or restoring 10 with a caller default of 25 writes
38
+ `size=10`, and the next render continues to show 10 rows.
39
+
40
+ `mode: 'local'` selects an empty per-instance `URLSearchParams` held in React
41
+ state. All state/filter/wire transformations above remain the same. Local
42
+ instances start on page 1 with the supplied defaults, ignore route params,
43
+ and never write the URL. Multiple instances on the same route are independent;
44
+ changing the outer URL cannot change them. Choose the mode at mount and remount
45
+ (or change the owner component's React `key`) to reset. The hook still runs
46
+ inside the application's existing router. `TableParamsMode` is exported from
47
+ both `portal-mojo/client` and `portal-mojo/client/runtime`.
48
+ Unknown runtime mode values fall back to `'url'` with one console warning per
49
+ mounted instance; valid and omitted modes do not warn.
36
50
 
37
51
  ## `excludeKeys` — the fixedParams scrub
38
52
 
@@ -74,6 +88,10 @@ Do not register a name that is also a model field.
74
88
 
75
89
  Precedence on restore: **URL > saved > defaults** per key — except `size`,
76
90
  a per-user viewing preference the URL merely echoes, where **saved wins**.
91
+ Local instances start with no explicit params, so saved values override
92
+ defaults without reading or changing the route. Persistence is still opt-in
93
+ through ModelTable's `persistState` (default false); use distinct `persistKey`
94
+ values for independent saved views. Fixed keys are excluded in either mode.
77
95
  Corrupt or other-version blobs are discarded AND cleared. `sort: ''`
78
96
  (deliberately un-sorted) is not persisted — the default sort returns.
79
97
 
@@ -81,5 +99,5 @@ Corrupt or other-version blobs are discarded AND cleared. `sort: ''`
81
99
 
82
100
  - Values are strings on the wire — booleans as `'true'/'false'`.
83
101
  - Never mutate `wire`; it's derived.
84
- - Don't store table state anywhere else (component state, context, redux
102
+ - Don't create a second table-state store (component state, context, redux
85
103
  patterns) — that's the exact drift this store exists to prevent.
package/docs/realtime.md CHANGED
@@ -17,7 +17,8 @@ disable this shared provider.
17
17
  - An open socket is not ready. The client waits for `auth_required`, then sends
18
18
  `{type:'authenticate',token,prefix:'bearer'}` and becomes ready only after
19
19
  `auth_success`. An auth error closes and suspends the connection until the
20
- token value changes.
20
+ token value changes. The exact direct admission-capacity error described
21
+ below is a transport refusal, not an authentication failure.
21
22
  - Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
22
23
  A server-initiated `{type:'ping'}` (django-mojo 1.32+ sends one every 20
23
24
  seconds and culls a socket silent for 90) is answered at once with
@@ -25,7 +26,11 @@ disable this shared provider.
25
26
  dispatched to consumers. Answering from the message handler keeps the socket
26
27
  alive while a background tab throttles the client's own ping timer.
27
28
  Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
28
- least 60 seconds, and focus/visibility cannot bypass that deadline.
29
+ least 60 seconds. The direct `{type:'error',message:'Too many connections'}`
30
+ admission refusal while authenticating or ready uses the same delay, even
31
+ when followed by a normal server close. Focus/visibility, token replacement,
32
+ and provider remount cannot bypass the deadline on that client. Queued frames
33
+ from the refused socket are ignored; recovery still requires authentication.
29
34
  - Desired topics are refcounted and replay after authentication. Operations are
30
35
  serialized because acknowledgements carry only the topic and denial is a
31
36
  generic uncorrelated `error`. Denial yields topic status `denied`; it does not
@@ -42,6 +47,12 @@ disable this shared provider.
42
47
 
43
48
  A top-level `{type:'error'}` frame is attributed in order:
44
49
 
50
+ 0. **Exact admission capacity refusal** — `message === 'Too many connections'`
51
+ while authenticating or ready: close and enter bounded `backoff`, without
52
+ dispatching an application error or declaring account access revoked.
53
+ Substrings, the `error` field, and wrapped application events do not match.
54
+ Consumers retain their independent HTTP authorization checks.
55
+
45
56
  1. **During authentication** — auth failure: the client closes and suspends
46
57
  until the token value changes. Surfaces through the lifecycle status only;
47
58
  no `error` event is dispatched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -3,11 +3,11 @@
3
3
  // Each takes the signed-in `me` row and renders on its own — an app can
4
4
  // mount any of them outside the modal (e.g. a settings page).
5
5
  import { useQueryClient } from '@tanstack/react-query';
6
- import type { ReactNode } from 'react';
6
+ import { useState, type ReactNode } from 'react';
7
7
  import { forgotPassword, getDuid, isPasskeyRegistrationSupported, revokeOtherSessions, type Me } from '../client/runtime';
8
8
  import { Badge, FormView, fmt, modal, toast, useAvatarUrl } from '../ui';
9
9
  import type { Field } from '../client/runtime';
10
- import { disableTotp, type NotificationKind } from './api';
10
+ import { disableTotp, unlinkOwnOAuthConnection, type NotificationKind } from './api';
11
11
  import {
12
12
  LoginEventModel, UserDeviceModel, browserLabel, deviceIcon,
13
13
  type LoginEventRow, type UserDeviceRow,
@@ -17,6 +17,7 @@ import {
17
17
  openEmailChangeDialog, openPhoneChangeDialog, openRecoveryCodesView, openRegenerateRecoveryCodes,
18
18
  openTotpEnrolDialog, openVerifyDialog,
19
19
  } from './dialogs';
20
+ import { captureAccountActor } from './actor';
20
21
  import { MeSaveModel, OAuthConnectionModel, accountKeys, invalidateMe, useRecoveryCodeStatus } from './models';
21
22
  import { NotificationPreferences } from './NotificationPreferences';
22
23
  import { PasskeyList } from './PasskeyList';
@@ -138,24 +139,33 @@ const provider = (name: string) => PROVIDERS[name.toLowerCase()] ?? { label: nam
138
139
  /** The linked Google/Apple/… sign-ins, one row each, with Unlink. */
139
140
  export function LinkedSignIns({ userId }: { userId: number }) {
140
141
  const { data } = OAuthConnectionModel.useList({ user: userId, size: 10, sort: '-created' });
141
- const del = OAuthConnectionModel.useDelete();
142
+ const qc = useQueryClient();
143
+ const [pending, setPending] = useState(false);
142
144
  const rows = data?.rows ?? [];
143
145
  if (rows.length === 0) return null;
144
146
 
145
147
  const unlink = async (id: number, label: string) => {
146
- const ok = await modal.confirm({
147
- title: `Unlink ${label}?`,
148
- message: <>You won&rsquo;t be able to sign in with <b>{label}</b> until you sign in with it again from the sign-in page. Your {label} account itself is untouched.</>,
149
- confirmText: 'Unlink',
150
- danger: true,
151
- });
152
- if (!ok) return;
148
+ const actor = captureAccountActor(userId);
153
149
  try {
154
- await del.mutateAsync({ id });
150
+ const ok = await modal.confirm({
151
+ title: `Unlink ${label}?`,
152
+ message: <>You won&rsquo;t be able to sign in with <b>{label}</b> until you sign in with it again from the sign-in page. Your {label} account itself is untouched.</>,
153
+ confirmText: 'Unlink',
154
+ danger: true,
155
+ });
156
+ if (!ok) return;
157
+ actor.check();
158
+ setPending(true);
159
+ await unlinkOwnOAuthConnection(id, actor.check);
160
+ actor.check();
161
+ void qc.invalidateQueries({ queryKey: [OAuthConnectionModel.endpoint] });
155
162
  toast.success(`${label} unlinked`);
156
163
  } catch (err) {
157
164
  // e.g. 400 "Cannot unlink your only login method. Set a password first." — as written.
158
165
  toast.error(accountErrorMessage(err));
166
+ } finally {
167
+ actor.dispose();
168
+ setPending(false);
159
169
  }
160
170
  };
161
171
 
@@ -170,7 +180,7 @@ export function LinkedSignIns({ userId }: { userId: number }) {
170
180
  <span>Signed in with {p.label}{row.email && <> · <span className="dim">{row.email}</span></>}</span>
171
181
  </div>
172
182
  <div className="acct-row-actions">
173
- <button type="button" className="btn btn-compact" disabled={del.isPending} onClick={() => void unlink(row.id, p.label)}>Unlink</button>
183
+ <button type="button" className="btn btn-compact" disabled={pending} onClick={() => void unlink(row.id, p.label)}>Unlink</button>
174
184
  </div>
175
185
  </div>
176
186
  );
@@ -0,0 +1,40 @@
1
+ // A pending profile action belongs to the account that started it, including
2
+ // time spent waiting for auth refresh or a fresh-auth dialog. Same-account
3
+ // token rotation remains valid; leaving that account invalidates the action
4
+ // permanently, even if the operator switches back before the response arrives.
5
+ import { getAuthSnapshot, subscribeAuth } from '../client/auth';
6
+
7
+ export interface AccountActor {
8
+ uid: string | null;
9
+ check: () => void;
10
+ dispose: () => void;
11
+ }
12
+
13
+ export function captureAccountActor(expectedUid: string | number | null = getAuthSnapshot().uid): AccountActor {
14
+ const uid = expectedUid == null ? null : String(expectedUid);
15
+ const matches = () => {
16
+ const current = getAuthSnapshot();
17
+ return uid !== null && current.authenticated && current.uid === uid;
18
+ };
19
+ let invalid = !matches();
20
+ const off = subscribeAuth(() => { if (!matches()) invalid = true; });
21
+ return {
22
+ uid,
23
+ check: () => {
24
+ if (invalid || !matches()) throw new Error('Your account changed. Reopen your profile to make this change.');
25
+ },
26
+ dispose: () => { invalid = true; off(); },
27
+ };
28
+ }
29
+
30
+ export async function withAccountActor<T>(run: (actor: AccountActor) => Promise<T>, expectedUid?: string | number | null): Promise<T> {
31
+ const actor = captureAccountActor(expectedUid);
32
+ try {
33
+ actor.check();
34
+ const value = await run(actor);
35
+ actor.check();
36
+ return value;
37
+ } finally {
38
+ actor.dispose();
39
+ }
40
+ }
@@ -12,11 +12,11 @@
12
12
  // Wire authority: django-mojo account/rest/totp.py, rest/user.py,
13
13
  // rest/verify.py, rest/notification_prefs.py (read 2026-10-07).
14
14
  import { MojoError } from '../client/errors';
15
- import { mojoCall } from '../client/client';
16
- import { withFreshAuth } from '../client/auth';
15
+ import { mojoCall, type FetchOpts } from '../client/client';
16
+ import { withFreshAuth, confirmEmailChange as confirmEmailChangeAuth } from '../client/auth';
17
+ import { withAccountActor } from './actor';
17
18
 
18
19
  export {
19
- confirmEmailChange,
20
20
  isPasskeyRegistrationSupported,
21
21
  passkeyErrorMessage,
22
22
  registerPasskey,
@@ -132,15 +132,42 @@ export async function changePassword(currentPassword: string, newPassword: strin
132
132
 
133
133
  // ── Email / phone changes and verification ────────────────────────────
134
134
 
135
+ // Profile dialogs may pass their opening actor's check, covering time between
136
+ // the request and confirmation steps as well as each request's auth wait.
137
+ async function profileCall(path: string, options: FetchOpts, check?: () => void, fresh = false) {
138
+ return withAccountActor(async (actor) => {
139
+ const beforeSend = () => { actor.check(); check?.(); };
140
+ beforeSend();
141
+ const send = () => mojoCall(path, { ...options, beforeSend });
142
+ const body = await (fresh ? withFreshAuth(send) : send());
143
+ beforeSend();
144
+ return body;
145
+ });
146
+ }
147
+
148
+ /** Caller-owned provider unlink. The opening account guard remains required. */
149
+ export async function unlinkOwnOAuthConnection(id: number, check: () => void): Promise<void> {
150
+ await profileCall(`/api/account/oauth_connection/${id}`, { method: 'DELETE', unscoped: true }, check);
151
+ }
152
+
153
+ /** Confirm and adopt an email change only while its initiating account is current. */
154
+ export async function confirmEmailChange(code: string, check?: () => void) {
155
+ return withAccountActor(async (actor) => {
156
+ const beforeSend = () => { actor.check(); check?.(); };
157
+ beforeSend();
158
+ return confirmEmailChangeAuth(code, beforeSend);
159
+ });
160
+ }
161
+
135
162
  /**
136
163
  * `POST /api/auth/email/change/request {email, method:'code'}` (fresh auth).
137
164
  * A 6-digit code goes to the NEW address; finish with confirmEmailChange().
138
165
  */
139
- export async function requestEmailChange(email: string): Promise<string> {
140
- const body = await withFreshAuth(() => mojoCall('/api/auth/email/change/request', {
166
+ export async function requestEmailChange(email: string, check?: () => void): Promise<string> {
167
+ const body = await profileCall('/api/auth/email/change/request', {
141
168
  method: 'POST',
142
169
  body: { email, method: 'code' },
143
- }));
170
+ }, check, true);
144
171
  return message(body, 'A verification code has been sent to your new email address.');
145
172
  }
146
173
 
@@ -154,37 +181,37 @@ export interface PhoneChangeTicket {
154
181
  * `POST /api/auth/phone/change/request {phone_number}` (fresh auth). The
155
182
  * session_token rides at the envelope's TOP level, not under `data`.
156
183
  */
157
- export async function requestPhoneChange(phoneNumber: string): Promise<PhoneChangeTicket> {
158
- const body = await withFreshAuth(() => mojoCall('/api/auth/phone/change/request', {
184
+ export async function requestPhoneChange(phoneNumber: string, check?: () => void): Promise<PhoneChangeTicket> {
185
+ const body = await profileCall('/api/auth/phone/change/request', {
159
186
  method: 'POST',
160
187
  body: { phone_number: phoneNumber },
161
- }));
188
+ }, check, true);
162
189
  const token = (body as { session_token?: unknown }).session_token;
163
190
  if (typeof token !== 'string' || !token) throw new MojoError('The server did not return a phone change session', 0);
164
191
  return { sessionToken: token, message: message(body, 'A verification code has been sent to your new phone number.') };
165
192
  }
166
193
 
167
194
  /** `POST /api/auth/phone/change/confirm {session_token, code}` — commits + verifies the number. */
168
- export async function confirmPhoneChange(sessionToken: string, code: string): Promise<string> {
169
- const body = await mojoCall('/api/auth/phone/change/confirm', {
195
+ export async function confirmPhoneChange(sessionToken: string, code: string, check?: () => void): Promise<string> {
196
+ const body = await profileCall('/api/auth/phone/change/confirm', {
170
197
  method: 'POST',
171
198
  body: { session_token: sessionToken, code },
172
- });
199
+ }, check);
173
200
  return message(body, 'Phone number updated successfully.');
174
201
  }
175
202
 
176
203
  /** `POST /api/auth/verify/{email|phone}/send` — a code (email asks for `method:'code'`). */
177
- export async function sendVerificationCode(channel: 'email' | 'phone'): Promise<string> {
178
- const body = await mojoCall(`/api/auth/verify/${channel}/send`, {
204
+ export async function sendVerificationCode(channel: 'email' | 'phone', check?: () => void): Promise<string> {
205
+ const body = await profileCall(`/api/auth/verify/${channel}/send`, {
179
206
  method: 'POST',
180
207
  body: channel === 'email' ? { method: 'code' } : {},
181
- });
208
+ }, check);
182
209
  return message(body, 'Verification code sent');
183
210
  }
184
211
 
185
212
  /** `POST /api/auth/verify/{email|phone}/confirm {code}`. */
186
- export async function confirmVerificationCode(channel: 'email' | 'phone', code: string): Promise<string> {
187
- const body = await mojoCall(`/api/auth/verify/${channel}/confirm`, { method: 'POST', body: { code } });
213
+ export async function confirmVerificationCode(channel: 'email' | 'phone', code: string, check?: () => void): Promise<string> {
214
+ const body = await profileCall(`/api/auth/verify/${channel}/confirm`, { method: 'POST', body: { code } }, check);
188
215
  return message(body, channel === 'email' ? 'Email verified' : 'Phone verified');
189
216
  }
190
217
 
@@ -30,6 +30,7 @@ import {
30
30
  requestEmailChange, requestPhoneChange, revokeOtherSessions, safeQrDataUrl, sendVerificationCode, startTotpSetup,
31
31
  suggestPasskeyName,
32
32
  } from './api';
33
+ import { captureAccountActor, type AccountActor } from './actor';
33
34
  import { MeSaveModel, PasskeyModel, accountKeys, invalidateMe } from './models';
34
35
 
35
36
  export const FRESH_AUTH_COPY = 'For your security, sign in again to make this change.';
@@ -517,11 +518,12 @@ function ChangePasswordDialog({ lock, close }: { lock: DismissLock; close: (valu
517
518
 
518
519
  export function openEmailChangeDialog(): Promise<boolean> {
519
520
  const lock = newLock();
520
- return modal.open<boolean | null>((close) => <EmailChangeDialog lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
521
- .then((v) => v === true);
521
+ const actor = captureAccountActor();
522
+ return modal.open<boolean | null>((close) => <EmailChangeDialog actor={actor} lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
523
+ .then((v) => v === true).finally(actor.dispose);
522
524
  }
523
525
 
524
- function EmailChangeDialog({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
526
+ function EmailChangeDialog({ actor, lock, close }: { actor: AccountActor; lock: DismissLock; close: (value: boolean | null) => void }) {
525
527
  const qc = useQueryClient();
526
528
  const me = useMe().data;
527
529
  const [email, setEmail] = useState('');
@@ -540,7 +542,7 @@ function EmailChangeDialog({ lock, close }: { lock: DismissLock; close: (value:
540
542
  setBusy(true);
541
543
  setError('');
542
544
  try {
543
- setNotice(await requestEmailChange(target));
545
+ setNotice(await requestEmailChange(target, actor.check));
544
546
  setSentTo(target);
545
547
  } catch (err) {
546
548
  setError(accountErrorMessage(err));
@@ -554,7 +556,7 @@ function EmailChangeDialog({ lock, close }: { lock: DismissLock; close: (value:
554
556
  setBusy(true);
555
557
  setError('');
556
558
  try {
557
- await confirmEmailChange(code);
559
+ await confirmEmailChange(code, actor.check);
558
560
  await invalidateMe(qc);
559
561
  toast.success('Email changed');
560
562
  close(true);
@@ -607,11 +609,12 @@ function EmailChangeDialog({ lock, close }: { lock: DismissLock; close: (value:
607
609
 
608
610
  export function openPhoneChangeDialog(): Promise<boolean> {
609
611
  const lock = newLock();
610
- return modal.open<boolean | null>((close) => <PhoneChangeDialog lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
611
- .then((v) => v === true);
612
+ const actor = captureAccountActor();
613
+ return modal.open<boolean | null>((close) => <PhoneChangeDialog actor={actor} lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
614
+ .then((v) => v === true).finally(actor.dispose);
612
615
  }
613
616
 
614
- function PhoneChangeDialog({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
617
+ function PhoneChangeDialog({ actor, lock, close }: { actor: AccountActor; lock: DismissLock; close: (value: boolean | null) => void }) {
615
618
  const qc = useQueryClient();
616
619
  const me = useMe().data;
617
620
  const current = typeof me?.phone_number === 'string' && me.phone_number ? me.phone_number : null;
@@ -632,7 +635,7 @@ function PhoneChangeDialog({ lock, close }: { lock: DismissLock; close: (value:
632
635
  setBusy(true);
633
636
  setError('');
634
637
  try {
635
- const result = await requestPhoneChange(target);
638
+ const result = await requestPhoneChange(target, actor.check);
636
639
  setTicket({ token: result.sessionToken, to: target, message: result.message });
637
640
  setCode('');
638
641
  } catch (err) {
@@ -648,7 +651,7 @@ function PhoneChangeDialog({ lock, close }: { lock: DismissLock; close: (value:
648
651
  setBusy(true);
649
652
  setError('');
650
653
  try {
651
- await confirmPhoneChange(ticket.token, code);
654
+ await confirmPhoneChange(ticket.token, code, actor.check);
652
655
  await invalidateMe(qc);
653
656
  toast.success(current ? 'Phone number changed' : 'Phone number added');
654
657
  close(true);
@@ -703,11 +706,12 @@ function PhoneChangeDialog({ lock, close }: { lock: DismissLock; close: (value:
703
706
 
704
707
  export function openVerifyDialog(channel: 'email' | 'phone'): Promise<boolean> {
705
708
  const lock = newLock();
706
- return modal.open<boolean | null>((close) => <VerifyDialog channel={channel} lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
707
- .then((v) => v === true);
709
+ const actor = captureAccountActor();
710
+ return modal.open<boolean | null>((close) => <VerifyDialog channel={channel} actor={actor} lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
711
+ .then((v) => v === true).finally(actor.dispose);
708
712
  }
709
713
 
710
- function VerifyDialog({ channel, lock, close }: { channel: 'email' | 'phone'; lock: DismissLock; close: (value: boolean | null) => void }) {
714
+ function VerifyDialog({ actor, channel, lock, close }: { actor: AccountActor; channel: 'email' | 'phone'; lock: DismissLock; close: (value: boolean | null) => void }) {
711
715
  const qc = useQueryClient();
712
716
  const me = useMe().data;
713
717
  const target = String((channel === 'email' ? me?.email : me?.phone_number) ?? '');
@@ -722,7 +726,7 @@ function VerifyDialog({ channel, lock, close }: { channel: 'email' | 'phone'; lo
722
726
  setBusy(true);
723
727
  setError('');
724
728
  try {
725
- setSent(await sendVerificationCode(channel));
729
+ setSent(await sendVerificationCode(channel, actor.check));
726
730
  } catch (err) {
727
731
  setError(accountErrorMessage(err));
728
732
  } finally {
@@ -735,7 +739,7 @@ function VerifyDialog({ channel, lock, close }: { channel: 'email' | 'phone'; lo
735
739
  setBusy(true);
736
740
  setError('');
737
741
  try {
738
- const message = await confirmVerificationCode(channel, code);
742
+ const message = await confirmVerificationCode(channel, code, actor.check);
739
743
  await invalidateMe(qc);
740
744
  toast.success(message);
741
745
  close(true);
@@ -770,11 +774,12 @@ function VerifyDialog({ channel, lock, close }: { channel: 'email' | 'phone'; lo
770
774
 
771
775
  export function openAvatarDialog(): Promise<unknown> {
772
776
  const lock = newLock();
773
- return modal.open((close) => <AvatarDialog lock={lock} close={() => close(null)} />, { size: 'md', canDismiss: () => !lock.locked });
777
+ const actor = captureAccountActor();
778
+ return modal.open((close) => <AvatarDialog actor={actor} lock={lock} close={() => close(null)} />, { size: 'md', canDismiss: () => !lock.locked }).finally(actor.dispose);
774
779
  }
775
780
 
776
781
  /** Personal-scope upload → `POST /api/user/me {avatar: <File id> | null}` (MeSaveModel). */
777
- function AvatarDialog({ lock, close }: { lock: DismissLock; close: () => void }) {
782
+ function AvatarDialog({ actor, lock, close }: { actor: AccountActor; lock: DismissLock; close: () => void }) {
778
783
  const me = useMe().data as Me | undefined;
779
784
  const save = MeSaveModel.useSave();
780
785
  const [busy, setBusy] = useState(false);
@@ -787,7 +792,9 @@ function AvatarDialog({ lock, close }: { lock: DismissLock; close: () => void })
787
792
  const attach = async (avatar: number | null) => {
788
793
  setBusy(true);
789
794
  try {
790
- const saved = await save.mutateAsync({ id: me?.id ?? null, changes: { avatar } });
795
+ actor.check();
796
+ const saved = await save.mutateAsync({ id: actor.uid, changes: { avatar } });
797
+ actor.check();
791
798
  setOwnerResult({ generation: ++generation.current, status: 'success', requestedValue: avatar, authoritativeValue: saved.avatar ?? null });
792
799
  if ((saved.avatar?.id ?? null) !== avatar) throw new Error('The server did not confirm the new photo');
793
800
  toast.success(avatar == null ? 'Photo removed' : 'Photo updated');
@@ -8,9 +8,10 @@
8
8
  // through — and the account-only query keys.
9
9
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
10
10
  import {
11
- mojoSave, sanitizeMe, useAuthSnapshot, withFreshAuth,
11
+ mojoCall, sanitizeMe, useAuthSnapshot, withFreshAuth,
12
12
  type Me, type SaveVars,
13
13
  } from '../client/runtime';
14
+ import { withAccountActor } from './actor';
14
15
  import { getNotificationPreferences, getRecoveryCodeStatus, type NotificationPreferencesResponse, type RecoveryCodeStatus } from './api';
15
16
 
16
17
  // ── /api/user/me ──────────────────────────────────────────────────────
@@ -21,18 +22,24 @@ import { getNotificationPreferences, getRecoveryCodeStatus, type NotificationPre
21
22
  * 403s email/username/is_superuser/permissions even unchanged), sanitizes
22
23
  * the answer with sanitizeMe, writes `['me', uid]` and refreshes every
23
24
  * `/api/user` cache (UserModel.useSave never touches `me`, and vice versa).
24
- * The `id` in SaveVars is ignored: the endpoint is always the caller.
25
+ * The endpoint is always the caller. A supplied id must match that caller;
26
+ * actor checks fence the final send, fresh-auth retry, and cache publication.
25
27
  */
26
28
  export const MeSaveModel = {
27
29
  useSave: () => {
28
30
  const qc = useQueryClient();
29
31
  const { uid } = useAuthSnapshot();
30
32
  return useMutation<Me, Error, SaveVars>({
31
- mutationFn: async ({ changes }: SaveVars) => sanitizeMe(await withFreshAuth(() => mojoSave<Me>('/api/user/me', null, changes))),
32
- onSuccess: (row) => {
33
- qc.setQueryData(['me', uid], row);
33
+ mutationFn: ({ id, changes }: SaveVars) => withAccountActor(async (actor) => {
34
+ const body = await withFreshAuth(() => mojoCall('/api/user/me', {
35
+ method: 'POST', body: changes, beforeSend: actor.check,
36
+ }));
37
+ actor.check();
38
+ const row = sanitizeMe(body.data as Me);
39
+ qc.setQueryData(['me', actor.uid], row);
34
40
  void qc.invalidateQueries({ queryKey: ['/api/user'] });
35
- },
41
+ return row;
42
+ }, id ?? uid),
36
43
  });
37
44
  },
38
45
  };
@@ -330,12 +330,13 @@ export const GRANT_SIGN_IN_AGAIN = 'Sign in again to continue';
330
330
  * access + refresh pair. Serialised behind any in-flight refresh so a
331
331
  * refresh that started on the old session can never land on top of it.
332
332
  */
333
- async function adoptGrant(data: TokenGrant | null | undefined, remember = true, event: 'login' | 'rotated' = 'login'): Promise<AuthUser> {
333
+ async function adoptGrant(data: TokenGrant | null | undefined, remember = true, event: 'login' | 'rotated' = 'login', check?: () => void): Promise<AuthUser> {
334
334
  if (!data || typeof data.access_token !== 'string' || !data.access_token
335
335
  || typeof data.refresh_token !== 'string' || !data.refresh_token) {
336
336
  throw new Error(GRANT_SIGN_IN_AGAIN);
337
337
  }
338
338
  if (refreshPromise) await refreshPromise;
339
+ check?.();
339
340
  setTokens(data.access_token, data.refresh_token, remember);
340
341
  startAutoRefresh();
341
342
  const user = data.user ?? {};
@@ -686,9 +687,11 @@ export function passkeyErrorMessage(error: unknown): string {
686
687
  * adopted here into the storage this session already uses. Emits 'rotated'
687
688
  * (never 'login': no explicit sign-in happened).
688
689
  */
689
- export async function confirmEmailChange(code: string): Promise<AuthUser> {
690
- const body = await mojoCall('/api/auth/email/change/confirm', { method: 'POST', body: { code } });
691
- return adoptGrant(body.data as TokenGrant, sessionIsPersistent(), 'rotated');
690
+ export async function confirmEmailChange(code: string, check?: () => void): Promise<AuthUser> {
691
+ const body = await mojoCall('/api/auth/email/change/confirm', { method: 'POST', body: { code }, beforeSend: check });
692
+ // Account dialogs fence both dispatch and adoption of the returned login.
693
+ check?.();
694
+ return adoptGrant(body.data as TokenGrant, sessionIsPersistent(), 'rotated', check);
692
695
  }
693
696
 
694
697
  /**
@@ -7,7 +7,7 @@
7
7
  // · multi-value filters collapse to `field__in=a,b` (single value → `field`)
8
8
  // · a daterange is the TRIPLE `dr_field` / `dr_start` / `dr_end`, so only
9
9
  // one range can be active at a time — by construction, not by convention.
10
- import { useCallback, useMemo, useRef } from 'react';
10
+ import { useCallback, useMemo, useRef, useState } from 'react';
11
11
  import { useSearchParams } from 'react-router-dom';
12
12
  import type { Params } from './types';
13
13
 
@@ -27,6 +27,8 @@ export interface TableParams {
27
27
  size: number;
28
28
  }
29
29
 
30
+ export type TableParamsMode = 'url' | 'local';
31
+
30
32
  export interface TableParamsApi extends TableParams {
31
33
  wire: Params;
32
34
  activeFilters: ActiveFilter[];
@@ -139,8 +141,19 @@ export function useTableParams(
139
141
  // resurface as a removable pill. Pass a render-stable Set — it is a
140
142
  // memo dependency.
141
143
  excludeKeys?: ReadonlySet<string>,
144
+ // Select once per mount. Local tables use this same store/transforms,
145
+ // with an empty per-instance source instead of the route's query string.
146
+ mode: TableParamsMode = 'url',
142
147
  ): TableParamsApi {
143
- const [sp, setSp] = useSearchParams();
148
+ const warnedMode = useRef(false);
149
+ const resolvedMode = mode === 'local' ? 'local' : 'url';
150
+ if (mode !== 'url' && mode !== 'local' && !warnedMode.current) {
151
+ warnedMode.current = true;
152
+ console.warn(`[portal-mojo] useTableParams: unknown mode "${String(mode)}" — falling back to 'url'`);
153
+ }
154
+ const [urlParams, setUrlParams] = useSearchParams();
155
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams());
156
+ const sp = resolvedMode === 'local' ? localParams : urlParams;
144
157
  // Defaults seed only the untouched route. Once this table writes params,
145
158
  // an intentionally empty filter set must stay empty ("Clear all" must
146
159
  // not immediately resurrect a route default).
@@ -175,7 +188,9 @@ export function useTableParams(
175
188
  if (next.search) out.set('search', next.search);
176
189
  if (next.sort) out.set('sort', next.sort);
177
190
  if (next.page > 1) out.set('page', String(next.page));
178
- if (next.size !== DEFAULT_SIZE) out.set('size', String(next.size));
191
+ // Omit only the effective default. Omitting the global default (10)
192
+ // would resurrect a caller's different default on the next render.
193
+ if (next.size !== (defaults.size ?? DEFAULT_SIZE)) out.set('size', String(next.size));
179
194
  for (const [key, value] of Object.entries(nextFilters)) {
180
195
  if (value !== '') out.set(key, value);
181
196
  }
@@ -185,8 +200,9 @@ export function useTableParams(
185
200
  const carried = sp.get(key);
186
201
  if (carried != null) out.set(key, carried);
187
202
  }
188
- setSp(out, { replace: true });
189
- }, [setSp, sp]);
203
+ if (resolvedMode === 'local') setLocalParams(out);
204
+ else setUrlParams(out, { replace: true });
205
+ }, [resolvedMode, setUrlParams, sp, defaults.size]);
190
206
 
191
207
  const wire = useMemo<Params>(() => ({
192
208
  start: (state.page - 1) * state.size,
@@ -241,6 +241,12 @@ export class RealtimeClient {
241
241
  private open(): void {
242
242
  if (!this.enabled || !this.token || this.suspended || this.socket) return;
243
243
  const run = this.generation;
244
+ // Token refresh and provider remount use this same entry point; neither
245
+ // may bypass a server admission deadline.
246
+ if (this.clock.now() < this.extendedBackoffUntil) {
247
+ this.scheduleReconnect(run, 0);
248
+ return;
249
+ }
244
250
  this.setLifecycle('connecting');
245
251
  let socket: RealtimeSocket;
246
252
  try {
@@ -312,6 +318,21 @@ export class RealtimeClient {
312
318
  if (typeof frame.topic === 'string') this.completeTopic(frame.type, frame.topic);
313
319
  return;
314
320
  }
321
+ if (frame.type === 'error' && frame.message === 'Too many connections'
322
+ && (this.lifecycle === 'authenticating' || this.lifecycle === 'ready')) {
323
+ // django-mojo's admission refusal also arrives after the challenge
324
+ // (or a ready-state capacity check), followed by a normal close.
325
+ // Fence queued frames before closing: late auth_success must not
326
+ // clear the deadline or make this refused socket ready.
327
+ this.extendedBackoffUntil = this.clock.now() + 60_000;
328
+ this.socket = null;
329
+ this.clearHeartbeat();
330
+ this.resetTopicConnectionState();
331
+ this.automaticUserTopic = null;
332
+ this.scheduleReconnect(run, 4429);
333
+ socket.close(4429, 'Connection limit');
334
+ return;
335
+ }
315
336
  if (frame.type === 'error') {
316
337
  if (this.lifecycle === 'authenticating') {
317
338
  this.suspend('auth-failed', 4003, 'Authentication failed');
@@ -17,9 +17,9 @@
17
17
  // · groupBy — synthetic header rows interleaved on key change; falsy key
18
18
  // = ungrouped tail (see ui/grouping.ts helpers)
19
19
  // · exportFormats — server-side export via download_format (csv/json)
20
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
20
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type ComponentType, type Dispatch, type SetStateAction } from 'react';
21
21
  import { useQueryClient } from '@tanstack/react-query';
22
- import { PAGE_SIZES, useTableParams, readTableState, writeTableState, clearTableState, type PersistedTableState } from '../client/params';
22
+ import { PAGE_SIZES, useTableParams, readTableState, writeTableState, clearTableState, type PersistedTableState, type TableParamsMode } from '../client/params';
23
23
  import { useModelList } from '../client/hooks';
24
24
  import { mojoDownload } from '../client/client';
25
25
  import type { ModelDef } from '../client/model';
@@ -100,6 +100,12 @@ function defaultCell(value: unknown, key: string): string {
100
100
  return String(value);
101
101
  }
102
102
 
103
+ /** Nested controls retain their own action; plain cells activate the row. */
104
+ function isRowControl(target: EventTarget, row: HTMLTableRowElement): boolean {
105
+ return target instanceof Element && target !== row
106
+ && target.closest('button, a, input, select, textarea, summary, [role="button"], [role="link"], [contenteditable="true"]') !== null;
107
+ }
108
+
103
109
  /** Page-number window: {1, total, current±1} with … for the gaps. */
104
110
  function pageWindow(current: number, total: number): (number | '…')[] {
105
111
  const keep = new Set<number>([1, total]);
@@ -221,16 +227,30 @@ function MenuDropdown({ button, children, align = 'end' }: {
221
227
  */
222
228
  export type RowId = number | string;
223
229
 
224
- export function ModelTable<T extends { id: RowId }>({
225
- model, endpoint, columns, filters = [], presets = [], eyebrow, title,
226
- searchable = true, searchPlaceholder = 'Search…', onRowClick, addLabel, onAdd, defaultSort, defaultParams, fixedParams,
227
- selectable = false, batchActions = [],
228
- columnChooser = false, persistState = false, persistKey,
229
- autoRefresh = 0,
230
- rowExpand, rowExpandMultiple = false,
231
- groupBy, groupHeaderLabel, groupHeaderStyle = 'banner',
232
- exportFormats = [], exporter, rowTone,
233
- }: {
230
+ /** Controlled query result. An omitted data value clears previously rendered rows. */
231
+ export interface ModelTableQuery<T> {
232
+ data?: { rows: T[]; count: number };
233
+ status: 'pending' | 'success' | 'error';
234
+ isFetching: boolean;
235
+ isPlaceholderData?: boolean;
236
+ error?: unknown;
237
+ refetch: () => unknown;
238
+ }
239
+
240
+ /** A stable component that owns fetching/caching and renders the shared table. */
241
+ export interface ModelTableQueryAdapterProps<T> {
242
+ /** Model-normalized table state, with fixedParams applied last. */
243
+ params: Params;
244
+ /** False until persistence is hydrated or while fixedParams is null. */
245
+ enabled: boolean;
246
+ children: (query: ModelTableQuery<T>) => ReactNode;
247
+ }
248
+
249
+ export interface ModelTableProps<T extends { id: RowId }> {
250
+ /** Replaces the default query entirely. Define the component outside render. */
251
+ queryAdapter?: ComponentType<ModelTableQueryAdapterProps<T>>;
252
+ /** Default 'url'. 'local' starts fresh per mount, without reading/writing route params. */
253
+ paramsMode?: TableParamsMode;
234
254
  /** A defineModel definition — supplies the endpoint (and, later, schema). */
235
255
  model?: ModelDef<T>;
236
256
  /** Bare endpoint, for tables without a model definition. */
@@ -246,10 +266,10 @@ export function ModelTable<T extends { id: RowId }>({
246
266
  onRowClick?: (row: T) => void;
247
267
  addLabel?: string;
248
268
  onAdd?: () => void;
249
- /** Initial sort when the URL doesn't carry one (default '-created'). */
269
+ /** Initial sort when the params store doesn't carry one (default '-created'). */
250
270
  defaultSort?: string;
251
271
  /**
252
- * Initial URL-backed query state. URL keys win; persisted state wins over
272
+ * Initial query state. URL keys win in URL mode; persisted state wins over
253
273
  * these defaults. `defaultSort` remains as the backwards-compatible
254
274
  * shorthand and wins over `defaultParams.sort` when both are supplied.
255
275
  */
@@ -269,7 +289,7 @@ export function ModelTable<T extends { id: RowId }>({
269
289
  batchActions?: BatchAction<T>[];
270
290
  columnChooser?: boolean;
271
291
  persistState?: boolean;
272
- /** Storage identity; default derives from route + endpoint. */
292
+ /** Storage identity; defaults to route + endpoint, or local::endpoint in local mode. */
273
293
  persistKey?: string;
274
294
  /** Interval refetch in SECONDS (5s floor). 0/absent = off. */
275
295
  autoRefresh?: number;
@@ -286,15 +306,12 @@ export function ModelTable<T extends { id: RowId }>({
286
306
  exporter?: (format: 'csv' | 'json', normalizedParams: Params) => Promise<void>;
287
307
  /** Semantic queue emphasis without coupling callers to table CSS. */
288
308
  rowTone?: (row: T) => RowTone | null;
289
- }) {
309
+ }
310
+
311
+ export function ModelTable<T extends { id: RowId }>(props: ModelTableProps<T>) {
312
+ const { model, endpoint, columns, defaultSort, defaultParams, fixedParams, searchable = true, persistState = false, persistKey, queryAdapter: Adapter, paramsMode = 'url' } = props;
290
313
  const resolvedEndpoint = model?.endpoint ?? endpoint;
291
314
  if (!resolvedEndpoint) throw new Error('ModelTable: pass `model` or `endpoint`');
292
- if (!GROUP_HEADER_STYLES.includes(groupHeaderStyle)) {
293
- console.warn(`ModelTable: unknown groupHeaderStyle "${groupHeaderStyle}" — falling back to 'banner'`);
294
- groupHeaderStyle = 'banner';
295
- }
296
-
297
- const qc = useQueryClient();
298
315
  const paramDefaults = useMemo(() => ({
299
316
  ...(defaultParams?.search != null ? { search: String(defaultParams.search) } : {}),
300
317
  ...(defaultParams?.size != null ? { size: Number(defaultParams.size) } : {}),
@@ -319,10 +336,10 @@ export function ModelTable<T extends { id: RowId }>({
319
336
  warnOnce(`ModelTable: "${key}" is in both fixedParams and defaultParams — the defaultParams copy is dead (fixed wins on the wire); remove it`);
320
337
  }
321
338
  }
322
- const p = useTableParams(paramDefaults, defaultFilters, fixedKeys);
339
+ const p = useTableParams(paramDefaults, defaultFilters, fixedKeys, paramsMode);
323
340
 
324
341
  // ── View persistence (WM-035): restore once BEFORE the first fetch ──
325
- const storageKey = persistKey ?? `${(typeof location !== 'undefined' ? location.hash.split('?')[0] : '') || '#/'}::${resolvedEndpoint}`;
342
+ const storageKey = persistKey ?? (paramsMode === 'local' ? `local::${resolvedEndpoint}` : `${(typeof location !== 'undefined' ? location.hash.split('?')[0] : '') || '#/'}::${resolvedEndpoint}`);
326
343
  const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set());
327
344
  const [hydrated, setHydrated] = useState(!persistState);
328
345
  const restoreRan = useRef(false);
@@ -355,14 +372,57 @@ export function ModelTable<T extends { id: RowId }>({
355
372
  model?.normalizeListParams ? model.normalizeListParams(tableParams) : tableParams,
356
373
  fixedParams,
357
374
  );
358
- // fixedParams === null means "scope pending" — hold the query so no
359
- // unscoped request is ever fired or cached. undefined = unscoped table.
360
- const query = useModelList<T>(resolvedEndpoint, listParams, {
361
- enabled: hydrated && fixedParams !== null,
362
- sanitizeRow: model?.sanitizeRow,
363
- });
364
- const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
365
- const count = query.data?.count ?? 0;
375
+ const enabled = hydrated && fixedParams !== null;
376
+ const render = (query: ModelTableQuery<T>) => (
377
+ <ModelTableView {...props} query={query} context={{ p, hydrated, hiddenCols, setHiddenCols, storageKey, listParams, fixedSignature, resolvedEndpoint, enabled }} />
378
+ );
379
+ return Adapter
380
+ ? <Adapter params={listParams} enabled={enabled}>{render}</Adapter>
381
+ : <DefaultTableQuery endpoint={resolvedEndpoint} params={listParams} enabled={enabled} sanitizeRow={model?.sanitizeRow}>{render}</DefaultTableQuery>;
382
+ }
383
+
384
+ function DefaultTableQuery<T>({ endpoint, params, enabled, sanitizeRow, children }: ModelTableQueryAdapterProps<T> & {
385
+ endpoint: string;
386
+ sanitizeRow?: (row: T) => T;
387
+ }) {
388
+ const query = useModelList<T>(endpoint, params, { enabled, sanitizeRow });
389
+ return children(query);
390
+ }
391
+
392
+ interface ModelTableContext {
393
+ p: ReturnType<typeof useTableParams>;
394
+ hydrated: boolean;
395
+ hiddenCols: Set<string>;
396
+ setHiddenCols: Dispatch<SetStateAction<Set<string>>>;
397
+ storageKey: string;
398
+ listParams: Params;
399
+ fixedSignature: string;
400
+ resolvedEndpoint: string;
401
+ enabled: boolean;
402
+ }
403
+
404
+ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
405
+ query: ModelTableQuery<T>;
406
+ context: ModelTableContext;
407
+ }) {
408
+ const {
409
+ columns, filters = [], presets = [], eyebrow, title,
410
+ searchable = true, searchPlaceholder = 'Search…', onRowClick, addLabel, onAdd,
411
+ selectable = false, batchActions = [],
412
+ columnChooser = false, persistState = false,
413
+ autoRefresh = 0,
414
+ rowExpand, rowExpandMultiple = false,
415
+ groupBy, groupHeaderLabel, groupHeaderStyle: requestedGroupHeaderStyle = 'banner',
416
+ exportFormats = [], exporter, rowTone,
417
+ } = props;
418
+ const { query, context: { p, hydrated, hiddenCols, setHiddenCols, storageKey, listParams, fixedSignature, resolvedEndpoint, enabled } } = props;
419
+ const qc = useQueryClient();
420
+ const groupHeaderStyle = GROUP_HEADER_STYLES.includes(requestedGroupHeaderStyle) ? requestedGroupHeaderStyle : 'banner';
421
+ if (groupHeaderStyle !== requestedGroupHeaderStyle) {
422
+ warnOnce(`ModelTable: unknown groupHeaderStyle "${requestedGroupHeaderStyle}" — falling back to 'banner'`);
423
+ }
424
+ const rows = useMemo(() => enabled ? query.data?.rows ?? [] : [], [query.data, enabled]);
425
+ const count = enabled ? query.data?.count ?? 0 : 0;
366
426
  const totalPages = Math.max(1, Math.ceil(count / p.size));
367
427
  const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
368
428
  const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
@@ -479,7 +539,8 @@ export function ModelTable<T extends { id: RowId }>({
479
539
  toast.error(err instanceof Error ? err.message : `${action.label} failed`);
480
540
  }
481
541
  setSelected(new Set());
482
- void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
542
+ if (props.queryAdapter) void query.refetch();
543
+ else void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
483
544
  return;
484
545
  }
485
546
  const results = await Promise.allSettled(targets.map((row) => action.run!(row, prepared)));
@@ -491,7 +552,8 @@ export function ModelTable<T extends { id: RowId }>({
491
552
  // Always clear + refetch on a non-cancel path so the user sees the
492
553
  // partial state after failures.
493
554
  setSelected(new Set());
494
- void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
555
+ if (props.queryAdapter) void query.refetch();
556
+ else void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
495
557
  };
496
558
 
497
559
  // ── Row expand ───────────────────────────────────────────────────────
@@ -526,7 +588,7 @@ export function ModelTable<T extends { id: RowId }>({
526
588
  refetchRef.current = query.refetch;
527
589
  const selectedRef = useRef(selected);
528
590
  selectedRef.current = selected;
529
- const autoRefreshMs = autoRefresh > 0 ? Math.max(5, autoRefresh) * 1000 : 0;
591
+ const autoRefreshMs = enabled && autoRefresh > 0 ? Math.max(5, autoRefresh) * 1000 : 0;
530
592
  useEffect(() => {
531
593
  if (!autoRefreshMs) return;
532
594
  const skip = () =>
@@ -610,7 +672,17 @@ export function ModelTable<T extends { id: RowId }>({
610
672
  isExpanded ? 'row-expanded' : '',
611
673
  tone ? `row-tone-${tone}` : '',
612
674
  ].filter(Boolean).join(' ') || undefined}
613
- onClick={onRowClick ? () => onRowClick(row) : undefined}
675
+ tabIndex={onRowClick ? 0 : undefined}
676
+ onClick={onRowClick ? (event) => {
677
+ if (event.defaultPrevented || isRowControl(event.target, event.currentTarget)) return;
678
+ onRowClick(row);
679
+ } : undefined}
680
+ onKeyDown={onRowClick ? (event) => {
681
+ if (event.target !== event.currentTarget || event.defaultPrevented || event.repeat) return;
682
+ if (event.key !== 'Enter' && event.key !== ' ') return;
683
+ event.preventDefault();
684
+ onRowClick(row);
685
+ } : undefined}
614
686
  >
615
687
  {expandEnabled && (
616
688
  <td className="col-expand" onClick={(e) => e.stopPropagation()}>
@@ -710,9 +782,9 @@ export function ModelTable<T extends { id: RowId }>({
710
782
  )}
711
783
  </MenuDropdown>
712
784
  )}
713
- {exportFormats.length > 0 && (
785
+ {exportFormats.length > 0 && (!props.queryAdapter || exporter) && (
714
786
  <MenuDropdown button={(open, toggle) => (
715
- <button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Export">
787
+ <button className="btn btn-compact" disabled={!enabled} onClick={toggle} aria-expanded={open} title="Export">
716
788
  <i className="bi bi-download" /> <span className="btn-label">Export</span>
717
789
  </button>
718
790
  )}>
@@ -729,6 +801,7 @@ export function ModelTable<T extends { id: RowId }>({
729
801
  <button
730
802
  className="btn-icon"
731
803
  title={autoRefreshMs > 0 ? `Refresh · auto every ${Math.round(autoRefreshMs / 1000)}s` : 'Refresh'}
804
+ disabled={!enabled}
732
805
  onClick={() => query.refetch()}
733
806
  >
734
807
  <i className={`bi bi-arrow-repeat${query.isFetching ? ' spin' : ''}`} />
@@ -786,6 +859,13 @@ export function ModelTable<T extends { id: RowId }>({
786
859
  </div>
787
860
  )}
788
861
 
862
+ {props.queryAdapter && enabled && query.status === 'error' && query.data && (
863
+ <div className="form-alert" role="alert">
864
+ {query.error instanceof Error ? query.error.message : 'Could not refresh records'}{' '}
865
+ <button className="btn btn-compact" onClick={() => query.refetch()}>Retry</button>
866
+ </div>
867
+ )}
868
+
789
869
  <div className="tbl-scroll">
790
870
  <table className="tbl">
791
871
  <thead>
@@ -820,7 +900,7 @@ export function ModelTable<T extends { id: RowId }>({
820
900
  change still serving the previous key's rows
821
901
  (isPlaceholderData). Background refetches of the
822
902
  SAME key (autoRefresh) keep the live rows. */}
823
- {query.isPending || query.isPlaceholderData || !hydrated ? (
903
+ {query.status === 'pending' || query.isPlaceholderData || !enabled ? (
824
904
  Array.from({ length: Math.min(p.size, 8) }).map((_, i) => (
825
905
  <tr key={i} className="skel-row" aria-hidden="true">
826
906
  {expandEnabled && <td className="col-expand" />}
@@ -842,7 +922,7 @@ export function ModelTable<T extends { id: RowId }>({
842
922
  ))}
843
923
  </tr>
844
924
  ))
845
- ) : query.isError ? (
925
+ ) : query.status === 'error' && (!props.queryAdapter || !query.data) ? (
846
926
  <tr><td colSpan={totalCols}>
847
927
  <div className="empty-state">
848
928
  <i className="bi bi-exclamation-triangle" />