portal-mojo 0.2.7 → 0.2.8

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/README.md CHANGED
@@ -52,6 +52,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
52
52
  | [loading.md](loading.md) | `Spinner`, `busy()`/`busyWhile()` blocking overlay, `ViewLoader`, `InlineLoader`, `Busy` — the anti-flash delay rule and skeleton-vs-loader guidance |
53
53
  | [popover.md](popover.md) | Anchored top-layer popover shell — placement, reposition, outside/Escape close, the dialog stacking story |
54
54
  | [user-menu.md](user-menu.md) | Top-nav account menu — avatar trigger, identity header, theme control, app items, pending Sign out, signed-out matrix, keyboard map |
55
+ | [account.md](account.md) | `portal-mojo/account` — the self-service My account modal: six flat sections (profile, passkeys, security, sessions, notifications, API keys), every wire contract it speaks, one-time-secret/fresh-auth invariants, product-portal mounting recipe |
55
56
  | [taginput.md](taginput.md) | Chip/tag entry — CSV wire shape, keyboard matrix, validation + inline errors |
56
57
  | [combobox.md](combobox.md) | The house autocomplete — options with descriptions/meta, commit-only change pipeline, allowCustom, ARIA |
57
58
  | [location-address.md](location-address.md) | Six-endpoint LocationClient, private provider sessions, stale guards, controlled AddressField, atomic form patching |
