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.
- package/docs/ModelTable.md +84 -3
- package/docs/account.md +10 -0
- package/docs/charts.md +49 -0
- package/docs/params.md +24 -6
- package/package.json +1 -1
- package/src/account/AccountSections.tsx +22 -12
- package/src/account/actor.ts +40 -0
- package/src/account/api.ts +44 -17
- package/src/account/dialogs.tsx +25 -18
- package/src/account/models.ts +13 -6
- package/src/charts/SeriesChart.tsx +207 -57
- package/src/client/auth.ts +7 -4
- package/src/client/params.ts +21 -5
- package/src/styles/core.css +2 -0
- package/src/ui/ModelTable.tsx +120 -40
package/docs/ModelTable.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
147
|
-
title: `Unlink ${label}?`,
|
|
148
|
-
message: <>You won’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
|
|
150
|
+
const ok = await modal.confirm({
|
|
151
|
+
title: `Unlink ${label}?`,
|
|
152
|
+
message: <>You won’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={
|
|
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
|
+
}
|
package/src/account/api.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/account/dialogs.tsx
CHANGED
|
@@ -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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|