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 +1 -0
- package/docs/account.md +260 -0
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/feedback.md +9 -0
- package/docs/idioms.md +12 -0
- package/package.json +2 -1
- package/src/account/AccountModal.tsx +275 -0
- package/src/account/AccountSections.tsx +370 -0
- package/src/account/NotificationPreferences.tsx +152 -0
- package/src/account/PasskeyList.tsx +144 -0
- package/src/account/api.ts +236 -0
- package/src/account/credential-models.ts +153 -0
- package/src/account/dialogs.tsx +822 -0
- package/src/account/index.ts +16 -0
- package/src/account/models.ts +76 -0
- package/src/account/sections/ApiKeysSection.tsx +138 -0
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/identity/users/models.ts +16 -128
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
- package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
- package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
- package/src/admin/identity/users/sections/actions.tsx +7 -100
- package/src/client/auth.ts +192 -5
- package/src/client/mock.ts +472 -7
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +20 -2
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 |
|
package/docs/account.md
ADDED
|
@@ -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).
|
package/docs/auth-pages.md
CHANGED
|
@@ -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** —
|
|
197
|
-
built on auth.ts
|
|
198
|
-
|
|
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
|
-
-
|
|
31
|
-
|
|
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.
|
|
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",
|