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
|
@@ -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
|
+
}
|
package/src/ui/FileField.tsx
CHANGED
|
@@ -99,13 +99,18 @@ export function FileField(props: FileFieldProps) {
|
|
|
99
99
|
}
|
|
100
100
|
}, [queue]);
|
|
101
101
|
|
|
102
|
-
useEffect(() =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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;
|
package/src/ui/FormWizard.tsx
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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(() => {
|
package/src/ui/ThemeProvider.tsx
CHANGED
|
@@ -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 (
|
|
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';
|
package/src/ui/UserMenu.tsx
CHANGED
|
@@ -96,8 +96,12 @@ function Avatar({ src, name, className, onError }: {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
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
|
|
package/src/ui/detail/index.ts
CHANGED
|
@@ -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
|
|
4
|
-
// (
|
|
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
|
|
4
|
-
//
|
|
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';
|
package/src/ui/loading.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
);
|