portal-mojo 0.2.7 → 0.2.9

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.
Files changed (168) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +7 -2
  4. package/docs/account.md +261 -0
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/auth-pages.md +8 -3
  22. package/docs/auth.md +45 -2
  23. package/docs/calendar.md +1 -1
  24. package/docs/client.md +159 -26
  25. package/docs/collection-multiselect.md +2 -2
  26. package/docs/combobox.md +2 -2
  27. package/docs/dataview.md +1 -1
  28. package/docs/datepicker.md +3 -3
  29. package/docs/daterange.md +1 -1
  30. package/docs/datetimepicker.md +1 -1
  31. package/docs/defineModel.md +40 -7
  32. package/docs/detail-primitives.md +1 -1
  33. package/docs/feedback.md +18 -8
  34. package/docs/file-upload-ux.md +2 -2
  35. package/docs/idioms.md +13 -1
  36. package/docs/loading.md +2 -2
  37. package/docs/location-address.md +2 -1
  38. package/docs/markdown.md +3 -3
  39. package/docs/menus-and-access.md +21 -1
  40. package/docs/multiselect-dropdown.md +1 -1
  41. package/docs/popover.md +1 -1
  42. package/docs/record-feed.md +2 -2
  43. package/docs/taginput.md +2 -1
  44. package/docs/timepicker.md +1 -1
  45. package/docs/timezone-select.md +4 -3
  46. package/docs/user-menu.md +4 -3
  47. package/docs/worldmap.md +2 -2
  48. package/package.json +4 -2
  49. package/src/account/AccountModal.tsx +276 -0
  50. package/src/account/AccountSections.tsx +370 -0
  51. package/src/account/NotificationPreferences.tsx +152 -0
  52. package/src/account/PasskeyList.tsx +144 -0
  53. package/src/account/api.ts +236 -0
  54. package/src/account/credential-models.ts +153 -0
  55. package/src/account/dialogs.tsx +822 -0
  56. package/src/account/index.ts +16 -0
  57. package/src/account/models.ts +76 -0
  58. package/src/account/sections/ApiKeysSection.tsx +138 -0
  59. package/src/admin/core/index.ts +1 -1
  60. package/src/admin/credentials/secret-dialog.tsx +3 -77
  61. package/src/admin/dns/models.ts +1 -1
  62. package/src/admin/domains/infrastructure.ts +12 -2
  63. package/src/admin/domains/security.ts +3 -1
  64. package/src/admin/edge/BlocklistPage.tsx +110 -0
  65. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  66. package/src/admin/edge/VhostDetail.tsx +166 -0
  67. package/src/admin/edge/VhostWizard.tsx +273 -0
  68. package/src/admin/edge/VhostsPage.tsx +47 -0
  69. package/src/admin/edge/api.ts +56 -0
  70. package/src/admin/edge/index.ts +15 -0
  71. package/src/admin/edge/models.ts +395 -0
  72. package/src/admin/edge/permissions.ts +13 -0
  73. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  74. package/src/admin/identity/users/models.ts +16 -128
  75. package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
  76. package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
  77. package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
  78. package/src/admin/identity/users/sections/actions.tsx +10 -103
  79. package/src/admin/index.ts +1 -0
  80. package/src/admin/phonehub/api.ts +5 -3
  81. package/src/admin/public/infrastructure.ts +1 -0
  82. package/src/admin/registry.ts +2 -1
  83. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  84. package/src/admin/storage/file-renditions.ts +12 -0
  85. package/src/admin/storage/index.ts +2 -0
  86. package/src/admin/storage/rendition-settings.ts +264 -0
  87. package/src/charts/exportChart.ts +1 -1
  88. package/src/client/action-result.ts +23 -61
  89. package/src/client/active-group.ts +9 -3
  90. package/src/client/auth.ts +207 -6
  91. package/src/client/client.ts +49 -10
  92. package/src/client/errors.ts +57 -2
  93. package/src/client/group-context.ts +2 -0
  94. package/src/client/group.tsx +112 -20
  95. package/src/client/hooks.ts +7 -3
  96. package/src/client/mock.ts +1429 -20
  97. package/src/client/model.ts +15 -3
  98. package/src/client/renditions-mock.ts +212 -0
  99. package/src/client/scoped.ts +19 -5
  100. package/src/styles/components/account.css +304 -0
  101. package/src/styles/components/admin-assistant.css +28 -0
  102. package/src/styles/components/admin-bouncer.css +53 -0
  103. package/src/styles/components/admin-cloudwatch.css +23 -0
  104. package/src/styles/components/admin-dashboard.css +16 -0
  105. package/src/styles/components/admin-devices.css +260 -0
  106. package/src/styles/components/admin-dns-certificates.css +28 -0
  107. package/src/styles/components/admin-dns-registrar.css +1 -0
  108. package/src/styles/components/admin-dns.css +43 -0
  109. package/src/styles/components/admin-edge.css +1 -0
  110. package/src/styles/components/admin-incidents.css +14 -0
  111. package/src/styles/components/admin-jobs.css +237 -0
  112. package/src/styles/components/admin-media-rendering.css +38 -0
  113. package/src/styles/components/admin-messaging.css +3 -0
  114. package/src/styles/components/admin-monitoring.css +152 -0
  115. package/src/styles/components/admin-network.css +406 -0
  116. package/src/styles/components/admin-phonehub.css +61 -0
  117. package/src/styles/components/admin-push.css +30 -0
  118. package/src/styles/components/admin-rules.css +23 -0
  119. package/src/styles/components/admin-security.css +37 -0
  120. package/src/styles/components/admin-shortlinks.css +8 -0
  121. package/src/styles/components/admin-signin.css +64 -0
  122. package/src/styles/components/admin-storage.css +4 -0
  123. package/src/styles/components/auth.css +162 -0
  124. package/src/styles/components/calendar.css +228 -0
  125. package/src/styles/components/charts-c2.css +249 -0
  126. package/src/styles/components/collection-multiselect.css +114 -0
  127. package/src/styles/components/collection-select.css +63 -0
  128. package/src/styles/components/combobox.css +42 -0
  129. package/src/styles/components/dataview.css +139 -0
  130. package/src/styles/components/datepicker.css +106 -0
  131. package/src/styles/components/daterange.css +119 -0
  132. package/src/styles/components/datetimepicker.css +230 -0
  133. package/src/styles/components/detail-primitives.css +246 -0
  134. package/src/styles/components/detailview.css +69 -0
  135. package/src/styles/components/drawer.css +111 -0
  136. package/src/styles/components/file-upload.css +112 -0
  137. package/src/styles/components/form-wizard.css +11 -0
  138. package/src/styles/components/formview.css +74 -0
  139. package/src/styles/components/group-admin.css +302 -0
  140. package/src/styles/components/guardrail.css +29 -0
  141. package/src/styles/components/idioms.css +84 -0
  142. package/src/styles/components/image-editor.css +80 -0
  143. package/src/styles/components/loading.css +200 -0
  144. package/src/styles/components/location-address.css +32 -0
  145. package/src/styles/components/markdown.css +192 -0
  146. package/src/styles/components/multiselect-dropdown.css +92 -0
  147. package/src/styles/components/popover.css +35 -0
  148. package/src/styles/components/record-feed.css +161 -0
  149. package/src/styles/components/right-panel.css +37 -0
  150. package/src/styles/components/tabs.css +26 -0
  151. package/src/styles/components/taginput.css +132 -0
  152. package/src/styles/components/timepicker.css +282 -0
  153. package/src/styles/components/user-admin.css +189 -0
  154. package/src/styles/components/user-menu.css +98 -0
  155. package/src/styles/components/worldmap.css +97 -0
  156. package/src/styles/core.css +807 -0
  157. package/src/styles/index.css +69 -0
  158. package/src/styles/required-tokens.json +32 -0
  159. package/src/ui/FileField.tsx +12 -7
  160. package/src/ui/FormWizard.tsx +9 -3
  161. package/src/ui/ThemeProvider.tsx +1 -1
  162. package/src/ui/UserMenu.tsx +6 -2
  163. package/src/ui/detail/index.ts +2 -2
  164. package/src/ui/index.ts +4 -2
  165. package/src/ui/loading.tsx +1 -1
  166. package/src/ui/modal.tsx +76 -0
  167. package/src/ui/secret-dialog.tsx +83 -0
  168. package/src/ui/toast.tsx +21 -3
