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.
- package/README.md +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +7 -2
- package/docs/account.md +261 -0
- package/docs/admin-bouncer.md +3 -3
- package/docs/admin-credentials.md +4 -3
- package/docs/admin-devices-geoip.md +1 -1
- package/docs/admin-dns-certificates.md +2 -2
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-edge.md +210 -0
- package/docs/admin-identity-directories-sources.json +2 -2
- package/docs/admin-identity-directories.md +9 -0
- package/docs/admin-identity-users.md +2 -2
- package/docs/admin-jobs.md +4 -4
- package/docs/admin-media-rendering.md +142 -0
- package/docs/admin-modals.md +7 -0
- package/docs/admin-monitoring.md +4 -4
- package/docs/admin-network-security.md +2 -2
- package/docs/admin-phonehub.md +4 -1
- package/docs/admin-storage.md +5 -2
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/calendar.md +1 -1
- package/docs/client.md +159 -26
- package/docs/collection-multiselect.md +2 -2
- package/docs/combobox.md +2 -2
- package/docs/dataview.md +1 -1
- package/docs/datepicker.md +3 -3
- package/docs/daterange.md +1 -1
- package/docs/datetimepicker.md +1 -1
- package/docs/defineModel.md +40 -7
- package/docs/detail-primitives.md +1 -1
- package/docs/feedback.md +18 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +13 -1
- package/docs/loading.md +2 -2
- package/docs/location-address.md +2 -1
- package/docs/markdown.md +3 -3
- package/docs/menus-and-access.md +21 -1
- package/docs/multiselect-dropdown.md +1 -1
- package/docs/popover.md +1 -1
- package/docs/record-feed.md +2 -2
- package/docs/taginput.md +2 -1
- package/docs/timepicker.md +1 -1
- package/docs/timezone-select.md +4 -3
- package/docs/user-menu.md +4 -3
- package/docs/worldmap.md +2 -2
- package/package.json +4 -2
- package/src/account/AccountModal.tsx +276 -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/core/index.ts +1 -1
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/domains/infrastructure.ts +12 -2
- package/src/admin/domains/security.ts +3 -1
- package/src/admin/edge/BlocklistPage.tsx +110 -0
- package/src/admin/edge/UpstreamsPage.tsx +113 -0
- package/src/admin/edge/VhostDetail.tsx +166 -0
- package/src/admin/edge/VhostWizard.tsx +273 -0
- package/src/admin/edge/VhostsPage.tsx +47 -0
- package/src/admin/edge/api.ts +56 -0
- package/src/admin/edge/index.ts +15 -0
- package/src/admin/edge/models.ts +395 -0
- package/src/admin/edge/permissions.ts +13 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
- 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 +10 -103
- package/src/admin/index.ts +1 -0
- package/src/admin/phonehub/api.ts +5 -3
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/registry.ts +2 -1
- package/src/admin/storage/MediaRenderingPage.tsx +258 -0
- package/src/admin/storage/file-renditions.ts +12 -0
- package/src/admin/storage/index.ts +2 -0
- package/src/admin/storage/rendition-settings.ts +264 -0
- package/src/charts/exportChart.ts +1 -1
- package/src/client/action-result.ts +23 -61
- package/src/client/active-group.ts +9 -3
- package/src/client/auth.ts +207 -6
- package/src/client/client.ts +49 -10
- package/src/client/errors.ts +57 -2
- package/src/client/group-context.ts +2 -0
- package/src/client/group.tsx +112 -20
- package/src/client/hooks.ts +7 -3
- package/src/client/mock.ts +1429 -20
- package/src/client/model.ts +15 -3
- package/src/client/renditions-mock.ts +212 -0
- package/src/client/scoped.ts +19 -5
- package/src/styles/components/account.css +304 -0
- package/src/styles/components/admin-assistant.css +28 -0
- package/src/styles/components/admin-bouncer.css +53 -0
- package/src/styles/components/admin-cloudwatch.css +23 -0
- package/src/styles/components/admin-dashboard.css +16 -0
- package/src/styles/components/admin-devices.css +260 -0
- package/src/styles/components/admin-dns-certificates.css +28 -0
- package/src/styles/components/admin-dns-registrar.css +1 -0
- package/src/styles/components/admin-dns.css +43 -0
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-incidents.css +14 -0
- package/src/styles/components/admin-jobs.css +237 -0
- package/src/styles/components/admin-media-rendering.css +38 -0
- package/src/styles/components/admin-messaging.css +3 -0
- package/src/styles/components/admin-monitoring.css +152 -0
- package/src/styles/components/admin-network.css +406 -0
- package/src/styles/components/admin-phonehub.css +61 -0
- package/src/styles/components/admin-push.css +30 -0
- package/src/styles/components/admin-rules.css +23 -0
- package/src/styles/components/admin-security.css +37 -0
- package/src/styles/components/admin-shortlinks.css +8 -0
- package/src/styles/components/admin-signin.css +64 -0
- package/src/styles/components/admin-storage.css +4 -0
- package/src/styles/components/auth.css +162 -0
- package/src/styles/components/calendar.css +228 -0
- package/src/styles/components/charts-c2.css +249 -0
- package/src/styles/components/collection-multiselect.css +114 -0
- package/src/styles/components/collection-select.css +63 -0
- package/src/styles/components/combobox.css +42 -0
- package/src/styles/components/dataview.css +139 -0
- package/src/styles/components/datepicker.css +106 -0
- package/src/styles/components/daterange.css +119 -0
- package/src/styles/components/datetimepicker.css +230 -0
- package/src/styles/components/detail-primitives.css +246 -0
- package/src/styles/components/detailview.css +69 -0
- package/src/styles/components/drawer.css +111 -0
- package/src/styles/components/file-upload.css +112 -0
- package/src/styles/components/form-wizard.css +11 -0
- package/src/styles/components/formview.css +74 -0
- package/src/styles/components/group-admin.css +302 -0
- package/src/styles/components/guardrail.css +29 -0
- package/src/styles/components/idioms.css +84 -0
- package/src/styles/components/image-editor.css +80 -0
- package/src/styles/components/loading.css +200 -0
- package/src/styles/components/location-address.css +32 -0
- package/src/styles/components/markdown.css +192 -0
- package/src/styles/components/multiselect-dropdown.css +92 -0
- package/src/styles/components/popover.css +35 -0
- package/src/styles/components/record-feed.css +161 -0
- package/src/styles/components/right-panel.css +37 -0
- package/src/styles/components/tabs.css +26 -0
- package/src/styles/components/taginput.css +132 -0
- package/src/styles/components/timepicker.css +282 -0
- package/src/styles/components/user-admin.css +189 -0
- package/src/styles/components/user-menu.css +98 -0
- package/src/styles/components/worldmap.css +97 -0
- package/src/styles/core.css +807 -0
- package/src/styles/index.css +69 -0
- package/src/styles/required-tokens.json +32 -0
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +4 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- 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
|
-
- **
|
|
67
|
-
(`panel`, `chip`, `tbl`, …)
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
package/docs/DetailView.md
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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.**
|
|
91
|
-
|
|
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.
|
package/docs/account.md
ADDED
|
@@ -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).
|
package/docs/admin-bouncer.md
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
182
|
-
|
|
183
|
-
`
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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.
|