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
@@ -0,0 +1,69 @@
1
+ @layer theme, base, components, utilities, portal-mojo;
2
+ /* portal-mojo/styles.css: every component style the package's markup needs.
3
+
4
+ - Import it from the app's Tailwind CSS entry, after `@import "tailwindcss"`,
5
+ and without a layer() of your own. The package layers itself.
6
+ - The `portal-mojo` layer sits after Tailwind's `utilities`, so package rules
7
+ still beat utilities, and any unlayered app rule beats the package.
8
+ - The `@source` below makes Tailwind scan the package's own markup, so the
9
+ app needs no `@source` line for portal-mojo.
10
+ - Colour tokens are the app's to declare; ./required-tokens.json lists them.
11
+ - Order matters: component files first, ./core.css last. */
12
+ @source "../";
13
+ @import "./components/popover.css" layer(portal-mojo);
14
+ @import "./components/taginput.css" layer(portal-mojo);
15
+ @import "./components/drawer.css" layer(portal-mojo);
16
+ @import "./components/combobox.css" layer(portal-mojo);
17
+ @import "./components/location-address.css" layer(portal-mojo);
18
+ @import "./components/collection-multiselect.css" layer(portal-mojo);
19
+ @import "./components/detail-primitives.css" layer(portal-mojo);
20
+ @import "./components/calendar.css" layer(portal-mojo);
21
+ @import "./components/markdown.css" layer(portal-mojo);
22
+ @import "./components/record-feed.css" layer(portal-mojo);
23
+ @import "./components/right-panel.css" layer(portal-mojo);
24
+ @import "./components/admin-assistant.css" layer(portal-mojo);
25
+ @import "./components/admin-security.css" layer(portal-mojo);
26
+ @import "./components/admin-dashboard.css" layer(portal-mojo);
27
+ @import "./components/admin-cloudwatch.css" layer(portal-mojo);
28
+ @import "./components/admin-monitoring.css" layer(portal-mojo);
29
+ @import "./components/admin-bouncer.css" layer(portal-mojo);
30
+ @import "./components/admin-incidents.css" layer(portal-mojo);
31
+ @import "./components/admin-rules.css" layer(portal-mojo);
32
+ @import "./components/admin-devices.css" layer(portal-mojo);
33
+ @import "./components/admin-jobs.css" layer(portal-mojo);
34
+ @import "./components/admin-network.css" layer(portal-mojo);
35
+ @import "./components/admin-dns.css" layer(portal-mojo);
36
+ @import "./components/admin-dns-certificates.css" layer(portal-mojo);
37
+ @import "./components/admin-dns-registrar.css" layer(portal-mojo);
38
+ @import "./components/admin-storage.css" layer(portal-mojo);
39
+ @import "./components/admin-media-rendering.css" layer(portal-mojo);
40
+ @import "./components/admin-edge.css" layer(portal-mojo);
41
+ @import "./components/admin-shortlinks.css" layer(portal-mojo);
42
+ @import "./components/admin-messaging.css" layer(portal-mojo);
43
+ @import "./components/admin-phonehub.css" layer(portal-mojo);
44
+ @import "./components/admin-push.css" layer(portal-mojo);
45
+ @import "./components/admin-signin.css" layer(portal-mojo);
46
+ @import "./components/multiselect-dropdown.css" layer(portal-mojo);
47
+ @import "./components/datepicker.css" layer(portal-mojo);
48
+ @import "./components/formview.css" layer(portal-mojo);
49
+ @import "./components/tabs.css" layer(portal-mojo);
50
+ @import "./components/form-wizard.css" layer(portal-mojo);
51
+ @import "./components/dataview.css" layer(portal-mojo);
52
+ @import "./components/detailview.css" layer(portal-mojo);
53
+ @import "./components/timepicker.css" layer(portal-mojo);
54
+ @import "./components/collection-select.css" layer(portal-mojo);
55
+ @import "./components/daterange.css" layer(portal-mojo);
56
+ @import "./components/datetimepicker.css" layer(portal-mojo);
57
+ @import "./components/auth.css" layer(portal-mojo);
58
+ @import "./components/idioms.css" layer(portal-mojo);
59
+ @import "./components/guardrail.css" layer(portal-mojo);
60
+ @import "./components/file-upload.css" layer(portal-mojo);
61
+ @import "./components/image-editor.css" layer(portal-mojo);
62
+ @import "./components/charts-c2.css" layer(portal-mojo);
63
+ @import "./components/group-admin.css" layer(portal-mojo);
64
+ @import "./components/user-admin.css" layer(portal-mojo);
65
+ @import "./components/loading.css" layer(portal-mojo);
66
+ @import "./components/worldmap.css" layer(portal-mojo);
67
+ @import "./components/user-menu.css" layer(portal-mojo);
68
+ @import "./components/account.css" layer(portal-mojo);
69
+ @import "./core.css" layer(portal-mojo);
@@ -0,0 +1,32 @@
1
+ {
2
+ "description": "Tokens a consuming app must declare for portal-mojo/styles.css: every custom property the package styles read with var() and no fallback, and do not declare themselves. Declare each one for light and for dark. Checked by scripts/style-contract.mjs, on the source and on the installed tarball.",
3
+ "required": [
4
+ "--accent",
5
+ "--accent-soft",
6
+ "--bad",
7
+ "--bad-soft",
8
+ "--bg",
9
+ "--field-bg",
10
+ "--field-line",
11
+ "--field-line-hover",
12
+ "--info",
13
+ "--info-soft",
14
+ "--ink",
15
+ "--ink-strong",
16
+ "--line",
17
+ "--line-soft",
18
+ "--mute",
19
+ "--ok",
20
+ "--ok-soft",
21
+ "--shadow",
22
+ "--shadow-pop",
23
+ "--sidebar-bg",
24
+ "--surface",
25
+ "--surface2",
26
+ "--warn",
27
+ "--warn-soft"
28
+ ],
29
+ "knownUndeclared": {
30
+ "--good": "Read by components/admin-push.css and declared by no app, so that rule's left border is dropped. Tracked as board item 6518; remove this entry with the fix."
31
+ }
32
+ }
@@ -99,13 +99,18 @@ export function FileField(props: FileFieldProps) {
99
99
  }