package/README.md CHANGED
@@ -9,6 +9,8 @@ npm install portal-mojo react react-dom react-router-dom @tanstack/react-query
9
9
  ```
10
10
 
11
11
  **Per-component reference docs (written for AI context): [`docs/`](docs/README.md).**
12
+ **Upgrading from 0.2:** raw calls now reject a flat HTTP-200 refusal — see
13
+ [Upgrading to 0.3](docs/client.md#upgrading-to-03).
12
14
  Live demos for everything: run `npm run dev:showcase` → **Develop → Components**.
13
15
 
14
16
  ## Subpath surfaces
@@ -63,18 +65,64 @@ are identical in both forms.
63
65
 
64
66
  ## What the consuming app must provide
65
67
 
66
- - **Theme tokens + component CSS.** Components render semantic classes
67
- (`panel`, `chip`, `tbl`, …) styled by the app's `theme.css` under
68
+ - **A CSS entry that imports the package stylesheet.** Components render
69
+ semantic classes (`panel`, `chip`, `tbl`, …). Their rules ship with the
70
+ package as `portal-mojo/styles.css`. In the app's Tailwind CSS entry, in
71
+ this order:
72
+
73
+ ```css
74
+ @import "tailwindcss";
75
+ @import "portal-mojo/styles.css";
76
+
77
+ :root { /* the app's tokens, light */ }
78
+ :root[data-theme="dark"] { /* the app's tokens, dark */ }
79
+ @theme inline { /* the app's own token-to-utility bridge, if it has one */ }
80
+
81
+ /* the app's own overrides, outside any layer */
82
+ ```
83
+
84
+ - **Import it from the CSS entry only**, never from TypeScript, and with
85
+ no `layer()` of your own. The file carries the package's Tailwind
86
+ `@source` and its own cascade layer. Both need the app's Tailwind pass,
87
+ and Tailwind rejects an `@source` inside a layered import.
88
+ - **No `@source` line for this package.** The stylesheet scans the
89
+ package's own markup, so utility classes used in package components are
90
+ generated without the app knowing the install path.
91
+ - **App rules always win.** Package rules sit in the `portal-mojo` cascade
92
+ layer, ordered after Tailwind's `utilities`. A rule the app writes
93
+ outside a layer beats a package rule whatever its specificity or order.
94
+ Package rules still beat Tailwind utilities.
95
+ - **`!important` is the exception.** For important declarations the layer
96
+ order runs backwards, so a package `!important` (a handful, in
97
+ `calendar`, `detailview`, `loading`, `admin-dns` and `core`) beats an
98
+ unlayered app `!important`. To override one, write the app rule with
99
+ `!important` inside one of Tailwind's layers, for example
100
+ `@layer components { … }`.
101
+ - **Theme tokens.** The package ships no token values. The app declares
102
+ every name in `src/styles/required-tokens.json`, under
68
103
  `data-theme="light"` **and** `"dark"` (reference set:
69
- `apps/portal/src/theme.css`).
70
- This includes the `.guardrail*` rules behind `confirmGuardrail`
71
- (reference: `apps/portal/src/theme/guardrail.css`) — the package ships no
72
- CSS, so an app that opens a guardrail must carry that block.
73
- - **Tailwind scan of this package.** In an installed app's CSS:
74
- `@source "../node_modules/portal-mojo/src";` (adjust the relative path from
75
- that CSS file). Utility classes used in package components are generated by
76
- the app's Tailwind pass. Monorepo development may scan
77
- `../../../packages/portal-mojo/src` instead.
104
+ `apps/portal/src/theme.css`). `npm run verify:package` keeps that list
105
+ equal to what the package styles read.
106
+
107
+ ### Migrating from copied theme files
108
+
109
+ Apps that copied the reference app's component CSS switch to the import:
110
+
111
+ 1. Delete the copied component files and the copied component body of
112
+ `theme.css` (everything that is not a token block or `@theme inline`).
113
+ 2. Keep the token blocks and `@theme inline`. Check them against
114
+ `src/styles/required-tokens.json`.
115
+ 3. Import `portal-mojo/styles.css` from the Tailwind entry, right after
116
+ `tailwindcss`, and drop any `@source` line that pointed at this package.
117
+ 4. Keep the app's own override files after it, outside any layer.
118
+ 5. Re-check every override. It now beats the package rule it targets
119
+ whatever its specificity or position, so an override that used to lose
120
+ now applies.
121
+
122
+ An app that keeps its old copies beside the import still renders: the
123
+ copies are unlayered and win. It just stops receiving package fixes for
124
+ those rules until the copies go.
125
+
78
126
  - **bootstrap-icons.** Components emit `bi bi-*` classes; the app imports the
79
127
  icon font once.
80
128
  - **Providers.** Every shell supplies `QueryClientProvider` (spread
@@ -73,7 +73,8 @@ receives a viewport-height limit; page/embedded bodies have no modal minimum
73
73
  or maximum. At narrow container widths an in-flow section button expands the
74
74
  same filtered rail entries, including rich, zero and dot badges. No native
75
75
  select stringification or second top-layer overlay is involved. The chrome
76
- lives in `theme/detailview.css` in both consuming apps.
76
+ lives in `packages/portal-mojo/src/styles/components/detailview.css` and ships in
77
+ `portal-mojo/styles.css`.
77
78
 
78
79
  ## Permission-gated sections (fail-closed)
79
80
 
@@ -191,7 +192,7 @@ get rows. See [Admin modal policy](admin-modals.md) for lifecycle exceptions.
191
192
  cache.
192
193
  - **Badges are state, not commands** — to "setBadge", change the value
193
194
  your `badges` prop is built from.
194
- - The styles for the C1 chrome live in `apps/portal/src/theme/detailview.css`
195
+ - The styles for the C1 chrome live in `packages/portal-mojo/src/styles/components/detailview.css`
195
196
  (`.rail-badge`, `.rail-dot`, `.dv-menu*`, `.dv-keep`) — tokens only,
196
197
  both themes.
197
198
 
package/docs/README.md CHANGED
@@ -35,6 +35,8 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
35
35
  | [admin-dns-certificates.md](admin-dns-certificates.md) | Certificate custody and ACME lifecycle — in-zone requests, delegation readiness, house gates, renewal-aware bounded polling, and the no-material boundary |
36
36
  | [admin-dns-registrar.md](admin-dns-registrar.md) | Domain search/quote/purchase, one-use token custody, durable-ledger reconciliation, scoped registrant PII, and manual House adoption |
37
37
  | [admin-storage.md](admin-storage.md) | Global S3 buckets, masked storage backends, explicit policy-backed File uploads, capability-safe FileView/shares, and finite rendition convergence |
38
+ | [admin-media-rendering.md](admin-media-rendering.md) | Storage → Media rendering: the three FILEMAN_RENDITIONS_* settings as a descriptor-driven form — delta-from-defaults writes, inline 400 routing, reset by writing `{}` |
39
+ | [admin-edge.md](admin-edge.md) | Edge vhosts by template kind with a shape-first wizard, `site_api` routes and their create order, declared upstreams, and the fleet blocklist with log-first modes. |
38
40
  | [admin-shortlinks.md](admin-shortlinks.md) | Global shortlink operations, destination-free caches, bounded click history, and reconciled one-shot mutations |
39
41
  | [admin-messaging.md](admin-messaging.md) | SES domains/mailboxes/sent/templates plus public contact requests, with write-only credentials and offline HTML previews |
40
42
  | [admin-phonehub.md](admin-phonehub.md) | Global Phone Hub: evidence-backed lookup, sanitized SMS audits, and write-only provider configuration |
@@ -52,6 +54,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
52
54
  | [loading.md](loading.md) | `Spinner`, `busy()`/`busyWhile()` blocking overlay, `ViewLoader`, `InlineLoader`, `Busy` — the anti-flash delay rule and skeleton-vs-loader guidance |
53
55
  | [popover.md](popover.md) | Anchored top-layer popover shell — placement, reposition, outside/Escape close, the dialog stacking story |
54
56
  | [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 |
57
+ | [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
58
  | [taginput.md](taginput.md) | Chip/tag entry — CSV wire shape, keyboard matrix, validation + inline errors |
56
59
  | [combobox.md](combobox.md) | The house autocomplete — options with descriptions/meta, commit-only change pipeline, allowCustom, ARIA |
57
60
  | [location-address.md](location-address.md) | Six-endpoint LocationClient, private provider sessions, stale guards, controlled AddressField, atomic form patching |
@@ -87,8 +90,10 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
87
90
  4. **Controlled inputs, one value pipeline.** A control can never display a
88
91
  value its state doesn't hold. Unknown option values fall back to a
89
92
  default WITH a `console.warn` — never to rendering nothing.
90
- 5. **Both themes, day one.** Style with tokens from `apps/portal/src/theme.css`
91
- (`--surface`, `--ink`, `--mute`, `--accent`, …) and verify under
93
+ 5. **Both themes, day one.** Component CSS lives in
94
+ `packages/portal-mojo/src/styles/`. Style with the app's tokens
95
+ (`--surface`, `--ink`, `--mute`, `--accent`, …; reference values in
96
+ `apps/portal/src/theme.css`) and verify under
92
97
  `data-theme="light"` AND `"dark"`.
93
98
  6. **django-mojo datetimes are epoch SECONDS** on the wire. `fmt.*` accepts
94
99
  them directly; do not `new Date(epochSeconds)` without ×1000.
@@ -0,0 +1,261 @@
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. Import `portal-mojo/styles.css` from the app's Tailwind CSS entry and
225
+ declare the colour tokens. `account.css`, `user-admin.css` (API-key rows)
226
+ and `group-admin.css` (`.ga-secret-*` token reveal) ship in it; there is
227
+ nothing to copy. The showcase still carries its own copies.
228
+ 3. Mount `ModalHost` + `ToastHost`, and a fresh-auth handler (apps/portal's
229
+ `FreshAuthHost`, registered through `setFreshAuthHandler`) inside the
230
+ authenticated tree. Without a handler a 440 just fails with the
231
+ fresh-auth copy.
232
+ 4. Pass `notificationKinds` for the kinds the app sends until the server
233
+ ships its registry (CamActive: alarms / security / controllers / digest).
234
+
235
+ ## Mock
236
+
237
+ - Every emailed / texted / authenticator code is **`123456`** (TOTP confirm
238
+ and regenerate, email change, phone change, verify); TOTP setup returns a
239
+ fixed secret; confirm answers 8 recovery codes.
240
+ - Seeded users that already require MFA (Maya, user 2) start **enrolled**;
241
+ everyone else gets the 400 not-enrolled answer until they set up.
242
+ - Passkey register begin/complete validate shape only (complete requires
243
+ `credential.id === rawId` and upserts a row); non-admin passkey reads are
244
+ owner-scoped.
245
+ - `sessions/revoke` and email-change confirm rotate `auth_key` (other mock
246
+ tokens die) and answer a fresh token pair.
247
+ - `armMockReauth('POST', '/api/auth/sessions/revoke')` (from
248
+ `portal-mojo/client`; any gated method + exact path) makes the next
249
+ matching authenticated call answer 440 once — the showcase Account demo
250
+ has a button for it.
251
+
252
+ ## Pitfalls
253
+
254
+ - Needs the app's QueryClient, router, `ThemeProvider`, `ModalHost`,
255
+ `ToastHost` and a fresh-auth handler (see Mounting).
256
+ - Don't add a second Sign out path that skips the app's `onSignOut` — Admin
257
+ must revoke its source session.
258
+ - `?user=<me.id>` on every list is load-bearing for admins: drop it and an
259
+ admin's own account shows everyone's passkeys / keys.
260
+ - Real-authenticator passkey registration is pending verification against a
261
+ live django-mojo (the mock checks shape only).
@@ -110,9 +110,9 @@ accepted it. No batch or action endpoints are invented, so partial failures use
110
110
  ## Styling and showcase
111
111
 
112
112
  The package emits semantic `bouncer-*` classes and imports no application CSS.
113
- Consumers must include `theme/admin-bouncer.css` after the shared token
114
- definitions. The repository keeps matching leaf files in `apps/portal` and
115
- `apps/showcase`, consistent with the current intentional theme duplication.
113
+ Its styles live in `src/styles/components/admin-bouncer.css` and ship in
114
+ `portal-mojo/styles.css`; the app declares the tokens. The showcase still
115
+ carries its own copy until it adopts the package stylesheet.
116
116
 
117
117
  The showcase's `AdminBouncerDemo` switches among the three real package pages
118
118
  so only one URL-backed top-level table is mounted at a time. Open signal/device
@@ -178,9 +178,10 @@ and confirmed rotation are explicit button actions.
178
178
  ## Styling contract
179
179
 
180
180
  Credential components render the existing semantic `ga-*` classes and never
181
- import an application stylesheet. A consuming portal or showcase must include
182
- the `group-admin.css` semantic rules in its theme entry, as the repository's
183
- `apps/portal/src/theme.css` and `apps/showcase/src/theme.css` do today.
181
+ import an application stylesheet. The `ga-*` rules ship in
182
+ `portal-mojo/styles.css`
183
+ (`packages/portal-mojo/src/styles/components/group-admin.css`); a consuming
184
+ app imports that one stylesheet from its Tailwind entry.
184
185
 
185
186
  ## Personal keys are separate
186
187
 
@@ -34,7 +34,7 @@ import {
34
34
  Account-security triage: who logged in from a new country, what is this IP,
35
35
  and is this device shared across accounts. Demo: showcase → Admin →
36
36
  **Devices, logins & GeoIP**. Styles:
37
- `apps/{portal,showcase}/src/theme/admin-devices.css` (byte-identical).
37
+ `packages/portal-mojo/src/styles/components/admin-devices.css`.
38
38
 
39
39
  ## Sections
40
40
 
@@ -107,8 +107,8 @@ sticky delegation, staging, unconfigured, and malformed-response evidence.
107
107
  Its only custody fixture is a private `material_present` boolean; no PEM is
108
108
  stored or serialized.
109
109
 
110
- Portal and Showcase both import `theme/admin-dns-certificates.css`, which uses
111
- semantic tokens only. The executable demo is **Develop → Components → DNS
110
+ `src/styles/components/admin-dns-certificates.css` ships in
111
+ `portal-mojo/styles.css` and uses semantic tokens only. The executable demo is **Develop → Components → DNS
112
112
  certificates & ACME** with manager, viewer, platform-house, and unconfigured
113
113
  legs. Run:
114
114
 
@@ -62,4 +62,4 @@ Confirmation shows identity, TTL, unchanged/removed/added values, and contextual
62
62
 
63
63
  The central mock is the only DNS state owner and mirrors id-less envelopes, global-versus-tenant authorization, active/provider/credential gates, complete replacement, Route 53 delete, GoDaddy floor/refusal/spent placeholders, Mojo, unknown records, and structured types. Mutation verification is mock-only. Live verification may read a zone and open/cancel an editor, but must never POST or DELETE DNS records.
64
64
 
65
- Both Portal and Showcase import `theme/admin-dns.css`; all colors use theme tokens. Run `npm run verify:admin-dns-records` with the orchestration-owned typecheck/build/browser pass.
65
+ `src/styles/components/admin-dns.css` ships in `portal-mojo/styles.css`; all colors use theme tokens. Run `npm run verify:admin-dns-records` with the orchestration-owned typecheck/build/browser pass.