@@ -0,0 +1,260 @@
1
+ # account — the self-service "My account" modal
2
+
3
+ ```ts
4
+ import {
5
+ AccountModal, openAccountModal, ACCOUNT_SECTIONS,
6
+ type AccountModalProps, type AccountSectionKey, type AccountSectionDef,
7
+ // the sections, mountable on their own (each takes { me })
8
+ AccountProfileSection, AccountPasskeysSection, AccountSecuritySection,
9
+ AccountSessionsSection, AccountNotificationsSection, AccountApiKeysSection,
10
+ // shared pieces (admin re-exports these unchanged)
11
+ PasskeyList, NotificationPreferences, ApiKeysSection,
12
+ PasskeyModel, ApiKeyModel, useGenerateUserApiKey, OAuthConnectionModel, MeSaveModel,
13
+ type NotificationKind,
14
+ } from 'portal-mojo/account';
15
+ ```
16
+
17
+ The signed-in user's own account, in ONE modal opened from the avatar menu
18
+ (portal-mojo #7184). A short rail is the whole navigation; each entry is one
19
+ flat section (no drill-ins, no cross-links); Sign out sits at the rail's
20
+ foot; the header carries the section title and Close. ≈760×520 on desktop;
21
+ under **720px** a full-screen sheet whose rail becomes a row of tabs across
22
+ the top. Every focused flow (add passkey, authenticator, recovery codes,
23
+ password, email/phone, verify, photo) stacks its own dialog over the modal.
24
+
25
+ `portal-mojo/account` is a page bundle bound to django-mojo wire contracts,
26
+ like an admin domain — but it never imports the admin barrel and runs no
27
+ setup at import, so a product portal can mount it without the Admin.
28
+
29
+ ```tsx
30
+ import { openAccountModal } from 'portal-mojo/account';
31
+
32
+ <UserMenu
33
+ authMode={authMode()}
34
+ onSignOut={signOut}
35
+ items={[{
36
+ label: 'My account', icon: 'bi-person-circle',
37
+ onSelect: () => void openAccountModal({ onSignOut: signOut, authMode: authMode() }),
38
+ }]}
39
+ />
40
+ ```
41
+
42
+ Live demo: showcase → Components → Account → **AccountModal**.
43
+
44
+ ## Props
45
+
46
+ `openAccountModal(props?)` opens `AccountModal` on the toolkit modal stack
47
+ (`modal.open`, size `lg`) and resolves when it closes. It takes every prop
48
+ below except `onClose`.
49
+
50
+ | Prop | Type | Notes |
51
+ |---|---|---|
52
+ | `sections?` | `AccountSectionKey[]` | Built-ins to show, in order. Default all six (`ACCOUNT_SECTIONS`): `'profile' \| 'passkeys' \| 'security' \| 'sessions' \| 'notifications' \| 'api-keys'`. Unknown keys warn once and drop. |
53
+ | `extraSections?` | `AccountSectionDef[]` | App sections appended to the rail: `{key, label, icon, render({me, close})}`. A key that duplicates one already on the rail warns and drops. |
54
+ | `initialSection?` | `string` | Section open on mount (built-in or extra key). Unknown keys warn and fall back to the first. |
55
+ | `onSignOut?` | `() => void \| Promise<void>` | Replaces the default `logout()` on the rail's Sign out. Pending state ("Signing out…", spinner, `aria-busy`); a rejection toasts `Sign out failed: <message>` and keeps the modal open. Pass the SAME handler as the app's `UserMenu`. |
56
+ | `authMode?` | `'inapp' \| 'hosted'` | With the default sign out only: `'hosted'` → `redirectToHostedAuth()` (when `hostedAuthUrl()` resolves), `'inapp'` → `#/auth/login`, omitted → the app's auth gate routes. |
57
+ | `notificationKinds?` | `NotificationKind[]` | The kinds this app sends: `{kind, label, description?, channels?}` (`channels` null/absent = all three). Overrides the server's catalogue. |
58
+ | `onClose` | `() => void` | `AccountModal` only — Close, sign out, or the session ending underneath it. |
59
+
60
+ Signed out: the modal has no account to show — it closes itself as soon as
61
+ `useAuthSnapshot().authenticated` is false (sign-out elsewhere, refresh
62
+ failure), and an `openAccountModal()` call while signed out closes at once.
63
+ `UserMenu` renders its signed-out matrix instead of the menu, so the item is
64
+ unreachable there anyway.
65
+
66
+ ## Sections
67
+
68
+ 1. **Profile** — avatar (image or initials) with a camera button → photo
69
+ dialog (`ImageField`, required square crop to 200×200); name, email and a
70
+ "System admin" line for superusers. CONTACT: Email (Verified/Unverified
71
+ chip; Verify → code dialog; Change → two-step email-change dialog), Phone
72
+ (Not set → Add; Verify; Change — the two-step phone-change flow). PERSONAL:
73
+ display name + timezone (`metadata.timezone`) through `FormView`
74
+ autosave ("Saves as you type"). One row per linked Google/Apple/… sign-in
75
+ with Unlink (confirm first; the server's 400 shown as written).
76
+ 2. **Passkeys** — intro, one card per passkey (`PasskeyList`: icon, friendly
77
+ name, "Added <date> · last used <relative>" / "never used"; Rename,
78
+ Remove, and Enable on a disabled one), a dashed **Add a passkey on this
79
+ device** button → dialog (suggested name, "works on <this host>",
80
+ Continue → the WebAuthn create ceremony), footer "Removing a passkey signs
81
+ out nothing. Keep at least one other way to sign in." Browsers without
82
+ `navigator.credentials.create` see a note instead of the button.
83
+ 3. **Security** — Authenticator app card (On/Off chip; Set up / "Set up a
84
+ new app" → 3-step enrol dialog: QR or key → code → recovery codes; Turn
85
+ off → confirm whose copy says two-step sign-in stays required when
86
+ `requires_mfa`), Recovery codes card ("N of 8 left", warn tone at ≤2;
87
+ View → masked hints; Regenerate → code → shown-once set), Password card
88
+ (Change → current + new + confirm with `PasswordStrengthMeter`, then a
89
+ `toast.action` offering **Sign out everywhere else** → `revokeOtherSessions()`
90
+ — a password change does not rotate `auth_key`; a
91
+ passwordless account gets "Email me a link" → `forgotPassword(email,
92
+ 'link')`).
93
+ 4. **Sessions** — up to four device cards (browser on OS, place · "active
94
+ now" or relative time; "This device" and "New location" chips), "And N
95
+ more devices", then **Sign out everywhere else** (confirm → revoke; this
96
+ session stays).
97
+ 5. **Notifications** — `NotificationPreferences`: CHANNELS — three master
98
+ cards Email / Push / In app, one switch each; WHAT TO SEND — one row per
99
+ kind × channel checkbox grid. A channel whose master is off renders its
100
+ column disabled; a kind not delivered on a channel shows "—". Every
101
+ toggle is optimistic and reverts + toasts the server's message on failure.
102
+ 6. **API keys** — the shared `ApiKeysSection` for self: list, Generate (show-
103
+ once token via `showSecretDialog`), Revoke.
104
+
105
+ ## Wire contract
106
+
107
+ Detail answers are `{status, data}`; lists add `count/start/size`. "Fresh
108
+ auth" = the call runs under `withFreshAuth` (a 440 prompts, then retries once).
109
+
110
+ | Flow | Request | Answer / notes |
111
+ |---|---|---|
112
+ | Identity | `GET /api/user/me` (`useMe`, key `['me', uid]`) | sanitized; `avatar` is `{id} \| null` |
113
+ | Personal autosave, photo | `POST /api/user/me {changed fields}` via `MeSaveModel` (fresh auth) | sanitized row → `['me', uid]`, every `/api/user` cache invalidated |
114
+ | Password | `POST /api/user/me {current_password, new_password}` | 400 on wrong/missing current; no fresh auth, no session rotation |
115
+ | Passkeys list | `GET /api/account/passkeys?user=<me.id>&sort=-created&size=25` | `?user=` is REQUIRED — a `users`/`manage_users` caller is otherwise served every user's rows |
116
+ | Rename / enable | `POST /api/account/passkeys/<id> {friendly_name}` / `{is_enabled}` | the only two editable fields |
117
+ | Remove passkey | `DELETE /api/account/passkeys/<id>` | `{status:"deleted"}`; no last-method guard |
118
+ | Register begin | `POST /api/account/passkeys/register/begin {}` (fresh auth) | `{challenge_id, publicKey, expiresAt}` |
119
+ | Register complete | `POST /api/account/passkeys/register/complete {challenge_id, credential, friendly_name}` (fresh auth) | the saved Passkey row |
120
+ | TOTP setup | `POST /api/account/totp/setup {}` (fresh auth) | `{secret, uri, qr_code:"data:image/…;base64,…"}`; calling it again replaces the secret and DISABLES an active authenticator until confirmed |
121
+ | TOTP confirm | `POST /api/account/totp/confirm {code}` (fresh auth) | `{is_enabled:true, recovery_codes:[8]}`; sets `requires_mfa` |
122
+ | TOTP off | `DELETE /api/account/totp` (fresh auth) | `{status:true}`; does NOT clear `requires_mfa` |
123
+ | Enrolled + count | `GET /api/account/totp/recovery-codes` | `{remaining, codes}` (masked hints) or **400 when not enrolled** — the only self-visible "authenticator is on" signal |
124
+ | Regenerate codes | `POST /api/account/totp/recovery-codes/regenerate {code}` (fresh auth) | `{recovery_codes}` |
125
+ | Email change | `POST /api/auth/email/change/request {email, method:'code'}` (fresh auth) → `POST /api/auth/email/change/confirm {code}` | confirm commits, rotates `auth_key` and answers a NEW login — adopted |
126
+ | Phone add/change | `POST /api/auth/phone/change/request {phone_number}` (fresh auth) → `POST /api/auth/phone/change/confirm {session_token, code}` | `session_token` rides at the envelope TOP level |
127
+ | Verify | `POST /api/auth/verify/{email\|phone}/send` (email sends `{method:'code'}`) → `…/confirm {code}` | |
128
+ | Linked sign-ins | `GET /api/account/oauth_connection?user=<me.id>`; `DELETE …/<id>` | 400 "Cannot unlink your only login method. Set a password first." shown as written |
129
+ | Devices | `GET /api/user/device?user=<me.id>&sort=-last_seen&size=4`; `GET /api/account/logins?user=<me.id>&sort=-created&size=50` | read-only; place + New-location chip from the device's latest login |
130
+ | Sign out others | `POST /api/auth/sessions/revoke {}` (fresh auth) | rotates `auth_key`, answers a NEW login — adopted, so this session stays |
131
+ | Notifications | `GET /api/account/notification/preferences`; `POST … {preferences:{kind:{email\|push\|in_app: bool}}}` | partial update, absent = on; caller-only |
132
+ | API keys | `GET /api/account/api_keys?user=<me.id>`; `POST /api/auth/generate_api_key {label, allowed_ips?, expire_days≤360}` (fresh auth); `POST /api/account/api_keys/<id> {revoke:{}}` (the POST_SAVE_ACTION) | no DELETE |
133
+
134
+ Never use the `revoke_sessions` user action for "sign out others" — it kills
135
+ the caller's own session too.
136
+
137
+ ### Passkey registration ceremony (`registerPasskey(name?)`)
138
+
139
+ `publicKey.challenge`, `publicKey.user.id` and `excludeCredentials[].id` are
140
+ base64url-decoded before `navigator.credentials.create({publicKey})` (null →
141
+ "No credential received from authenticator"). The credential posts as
142
+ `{id, rawId, type, response:{clientDataJSON, attestationObject}, transports}`
143
+ — `transports` (`response.getTransports?.() ?? []`) at the credential's
144
+ **top level**, not inside `response`; the server rejects `id !== rawId`. The
145
+ name key is **`friendly_name`** (default `suggestPasskeyName()`, e.g. "Mac —
146
+ Chrome"). Complete is retry-safe (matched on `credential_id`; the challenge is
147
+ deleted only after success), so a 440 on complete replays the same body.
148
+ Errors map through `passkeyErrorMessage()` (dismissed, already registered,
149
+ wrong domain, cancelled; server text as written).
150
+
151
+ ### `POST /api/user/me` — what a self save may carry
152
+
153
+ - **Allowed**: `first_name`, `last_name`, `display_name`, `metadata.*`
154
+ (merged), `phone_number` only to set a first one or clear it, `dob` only
155
+ when unset, `avatar` (a completed, groupless image File the caller
156
+ uploaded, or null), `{new_password, current_password}`.
157
+ - **403**: `email`/`username` changed, `password`, admin-only fields
158
+ (`is_email_verified`, `is_phone_verified`, `requires_mfa`, `is_active`,
159
+ `org`) changed, `is_superuser`/`is_staff` **even unchanged**,
160
+ `permissions`, `metadata.protected`, replacing an existing phone ("Use the
161
+ phone change flow…"), changing a set `dob`.
162
+ - **Silently dropped** (NO_SAVE_FIELDS): `id`, `auth_key`, `last_activity`,
163
+ `is_dob_verified`, `requires_password_change`, secrets, `has_password`,
164
+ `has_passkey`, ….
165
+
166
+ So never post the `me` row back as-is — `MeSaveModel` posts only the changed
167
+ fields. `has_password` is in the `me` graph; passwordless users set a first
168
+ password through `/api/auth/forgot`.
169
+
170
+ ### Notification preferences
171
+
172
+ The reserved kind **`"*"`** is the per-channel master switch:
173
+ `{"*": {email: false}}` silences every kind on email, including kinds
174
+ registered later; the UI reads `"*".<channel> === false` as master off. Kind
175
+ rows resolve: the `notificationKinds` prop ▸ the GET's `kinds` catalogue
176
+ (when the server ships the registry) ▸ any kind already stored in the
177
+ caller's preferences ▸ one "General" row.
178
+
179
+ ## Invariants
180
+
181
+ - **One-time secrets never enter a cache.** TOTP secret/uri/QR, recovery
182
+ codes, API tokens, the phone-change `session_token` and returned logins
183
+ live in the dialog's component state (or a pending-mutation callback, the
184
+ `useGenerateUserApiKey` pattern) and die with it. The recovery-code status
185
+ cache holds only `{enrolled, remaining}` — the masked hints leak 4 of 12
186
+ hex chars each and are fetched into the View dialog only.
187
+ - **Shown-once codes** cannot be dismissed (Escape/backdrop/Done) until "I
188
+ saved these somewhere safe" is ticked; Copy + a local `recovery-codes.txt`
189
+ download (object URL revoked). Copy: "Save these codes now. They will not
190
+ be shown again. Old codes no longer work."
191
+ - **Dialogs die with the session.** Sign-out / `'unauthorized'` / a uid
192
+ change closes the AccountModal and every dialog over it (the modal stack
193
+ empties). Authenticator setup runs once per OPEN (never again on a host
194
+ remount) and only for the uid the dialog was opened for — otherwise it
195
+ closes with the sign-in copy.
196
+ - **Fresh auth**: every server-gated call is wrapped in `withFreshAuth`; a
197
+ dismissed step-up reads "For your security, sign in again to make this
198
+ change." The step-up is the app's FreshAuthHost — never an ad-hoc password
199
+ prompt.
200
+ - **QR** renders only from a `data:image/(png|svg+xml);base64,` value inside
201
+ `<img>` (`safeQrDataUrl`, re-checked at the sink); anything else falls back
202
+ to the typed key.
203
+ - **Avatar URL in component state only** (`useAvatarUrl`, the
204
+ [user-menu.md](user-menu.md) rule); the `me` cache holds `{id}`.
205
+ - **Orphaned passkeys are signalled.** When `navigator.credentials.create`
206
+ succeeded but `register/complete` failed (or its step-up was dismissed),
207
+ `registerPasskey` best-effort calls
208
+ `PublicKeyCredential.signalUnknownCredential({rpId, credentialId})` so the
209
+ authenticator can drop the unsaved credential, then rethrows the original
210
+ error.
211
+ - **Passkeys are host-bound**: the Add dialog says which host the passkey
212
+ will work on. Registration options carry no `authenticatorSelection` yet
213
+ (django-mojo #7191).
214
+ - Server messages are shown as written; pending writes block dismissal.
215
+ - Turning the authenticator off keeps two-step sign-in required — only an
216
+ administrator clears `requires_mfa`.
217
+
218
+ ## Mounting in a product portal
219
+
220
+ 1. Add the one-liner above to the app's `UserMenu` `items` (the same
221
+ `onSignOut` both places). Lazy-load it if the shell entry is budgeted:
222
+ `import('portal-mojo/account').then((m) => m.openAccountModal(props))`
223
+ (apps/portal does this).
224
+ 2. Copy the CSS (the package ships none): `theme/account.css`, plus
225
+ `theme/user-admin.css` (API-key rows) and `theme/group-admin.css`
226
+ (`.ga-secret-*` token reveal) if the app doesn't already ship them.
227
+ 3. Mount `ModalHost` + `ToastHost`, and a fresh-auth handler (apps/portal's
228
+ `FreshAuthHost`, registered through `setFreshAuthHandler`) inside the
229
+ authenticated tree. Without a handler a 440 just fails with the
230
+ fresh-auth copy.
231
+ 4. Pass `notificationKinds` for the kinds the app sends until the server
232
+ ships its registry (CamActive: alarms / security / controllers / digest).
233
+
234
+ ## Mock
235
+
236
+ - Every emailed / texted / authenticator code is **`123456`** (TOTP confirm
237
+ and regenerate, email change, phone change, verify); TOTP setup returns a
238
+ fixed secret; confirm answers 8 recovery codes.
239
+ - Seeded users that already require MFA (Maya, user 2) start **enrolled**;
240
+ everyone else gets the 400 not-enrolled answer until they set up.
241
+ - Passkey register begin/complete validate shape only (complete requires
242
+ `credential.id === rawId` and upserts a row); non-admin passkey reads are
243
+ owner-scoped.
244
+ - `sessions/revoke` and email-change confirm rotate `auth_key` (other mock
245
+ tokens die) and answer a fresh token pair.
246
+ - `armMockReauth('POST', '/api/auth/sessions/revoke')` (from
247
+ `portal-mojo/client`; any gated method + exact path) makes the next
248
+ matching authenticated call answer 440 once — the showcase Account demo
249
+ has a button for it.
250
+
251
+ ## Pitfalls
252
+
253
+ - Needs the app's QueryClient, router, `ThemeProvider`, `ModalHost`,
254
+ `ToastHost` and a fresh-auth handler (see Mounting).
255
+ - Don't add a second Sign out path that skips the app's `onSignOut` — Admin
256
+ must revoke its source session.
257
+ - `?user=<me.id>` on every list is load-bearing for admins: drop it and an
258
+ admin's own account shows everyone's passkeys / keys.
259
+ - Real-authenticator passkey registration is pending verification against a
260
+ live django-mojo (the mock checks shape only).
@@ -13,6 +13,8 @@ import {
13
13
  // Fresh-auth (step-up) challenge surface
14
14
  REAUTH_STATUS, isReauthRequired, setFreshAuthHandler, requestFreshAuth,
15
15
  withFreshAuth, sessionIsPersistent,
16
+ // shared passkey error copy (LoginPage, MfaPanel, FreshAuthHost)
17
+ passkeyErrorMessage,
16
18
  type MfaChallenge, type FreshAuthHandler, type MfaCompletionOptions,
17
19
  } from 'portal-mojo/client';
18
20
 
@@ -80,6 +82,8 @@ answers with a full TokenGrant).
80
82
  - **Passkey**: `loginWithPasskey(username?, {remember})` behind `isPasskeySupported()`;
81
83
  no username → discoverable-credential prompt. The ceremony is fully built;
82
84
  REAL-authenticator verification is deferred (mock validates shape only).
85
+ Failures read through `passkeyErrorMessage()` (one copy for login, the MFA
86
+ passkey step and the fresh-auth modal).
83
87
  The remember choice is explicit because MFA and fresh-auth passkey paths
84
88
  must not promote a sessionStorage session into localStorage.
85
89
 
@@ -193,9 +197,10 @@ the first matching 440, retry success, and later normal calls reproducible.
193
197
  deployment-specific providers (`GET /api/auth/config` → `login.methods`),
194
198
  and no way to exercise the round-trip against the mock or without real
195
199
  provider credentials. Hosted pages cover OAuth deployments today.
196
- - **Passkey real-authenticator verification** — the ceremony is fully
197
- built on auth.ts; verifying against a real authenticator + a live
198
- django-mojo passkey registration is Ian's post-merge pass.
200
+ - **Passkey real-authenticator verification** — login and registration
201
+ ceremonies are built on auth.ts (`loginWithPasskey`, `registerPasskey` —
202
+ the AccountModal's Add a passkey, see [account.md](account.md)).
203
+ Registration ceremony built; real-authenticator check pending.
199
204
 
200
205
  ## Pitfalls
201
206
 
package/docs/auth.md CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  sendMagicLink, loginWithMagicToken, handleMagicTokenFromURL,
8
8
  forgotPassword, resetPasswordWithCode, resetPasswordWithToken,
9
9
  loginWithPasskey, isPasskeySupported,
10
+ // self-service, signed in (the AccountModal's wire — see account.md)
11
+ registerPasskey, isPasskeyRegistrationSupported, suggestPasskeyName,
12
+ passkeyErrorMessage, confirmEmailChange, revokeOtherSessions,
10
13
  } from 'portal-mojo/client';
11
14
  ```
12
15
 
@@ -17,6 +20,7 @@ initAuth(); // installs the pre-request gate + Authorizati
17
20
  void handleAuthCodeFromURL(); // returning from the hosted /auth pages (no-op otherwise)
18
21
  onAuth('login', () => queryClient.invalidateQueries()); // identity changed →
19
22
  onAuth('logout', () => queryClient.invalidateQueries()); // every cached answer is suspect
23
+ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issued session
20
24
  ```
21
25
 
22
26
  ## Session mechanics
@@ -27,8 +31,24 @@ onAuth('logout', () => queryClient.invalidateQueries()); // every cached answe
27
31
  - `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
28
32
  - `logout()` is client-side (django-mojo has no logout endpoint) and emits
29
33
  `'logout'`.
30
- - Events for `onAuth`: `'login' | 'logout' | 'refreshed' | 'refresh-failed'
31
- | 'unauthorized'`.
34
+ - Every flow that answers with a login grant adopts it only when it carries
35
+ an `access_token` + `refresh_token` pair; anything else (e.g. django-mojo's
36
+ `forced_password_response` after `auth_key` rotated) rejects with
37
+ `GRANT_SIGN_IN_AGAIN` ("Sign in again to continue") and leaves storage
38
+ untouched. Adoption waits for an in-flight refresh, and a refresh that
39
+ completes after the stored session changed (new login, logout, another
40
+ tab) is discarded instead of written over it.
41
+ - Events for `onAuth`: `'login' | 'rotated' | 'logout' | 'refreshed' |
42
+ 'refresh-failed' | 'unauthorized'`.
43
+ - `'login'` — an explicit, completed sign-in (password, MFA step, passkey,
44
+ magic link, password reset, hosted-auth exchange).
45
+ - `'rotated'` — the SAME user's session re-issued by a credential change
46
+ (`revokeOtherSessions()`, `confirmEmailChange()`): new tokens in the same
47
+ storage, but NOT a login — never treat it as one (the packaged Admin
48
+ source session reopens a logout tombstone only on `'login'`). Invalidate
49
+ caches on it where you do on `'login'`.
50
+ - `'logout'` / `'unauthorized'` — the session ended; the toolkit modal
51
+ stack closes every open dialog on both.
32
52
 
33
53
  Both localStorage and sessionStorage keep the existing token keys. Refresh
34
54
  preserves the chosen storage, including sessions with only a valid refresh token
@@ -84,3 +104,26 @@ magic-link (`sendMagicLink` → `loginWithMagicToken`), password reset (code
84
104
  and token variants), passkeys (`loginWithPasskey`; ceremony fully ported,
85
105
  mock validates shape only). All reject with the server's message on
86
106
  failure.
107
+
108
+ ## Self-service credential changes (signed in)
109
+
110
+ - `isPasskeyRegistrationSupported()` — `PublicKeyCredential` **and**
111
+ `navigator.credentials.create` (`isPasskeySupported()` only checks `get`).
112
+ - `registerPasskey(name?)` — fresh-auth `POST /api/account/passkeys/register/begin`
113
+ → `navigator.credentials.create` → fresh-auth `…/register/complete
114
+ {challenge_id, credential, friendly_name}`. `transports` rides at the
115
+ credential's top level; `id === rawId`. Returns the saved row; callers
116
+ refresh `PasskeyModel` and `me`. Default name `suggestPasskeyName()`.
117
+ - `passkeyErrorMessage(err)` — one copy for every passkey ceremony (login,
118
+ step-up, registration): NotAllowedError → "Passkey prompt was dismissed",
119
+ InvalidStateError → already registered, SecurityError → wrong domain/HTTPS,
120
+ AbortError → cancelled; anything else (server text) as written.
121
+ - `confirmEmailChange(code)` — `POST /api/auth/email/change/confirm`; the
122
+ server rotates `auth_key` and answers a new login, adopted into the
123
+ storage this session already uses (`sessionIsPersistent()`).
124
+ - `revokeOtherSessions()` — fresh-auth `POST /api/auth/sessions/revoke`;
125
+ every other session dies, the returned login is adopted so this one stays.
126
+
127
+ Registration ceremony built; real-authenticator check pending. The rest of
128
+ the account wire (TOTP, recovery codes, phone, verify, preferences) is in
129
+ [account.md](account.md).
package/docs/feedback.md CHANGED
@@ -17,12 +17,21 @@ is a Promise that resolves when its dialog closes.
17
17
  | `modal.detail((close) => <DetailView …/>)` | detail-sized shell for record views |
18
18
  | `modal.drawer<T>({title, content\|render, …})` | `T \| null` — right slide-over (below) |
19
19
  | `formModal({title, fields, initial?, submitText?, intro?})` | `FormData \| null` (see forms.md) |
20
+ | `modal.closeAll()` | closes every open modal/drawer; each await resolves `null` |
20
21
 
21
22
  Patterns:
22
23
  - Danger confirms: `danger: true` styles the confirm button destructively.
23
24
  - Batch prepare: open a `formModal` INSIDE a flow to collect once-per-batch
24
25
  input; resolve null to cancel the whole thing.
25
26
  - Stacked dialogs are fine (disable-reason form over a detail modal).
27
+ - `canDismiss` also covers the dialog closing natively without a cancelable
28
+ `cancel` (Chromium's second Escape with no click between, Android back):
29
+ a locked modal reopens with focus inside; an unlocked one resolves `null`.
30
+ A `dismissable: false` drawer reopens the same way.
31
+ - The stack ends with the session: `'logout'` / `'unauthorized'` auth events,
32
+ or the signed-in uid changing (another tab), close every open dialog
33
+ (`null`), so a host remounted after sign-in never brings a previous user's
34
+ dialog back.
26
35
 
27
36
  ## confirmGuardrail — the "are you sure, and here is why" stop
28
37
 
package/docs/idioms.md CHANGED
@@ -66,6 +66,18 @@ handle.dismiss(); // optional early resolution
66
66
  - `onUndo` typically fires the inverse mutation; surface ITS failure with a
67
67
  normal `toast.error` — the undo toast itself is done by then.
68
68
 
69
+ ## toast.action / actionToast
70
+
71
+ ```ts
72
+ toast.action('Your other sessions stay signed in.', 'Sign out everywhere else', () => revokeOthers(), { timeout: 10000 });
73
+ ```
74
+
75
+ The undo card with a named button: a follow-up OFFER after something
76
+ already happened (not an inverse). Clicking dismisses at once and calls
77
+ `onAction`; otherwise the card auto-dismisses after `timeout` (default
78
+ 10000ms). Returns `{ dismiss }`. Surface the action's own outcome with a
79
+ normal toast.
80
+
69
81
  ## toast.progress / progressToast
70
82
 
71
83
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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",
@@ -27,6 +27,7 @@
27
27
  "exports": {
28
28
  "./client": "./src/client/index.ts",
29
29
  "./client/runtime": "./src/client/runtime.ts",
30
+ "./account": "./src/account/index.ts",
30
31
  "./ui": "./src/ui/index.ts",
31
32
  "./ui/shell": "./src/ui/shell.ts",
32
33
  "./charts": "./src/charts/index.ts",