100
100
  }, [queue]);
101
101
 
102
- useEffect(() => () => {
103
- mounted.current = false;
104
- generation.current += 1;
105
- editController.current?.abort();
106
- revokeLocal();
107
- if (desired.current !== undefined && candidate.current != null) reportOrphan(candidate.current);
108
- queue.cancelAll();
102
+ useEffect(() => {
103
+ // Re-arm on (re)mount: StrictMode's probe unmount runs the cleanup
104
+ // below, and without this the remount stays "unmounted" forever.
105
+ mounted.current = true;
106
+ return () => {
107
+ mounted.current = false;
108
+ generation.current += 1;
109
+ editController.current?.abort();
110
+ revokeLocal();
111
+ if (desired.current !== undefined && candidate.current != null) reportOrphan(candidate.current);
112
+ queue.cancelAll();
113
+ };
109
114
  }, [queue, reportOrphan, revokeLocal]);
110
115
 
111
116
  const active = queue.snapshot.activeCount > 0 || queue.snapshot.queuedCount > 0;
@@ -145,9 +145,15 @@ export function FormWizard(props: FormWizardProps) {
145
145
  // eslint-disable-next-line react-hooks/exhaustive-deps
146
146
  }, [resetKey]);
147
147
 
148
- useEffect(() => () => {
149
- mounted.current = false;
150
- generation.current += 1;
148
+ useEffect(() => {
149
+ // StrictMode replays this effect in development; without re-arming,
150
+ // the replayed cleanup leaves the flag false for the real mount and
151
+ // every onNext/onFinish result is discarded with the wizard still busy.
152
+ mounted.current = true;
153
+ return () => {
154
+ mounted.current = false;
155
+ generation.current += 1;
156
+ };
151
157
  }, []);
152
158
 
153
159
  useEffect(() => {
@@ -1,6 +1,6 @@
1
1
  // ThemeManager port: the preference/resolved two-value model. Preference is
2
2
  // 'light' | 'dark' | 'system' (persisted); resolved is what's on screen.
3
- // Stamps data-theme on <html>; all tokens key off it (see theme.css).
3
+ // Stamps data-theme on <html>; all tokens key off it (the app's theme.css).
4
4
  import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
5
5
 
6
6
  export type ThemePref = 'light' | 'dark' | 'system';
@@ -96,8 +96,12 @@ function Avatar({ src, name, className, onError }: {
96
96
  );
97
97
  }
98
98
 
99
- /** Stored avatar capability for `avatarId`, fetched while mounted only. */
100
- function useAvatarUrl(avatarId: number | null): [string | null, () => void] {
99
+ /**
100
+ * Stored avatar capability for `avatarId`, fetched while mounted only and
101
+ * kept in component state (never a Query cache). Returns `[src, onError]`;
102
+ * wire `onError` to the <img> so a dead capability falls back to initials.
103
+ */
104
+ export function useAvatarUrl(avatarId: number | null): [string | null, () => void] {
101
105
  const [fetched, setFetched] = useState<{ id: number; src: string | null } | null>(null);
102
106
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
103
107
 
@@ -1,7 +1,7 @@
1
1
  // portal-mojo/ui — detail primitives: the small building blocks admin record
2
2
  // pages are composed from. Ported from web-mojo's src/core/views/data/*
3
- // primitives + the admin metadata section. Styles live in the consuming app
4
- // (apps/portal/src/theme/detail-primitives.css); docs in
3
+ // primitives + the admin metadata section. Styles ship with the package
4
+ // (src/styles/components/detail-primitives.css); docs in
5
5
  // packages/portal-mojo/docs/detail-primitives.md.
6
6
  export * from './StatusPanel';
7
7
  export * from './FlowStrip';
package/src/ui/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // portal-mojo/ui — mission-control UI: server tables, filters, schema forms,
2
2
  // detail views, native-<dialog> modals, toasts, theming. Components render
3
- // semantic classes (panel, chip, tbl, …) styled by the consuming app's
4
- // theme.css tokens — light AND dark (reference set: apps/portal/src/theme.css).
3
+ // semantic classes (panel, chip, tbl, …) styled by portal-mojo/styles.css
4
+ // from the consuming app's tokens — light AND dark (reference set:
5
+ // apps/portal/src/theme.css).
5
6
  export * from './ThemeProvider';
6
7
  export * from './Guarded';
7
8
  export * from './GroupSwitcher';
@@ -30,6 +31,7 @@ export * from './MultiSelectDropdown';
30
31
  export * as dateFns from './date/fns';
31
32
  export * from './detail';
32
33
  export * from './modal';
34
+ export * from './secret-dialog';
33
35
  export * from './safe-node';
34
36
  export * from './RouteError';
35
37
  export * from './route-error';
@@ -22,7 +22,7 @@
22
22
  // the same reasoning Popover.tsx uses, and the native inertness of a
23
23
  // modal dialog is what makes this actually block input
24
24
  // FIXED hardcoded #fff/#0d6efd/#e9ecef (light-theme only, unreadable in
25
- // dark) → tokens in apps/portal/src/theme/loading.css, both themes
25
+ // dark) → tokens in src/styles/components/loading.css, both themes
26
26
  // NEW the anti-flash `delay` + `minVisible` hold. web-mojo showed the
27
27
  // overlay on the same tick as the call, so every sub-100ms action
28
28
  // strobed. A spinner that appears for 80ms is worse than none.
package/src/ui/modal.tsx CHANGED
@@ -3,6 +3,7 @@
3
3
  // event handler; no JSX modal state to hoist. The z-index/backdrop stack
4
4
  // manager web-mojo needed does not exist here: <dialog> stacks natively.
5
5
  import { useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
6
+ import { getAuthSnapshot, onAuth, subscribeAuth } from '../client/auth';
6
7
 
7
8
  export type ModalSize = 'sm' | 'md' | 'lg';
8
9
 
@@ -63,6 +64,50 @@ function emit() {
63
64
  listeners.forEach((fn) => fn());
64
65
  }
65
66
 
67
+ /**
68
+ * Close EVERY open modal/drawer, resolving each await with null. Bypasses
69
+ * canDismiss — this is the session ending, not the user dismissing.
70
+ */
71
+ function closeAll(): void {
72
+ if (stack.length === 0) return;
73
+ const open = stack;
74
+ stack = [];
75
+ emit();
76
+ // resolve() is each item's settled-guarded close; the item is already
77
+ // gone, so a drawer's exit animation has nothing left to play.
78
+ for (const item of open) item.resolve(null);
79
+ }
80
+
81
+ // The stack is module state, so it outlives the host that renders it: an
82
+ // app that mounts <ModalHost> under its auth guard unmounts it on sign-out
83
+ // and REMOUNTS it on the next sign-in — every dialog body would come back,
84
+ // re-run its mount effects, for whoever signed in. A session ending (this
85
+ // tab's logout / unauthorized) or a change of identity seen through storage
86
+ // (another tab signing out or in as someone else) therefore empties it.
87
+ // Wired on the first open (never at import: no storage reads in non-DOM
88
+ // consumers of the package).
89
+ let authWired = false;
90
+ function wireAuth(): void {
91
+ if (authWired || typeof window === 'undefined') return;
92
+ authWired = true;
93
+ // Best-effort: a harness or host that stubs the auth module (or has no
94
+ // storage) must never stop a modal from opening.
95
+ try {
96
+ onAuth('logout', closeAll);
97
+ onAuth('unauthorized', closeAll);
98
+ let seenUid: string | null = null;
99
+ const observe = () => {
100
+ try {
101
+ const snap = getAuthSnapshot();
102
+ if (seenUid != null && (!snap.authenticated || snap.uid !== seenUid)) closeAll();
103
+ seenUid = snap.authenticated ? snap.uid : null;
104
+ } catch { /* auth unavailable: nothing to observe */ }
105
+ };
106
+ observe();
107
+ subscribeAuth(observe);
108
+ } catch { /* auth module stubbed or unavailable: modals still open */ }
109
+ }
110
+
66
111
  export interface ModalOptions {
67
112
  size?: ModalSize;
68
113
  flush?: boolean;
@@ -71,6 +116,7 @@ export interface ModalOptions {
71
116
  }
72
117
 
73
118
  function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOptions = {}): Promise<T | null> {
119
+ wireAuth();
74
120
  return new Promise<T | null>((resolve) => {
75
121
  const id = nextId++;
76
122
  let settled = false;
@@ -120,6 +166,7 @@ function drawerWidthPx(width: DrawerWidth | undefined): number {
120
166
  }
121
167
 
122
168
  function drawer<T = unknown>(opts: DrawerOptions<T>): Promise<T | null> {
169
+ wireAuth();
123
170
  const body = opts.render ?? (() => opts.content ?? null);
124
171
  let dismissable = opts.dismissable ?? true;
125
172
  if (!dismissable && !opts.render) {
@@ -169,6 +216,9 @@ function drawer<T = unknown>(opts: DrawerOptions<T>): Promise<T | null> {
169
216
  export const modal = {
170
217
  open,
171
218
 
219
+ /** Close every open modal and drawer; each await resolves null. */
220
+ closeAll,
221
+
172
222
  /** Detail modal: large, flush body — the Modal.detail() envelope. */
173
223
  detail(render: (close: (value: unknown) => void) => ReactNode): Promise<unknown> {
174
224
  return open(render, { size: 'lg', flush: true });
@@ -212,11 +262,29 @@ function ModalDialog({ item }: { item: ModalItem }) {
212
262
  item.resolve(null);
213
263
  };
214
264
 
265
+ // The native dialog can close WITHOUT a cancelable cancel: Chromium's
266
+ // second Escape with no click in between (close-watcher anti-abuse) and
267
+ // Android back fire a non-cancelable cancel, then close. A locked item
268
+ // reopens; an unlocked one settles like any other dismiss.
269
+ const onNativeClose = () => {
270
+ const dialog = ref.current;
271
+ if (!dialog || dialog.open || !stack.some((m) => m.id === item.id)) return; // unmount / already settled
272
+ if (item.canDismiss?.() === false) {
273
+ dialog.showModal();
274
+ if (!dialog.contains(document.activeElement)) {
275
+ (dialog.querySelector<HTMLElement>('[autofocus], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])') ?? dialog).focus();
276
+ }
277
+ return;
278
+ }
279
+ item.resolve(null);
280
+ };
281
+
215
282
  return (
216
283
  <dialog
217
284
  ref={ref}
218
285
  className={`mojo-modal mojo-modal-${item.size}${item.flush ? ' mojo-modal-flush' : ''}`}
219
286
  onCancel={(e) => { e.preventDefault(); dismiss(); }}
287
+ onClose={onNativeClose}
220
288
  onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
221
289
  >
222
290
  {item.flush && <button type="button" className="btn-icon modal-fallback-close" aria-label="Close" onClick={dismiss}><i className="bi bi-x-lg" /></button>}
@@ -238,6 +306,13 @@ function DrawerDialog({ item }: { item: DrawerItem }) {
238
306
  }, []);
239
307
 
240
308
  const dismiss = () => { if (item.dismissable) item.resolve(null); };
309
+ // Same non-cancelable close path as ModalDialog: a non-dismissable drawer reopens.
310
+ const onNativeClose = () => {
311
+ const el = ref.current;
312
+ if (!el || el.open || !stack.some((m) => m.id === item.id && m.variant === 'drawer' && !m.closing)) return;
313
+ if (!item.dismissable) { el.showModal(); return; }
314
+ item.resolve(null);
315
+ };
241
316
  const hasHead = item.dismissable || item.eyebrow != null || item.title != null || (item.meta?.length ?? 0) > 0;
242
317
 
243
318
  return (
@@ -247,6 +322,7 @@ function DrawerDialog({ item }: { item: DrawerItem }) {
247
322
  // React's CSSProperties has no index signature for custom props.
248
323
  style={{ '--drawer-w': `${item.width}px` } as CSSProperties}
249
324
  onCancel={(e) => { e.preventDefault(); dismiss(); }}
325
+ onClose={onNativeClose}
250
326
  onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
251
327
  >
252
328
  {/* The panel fills the dialog, so the dialog element is only ever
@@ -0,0 +1,83 @@
1
+ // showSecretDialog / SecretBox — the show-once secret reveal (API tokens,
2
+ // webhook secrets). Moved down from admin/credentials so non-admin surfaces
3
+ // (portal-mojo/account) can reveal a freshly minted token without importing
4
+ // an admin domain; admin/credentials re-exports it unchanged. Styled by the
5
+ // app's group-admin.css (.ga-secret-*).
6
+ import { useState, type ReactNode } from 'react';
7
+ import { modal } from './modal';
8
+
9
+ /** Clipboard write with the execCommand fallback for insecure contexts. */
10
+ export async function copyText(value: string): Promise<boolean> {
11
+ try {
12
+ if (navigator.clipboard?.writeText) {
13
+ await navigator.clipboard.writeText(value);
14
+ return true;
15
+ }
16
+ } catch {
17
+ // Fall through for insecure contexts.
18
+ }
19
+ try {
20
+ const input = document.createElement('textarea');
21
+ input.value = value;
22
+ input.style.position = 'fixed';
23
+ input.style.opacity = '0';
24
+ document.body.appendChild(input);
25
+ input.select();
26
+ const copied = document.execCommand('copy');
27
+ input.remove();
28
+ return copied;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function SecretBox({ secret, ariaLabel }: { secret: string; ariaLabel: string }) {
35
+ const [copied, setCopied] = useState(false);
36
+ return (
37
+ <div className="ga-secret-box">
38
+ <code className="ga-secret-value" aria-label={ariaLabel}>{secret}</code>
39
+ <button
40
+ type="button"
41
+ className="btn btn-compact"
42
+ title="Copy to clipboard"
43
+ aria-label="Copy to clipboard"
44
+ onClick={() => {
45
+ void copyText(secret).then((ok) => {
46
+ if (!ok) return;
47
+ setCopied(true);
48
+ setTimeout(() => setCopied(false), 1600);
49
+ });
50
+ }}
51
+ >
52
+ <i className={`bi ${copied ? 'bi-check-lg' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
53
+ </button>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export interface SecretDialogOptions {
59
+ title: string;
60
+ warning: ReactNode;
61
+ intro?: ReactNode;
62
+ secret: string;
63
+ ariaLabel: string;
64
+ footer?: ReactNode;
65
+ }
66
+
67
+ export function showSecretDialog(options: SecretDialogOptions): Promise<unknown> {
68
+ return modal.open((close) => (
69
+ <div className="modal-pad">
70
+ <h2 className="modal-title">{options.title}</h2>
71
+ {options.intro && <div className="ga-secret-intro">{options.intro}</div>}
72
+ <div className="ga-secret-warning" role="alert">
73
+ <i className="bi bi-exclamation-triangle-fill" />
74
+ <span>{options.warning}</span>
75
+ </div>
76
+ <SecretBox secret={options.secret} ariaLabel={options.ariaLabel} />
77
+ {options.footer}
78
+ <div className="modal-actions">
79
+ <button className="btn" onClick={() => close(null)}>Close</button>
80
+ </div>
81
+ </div>
82
+ ), { size: 'lg' });
83
+ }
package/src/ui/toast.tsx CHANGED
@@ -16,7 +16,7 @@
16
16
  // in-flight operation's only indicator.
17
17
  //
18
18
  // Visual spec from web-mojo's toast.css (left accent bar, compact card,
19
- // bottom-right); idiom styles live in the app's theme/idioms.css — tokens
19
+ // bottom-right); idiom styles live in src/styles/components/idioms.css — tokens
20
20
  // only, both themes.
21
21
  import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
22
22
  import { createPortal } from 'react-dom';
@@ -24,7 +24,8 @@ import { createPortal } from 'react-dom';
24
24
  type Level = 'success' | 'error' | 'info' | 'warning';
25
25
 
26
26
  interface BasicToast { id: number; kind: 'basic'; level: Level; message: string }
27
- interface UndoToastItem { id: number; kind: 'undo'; message: string; onUndo?: () => void }
27
+ /** The undo card; `label` renames its button (toast.action — a follow-up offer). */
28
+ interface UndoToastItem { id: number; kind: 'undo'; message: string; onUndo?: () => void; label?: string }
28
29
  interface ProgressToastItem {
29
30
  id: number;
30
31
  kind: 'progress';
@@ -129,6 +130,21 @@ export function progressToast(label: string, opts: { onCancel?: () => void } = {
129
130
  };
130
131
  }
131
132
 
133
+ /**
134
+ * A follow-up OFFER on the undo card's grammar: the message, one action
135
+ * button named `label` (click dismisses at once and runs `onAction`), and an
136
+ * auto-dismiss after `timeout` ms (default 10000) that leaves things as they are.
137
+ */
138
+ export function actionToast(message: string, label: string, onAction: () => void, opts: { timeout?: number } = {}): UndoToastHandle {
139
+ const id = nextId++;
140
+ toasts = [...toasts, { id, kind: 'undo', message, onUndo: onAction, label }];
141
+ emit();
142
+ const timer = setTimeout(() => remove(id), opts.timeout ?? 10_000);
143
+ return {
144
+ dismiss: () => { clearTimeout(timer); remove(id); },
145
+ };
146
+ }
147
+
132
148
  export const toast = {
133
149
  success: (m: string) => push('success', m),
134
150
  error: (m: string) => push('error', m),
@@ -139,6 +155,8 @@ export const toast = {
139
155
  undo: undoToast,
140
156
  /** Persistent progress card with a bar. See progressToast(). */
141
157
  progress: progressToast,
158
+ /** A message with one follow-up action button. See actionToast(). */
159
+ action: actionToast,
142
160
  };
143
161
 
144
162
  const ICONS: Record<Level, string> = {
@@ -250,7 +268,7 @@ export function ToastHost() {
250
268
  className="undo-btn"
251
269
  onClick={() => { remove(t.id); t.onUndo?.(); }}
252
270
  >
253
- Undo
271
+ {t.label ?? 'Undo'}
254
272
  </button>
255
273
  </div>
256
274
  );