portal-mojo 0.2.11 → 0.2.13

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/charts.md CHANGED
@@ -69,6 +69,55 @@ Legend entries toggle series; hover shows the crosshair index tooltip;
69
69
  ticks are "nice"-rounded; container width is measured (no viewBox
70
70
  smearing).
71
71
 
72
+ Sparse histories can opt into numeric X positions without changing categorical
73
+ callers:
74
+
75
+ ```tsx
76
+ <SeriesChart
77
+ labels={['10:01', '10:02', '10:03', '10:12', '10:13']}
78
+ xValues={[60, 120, 180, 720, 780]}
79
+ xDomain={[0, 900]}
80
+ maxGap={180}
81
+ datasets={[{ label: 'CPU', data: [12, 24, null, 40, 32] }]}
82
+ minValue={0} maxValue={100}
83
+ ariaLabel="Kiosk CPU history"
84
+ valueFormatter={(value) => `${value}%`}
85
+ onInspect={(index) => { /* selected original sample index, or null */ }}
86
+ />
87
+ ```
88
+
89
+ - `Dataset.data` accepts `(number | null | undefined)[]`. Null, undefined,
90
+ `NaN`, and infinities are unavailable: no zero substitution, connecting
91
+ line, area fill across the gap, bar, or inspection dot. Tooltips say
92
+ **Unavailable**; incomplete stacked totals do too. A one-point run always
93
+ has a dot, even above 40 labels or with routine `showDots={false}`.
94
+ - `xValues?: number[]` aligns with the original labels/data indices. Supply
95
+ ordered timestamps (seconds or milliseconds both work); labels are display
96
+ strings only. Numeric spacing uses actual values. A missing/nonfinite X
97
+ breaks the run and cannot be inspected. Reversed X movement breaks the run.
98
+ With no `xValues`, the existing evenly spaced category centers remain.
99
+ - `xDomain?: [number, number]` fixes the visible window, including empty time
100
+ before/after the samples. Out-of-window samples are omitted from drawing,
101
+ inspection, and automatic Y scaling. Omit it to fit the finite X values;
102
+ a single distinct X is centered. Invalid/reversed domains fall back to fit.
103
+ - `maxGap?: number` breaks line/area runs when the distance between consecutive
104
+ numeric X values exceeds the limit, in the **same units** as `xValues`.
105
+ It does not synthesize timestamps or missing buckets. Omit it for no extra
106
+ distance threshold. Negative/nonfinite limits are ignored.
107
+ - `minValue` and `maxValue` set exact finite Y bounds; omitted ends are
108
+ automatically scaled. Geometry is clipped at the plot bounds. A reversed
109
+ pair falls back to automatic bounds. Percentage callers can use `0`/`100`.
110
+ - `ariaLabel` names the focusable SVG. Left/Right inspect adjacent original
111
+ samples; Home/End inspect the first/last visible timestamp; Escape and blur
112
+ clear inspection. A live accessible readout announces the label and each
113
+ visible series value. Pointer movement and touch use that same index and
114
+ `onInspect(index: number | null)` callback. Touch selection stays after lift;
115
+ pointer cancellation clears it. Changing series, values, labels, X positions,
116
+ or domain clears inspection; equivalent new arrays do not.
117
+
118
+ `npm run verify:series-chart` mounts the real component to cover these
119
+ contracts alongside categorical stacked/grouped bars and container resize.
120
+
72
121
  ## MetricsChart (the django-mojo metrics widget)
73
122
 
74
123
  ```tsx
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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');