portal-mojo 0.2.8 → 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 (149) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +6 -2
  4. package/docs/account.md +4 -3
  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/calendar.md +1 -1
  22. package/docs/client.md +159 -26
  23. package/docs/collection-multiselect.md +2 -2
  24. package/docs/combobox.md +2 -2
  25. package/docs/dataview.md +1 -1
  26. package/docs/datepicker.md +3 -3
  27. package/docs/daterange.md +1 -1
  28. package/docs/datetimepicker.md +1 -1
  29. package/docs/defineModel.md +40 -7
  30. package/docs/detail-primitives.md +1 -1
  31. package/docs/feedback.md +9 -8
  32. package/docs/file-upload-ux.md +2 -2
  33. package/docs/idioms.md +1 -1
  34. package/docs/loading.md +2 -2
  35. package/docs/location-address.md +2 -1
  36. package/docs/markdown.md +3 -3
  37. package/docs/menus-and-access.md +21 -1
  38. package/docs/multiselect-dropdown.md +1 -1
  39. package/docs/popover.md +1 -1
  40. package/docs/record-feed.md +2 -2
  41. package/docs/taginput.md +2 -1
  42. package/docs/timepicker.md +1 -1
  43. package/docs/timezone-select.md +4 -3
  44. package/docs/user-menu.md +4 -3
  45. package/docs/worldmap.md +2 -2
  46. package/package.json +3 -2
  47. package/src/account/AccountModal.tsx +3 -2
  48. package/src/account/index.ts +2 -2
  49. package/src/admin/core/index.ts +1 -1
  50. package/src/admin/dns/models.ts +1 -1
  51. package/src/admin/domains/infrastructure.ts +12 -2
  52. package/src/admin/domains/security.ts +3 -1
  53. package/src/admin/edge/BlocklistPage.tsx +110 -0
  54. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  55. package/src/admin/edge/VhostDetail.tsx +166 -0
  56. package/src/admin/edge/VhostWizard.tsx +273 -0
  57. package/src/admin/edge/VhostsPage.tsx +47 -0
  58. package/src/admin/edge/api.ts +56 -0
  59. package/src/admin/edge/index.ts +15 -0
  60. package/src/admin/edge/models.ts +395 -0
  61. package/src/admin/edge/permissions.ts +13 -0
  62. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  63. package/src/admin/identity/users/sections/actions.tsx +3 -3
  64. package/src/admin/index.ts +1 -0
  65. package/src/admin/phonehub/api.ts +5 -3
  66. package/src/admin/public/infrastructure.ts +1 -0
  67. package/src/admin/registry.ts +2 -1
  68. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  69. package/src/admin/storage/file-renditions.ts +12 -0
  70. package/src/admin/storage/index.ts +2 -0
  71. package/src/admin/storage/rendition-settings.ts +264 -0
  72. package/src/charts/exportChart.ts +1 -1
  73. package/src/client/action-result.ts +23 -61
  74. package/src/client/active-group.ts +9 -3
  75. package/src/client/auth.ts +18 -4
  76. package/src/client/client.ts +49 -10
  77. package/src/client/errors.ts +57 -2
  78. package/src/client/group-context.ts +2 -0
  79. package/src/client/group.tsx +112 -20
  80. package/src/client/hooks.ts +7 -3
  81. package/src/client/mock.ts +958 -14
  82. package/src/client/model.ts +15 -3
  83. package/src/client/renditions-mock.ts +212 -0
  84. package/src/client/scoped.ts +19 -5
  85. package/src/styles/components/account.css +304 -0
  86. package/src/styles/components/admin-assistant.css +28 -0
  87. package/src/styles/components/admin-bouncer.css +53 -0
  88. package/src/styles/components/admin-cloudwatch.css +23 -0
  89. package/src/styles/components/admin-dashboard.css +16 -0
  90. package/src/styles/components/admin-devices.css +260 -0
  91. package/src/styles/components/admin-dns-certificates.css +28 -0
  92. package/src/styles/components/admin-dns-registrar.css +1 -0
  93. package/src/styles/components/admin-dns.css +43 -0
  94. package/src/styles/components/admin-edge.css +1 -0
  95. package/src/styles/components/admin-incidents.css +14 -0
  96. package/src/styles/components/admin-jobs.css +237 -0
  97. package/src/styles/components/admin-media-rendering.css +38 -0
  98. package/src/styles/components/admin-messaging.css +3 -0
  99. package/src/styles/components/admin-monitoring.css +152 -0
  100. package/src/styles/components/admin-network.css +406 -0
  101. package/src/styles/components/admin-phonehub.css +61 -0
  102. package/src/styles/components/admin-push.css +30 -0
  103. package/src/styles/components/admin-rules.css +23 -0
  104. package/src/styles/components/admin-security.css +37 -0
  105. package/src/styles/components/admin-shortlinks.css +8 -0
  106. package/src/styles/components/admin-signin.css +64 -0
  107. package/src/styles/components/admin-storage.css +4 -0
  108. package/src/styles/components/auth.css +162 -0
  109. package/src/styles/components/calendar.css +228 -0
  110. package/src/styles/components/charts-c2.css +249 -0
  111. package/src/styles/components/collection-multiselect.css +114 -0
  112. package/src/styles/components/collection-select.css +63 -0
  113. package/src/styles/components/combobox.css +42 -0
  114. package/src/styles/components/dataview.css +139 -0
  115. package/src/styles/components/datepicker.css +106 -0
  116. package/src/styles/components/daterange.css +119 -0
  117. package/src/styles/components/datetimepicker.css +230 -0
  118. package/src/styles/components/detail-primitives.css +246 -0
  119. package/src/styles/components/detailview.css +69 -0
  120. package/src/styles/components/drawer.css +111 -0
  121. package/src/styles/components/file-upload.css +112 -0
  122. package/src/styles/components/form-wizard.css +11 -0
  123. package/src/styles/components/formview.css +74 -0
  124. package/src/styles/components/group-admin.css +302 -0
  125. package/src/styles/components/guardrail.css +29 -0
  126. package/src/styles/components/idioms.css +84 -0
  127. package/src/styles/components/image-editor.css +80 -0
  128. package/src/styles/components/loading.css +200 -0
  129. package/src/styles/components/location-address.css +32 -0
  130. package/src/styles/components/markdown.css +192 -0
  131. package/src/styles/components/multiselect-dropdown.css +92 -0
  132. package/src/styles/components/popover.css +35 -0
  133. package/src/styles/components/record-feed.css +161 -0
  134. package/src/styles/components/right-panel.css +37 -0
  135. package/src/styles/components/tabs.css +26 -0
  136. package/src/styles/components/taginput.css +132 -0
  137. package/src/styles/components/timepicker.css +282 -0
  138. package/src/styles/components/user-admin.css +189 -0
  139. package/src/styles/components/user-menu.css +98 -0
  140. package/src/styles/components/worldmap.css +97 -0
  141. package/src/styles/core.css +807 -0
  142. package/src/styles/index.css +69 -0
  143. package/src/styles/required-tokens.json +32 -0
  144. package/src/ui/FormWizard.tsx +9 -3
  145. package/src/ui/ThemeProvider.tsx +1 -1
  146. package/src/ui/detail/index.ts +2 -2
  147. package/src/ui/index.ts +3 -2
  148. package/src/ui/loading.tsx +1 -1
  149. package/src/ui/toast.tsx +1 -1
@@ -140,7 +140,7 @@ converging it onto flat sections is a named follow-up.
140
140
  ## Group context (A3)
141
141
 
142
142
  Product portals place `<GroupProvider>` at the app root. `useActiveGroup() → {group, member,
143
- loading, setActiveGroup, clearActiveGroup}`. The active group id rides the
143
+ loading, accessDenied, setActiveGroup, clearActiveGroup}`. The active group id rides the
144
144
  REAL `?group=` search param (hash-safe, deep-linkable) with localStorage
145
145
  fallback; activating a group fetches the caller's member record
146
146
  (`/api/group/<id>/member`) whose permissions feed `useCan`.
@@ -148,3 +148,23 @@ fallback; activating a group fetches the caller's member record
148
148
  embedded-parent rows, kind chips); `RequiresGroup` wraps pages that need a
149
149
  group and shows the pick-a-group state otherwise. Do not install these in the
150
150
  standalone Admin shell; they belong to group-scoped product navigation.
151
+
152
+ A cached group and membership remain resolved during network errors and server
153
+ 5xx responses. The URL, stored selection and active-group signal stay in place.
154
+ An unresolved initial group still follows the URL/stored fallback chain.
155
+ Group 401/403/404 suppress the cached group immediately and run that fallback;
156
+ membership 401/403/404 keep the group identity but expose `member: null` and
157
+ `accessDenied: true`. These definitive denials do not retry.
158
+ Once denied, a later network failure or selecting an old switcher row cannot
159
+ restore cached grants. Recovery requires successful guarded group and member
160
+ reads; membership-only denial requires a successful member read.
161
+
162
+ A private workspace must treat `accessDenied === true` as a boundary: cancel
163
+ pending work, hide cached private results and remove temporary preview URLs.
164
+ Removing `member` only removes member-derived grants; user/system grants may
165
+ still satisfy `useCan`. This flag is not a loading state. Global Admin clears
166
+ it along with the group/member context. The server checks every request.
167
+ Provider requests check their candidate group, selection generation and authenticated actor both
168
+ before dispatch and after resolving, and consume TanStack's cancellation signal.
169
+ Provider teardown invalidates pending request closures even if another query
170
+ observer keeps their cache key alive.
@@ -107,7 +107,7 @@ element the user just clicked.
107
107
  construction.
108
108
  3. `null`/`undefined` are the only "nothing selected" — `0` and `''` are
109
109
  selections.
110
- 4. Both themes, tokens only: `apps/portal/src/theme/multiselect-dropdown.css`
110
+ 4. Both themes, tokens only: `packages/portal-mojo/src/styles/components/multiselect-dropdown.css`
111
111
  (reuses `.tbl-check`, `.btn`, `.field-*`; the popover shell supplies the
112
112
  surface/border/shadow).
113
113
 
package/docs/popover.md CHANGED
@@ -101,7 +101,7 @@ it (source parity).
101
101
 
102
102
  ## Styling
103
103
 
104
- `.mojo-popover` lives in `apps/portal/src/theme/popover.css` — tokens
104
+ `.mojo-popover` lives in `packages/portal-mojo/src/styles/components/popover.css` — tokens
105
105
  only, both themes. It neutralizes the UA `[popover]` stylesheet
106
106
  (`inset: 0` centering, `margin: auto`, border, padding, Canvas colors)
107
107
  and restores the mission-control look (`.filter-menu` family: `--surface`
@@ -221,8 +221,8 @@ surfaces local rejection through the same alert region.
221
221
 
222
222
  ## Styling
223
223
 
224
- Import `theme/record-feed.css` after the token declarations (the portal and
225
- showcase theme aggregators already do). It uses only the mission-control
224
+ `src/styles/components/record-feed.css` ships in `portal-mojo/styles.css`;
225
+ the app declares the tokens. It uses only the mission-control
226
226
  tokens and supports both `data-theme` values. Override the default bounded
227
227
  height with `--record-feed-height` on a wrapper/component class.
228
228
 
package/docs/taginput.md CHANGED
@@ -99,7 +99,8 @@ containing commas that would re-split on the next load.
99
99
 
100
100
  ## Styling
101
101
 
102
- Classes are styled by the consuming app (`apps/portal/src/theme/taginput.css`,
102
+ Classes are styled by the package stylesheet
103
+ (`packages/portal-mojo/src/styles/components/taginput.css`,
103
104
  tokens only, light + dark): `.tag-input`, `.tag-input-wrap` (wears the `.input`
104
105
  field look, accent ring on `:focus-within`), `.tag-chip` / `.tag-chip-text` /
105
106
  `.tag-chip-x`, `.tag-input-field`, `.tag-input-foot` / `.tag-input-count` /
@@ -150,7 +150,7 @@ zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `'UTC'` on failure)
150
150
 
151
151
  ## Styling
152
152
 
153
- `apps/portal/src/theme/timepicker.css` — `mojo-time-*` anatomy only
153
+ `packages/portal-mojo/src/styles/components/timepicker.css` — `mojo-time-*` anatomy only
154
154
  (`trigger` / `trigger-text` / `trigger-clear` / `popover-inner` /
155
155
  `picker-inline` / `stepper*` / `ampm*` / `tz-host` / `foot`), tokens only,
156
156
  both themes. The trigger wears the house `.input` look so it lines up in a
@@ -140,8 +140,9 @@ be rendering a plain `<select>` fallback in that slot; swapping it for
140
140
  reachable on engines without `Intl.supportedValuesOf`.
141
141
  - An empty `timezones={[]}` means "no override" (source parity), not "no
142
142
  zones".
143
- - No stylesheet ships: the wrapper `.tz-select` is an unstyled hook and every
144
- pixel comes from `apps/portal/src/theme/combobox.css`, which consuming apps
145
- must include.
143
+ - No stylesheet of its own: the wrapper `.tz-select` is an unstyled hook and
144
+ every pixel comes from
145
+ `packages/portal-mojo/src/styles/components/combobox.css`, which ships in
146
+ `portal-mojo/styles.css`.
146
147
  - The dropdown is ComboBox's locally-positioned list — inside
147
148
  `overflow:hidden` containers it clips (MERGE-WIRE: Popover primitive #1271).
package/docs/user-menu.md CHANGED
@@ -90,8 +90,8 @@ interface UserMenuItem {
90
90
  (state is keyed by avatar id).
91
91
  - No avatar request while signed out or when `avatar` is null.
92
92
  - Popover is controlled; outside-mousedown closes without moving focus.
93
- - Tokens only — `apps/portal/src/theme/user-menu.css`; both themes from the
94
- token swap. Reuses `.seg`/`.seg-btn`, `.chip-primary`, `.spin`,
93
+ - Tokens only — `src/styles/components/user-menu.css`, shipped in
94
+ `portal-mojo/styles.css`; both themes from the token swap. Reuses `.seg`/`.seg-btn`, `.chip-primary`, `.spin`,
95
95
  `.btn-primary`/`.btn-compact`, `.chip-muted`.
96
96
 
97
97
  ## Signed-out matrix
@@ -128,4 +128,5 @@ menu. Theme picks apply immediately and keep the menu open.
128
128
  `ThemeProvider` for the theme control; `ToastHost` for the failure toast.
129
129
  - Don't also render a theme toggle button in the same bar — the menu owns it.
130
130
  - `onSignOut` that never settles leaves Sign out pending; resolve or reject.
131
- - Apps must ship `theme/user-menu.css` (the package ships no CSS).
131
+ - Apps must import `portal-mojo/styles.css` and declare the tokens; the
132
+ UserMenu styles ship in it.
package/docs/worldmap.md CHANGED
@@ -298,5 +298,5 @@ round-trip, clamping, fitting, antimeridian splitting), the centroid table
298
298
  (244 unique keys, coordinate ranges, `ES` = Spain), the binding helpers
299
299
  (slug parsing, sizing, sorting/top-N/drops), tone fallbacks and their warns,
300
300
  the land normalizer, and the source invariants above (no network, no map
301
- library, no HTML-string tooltips, tokens-only CSS, and the two theme copies
302
- staying byte-identical).
301
+ library, no HTML-string tooltips, tokens-only CSS, and the package
302
+ stylesheet importing `worldmap.css`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -42,7 +42,8 @@
42
42
  "./admin/infrastructure": "./src/admin/public/infrastructure.ts",
43
43
  "./admin/communications": "./src/admin/public/communications.ts",
44
44
  "./admin/assistant": "./src/admin/public/assistant.ts",
45
- "./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts"
45
+ "./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts",
46
+ "./styles.css": "./src/styles/index.css"
46
47
  },
47
48
  "scripts": {
48
49
  "typecheck": "tsc --noEmit"
@@ -11,8 +11,9 @@
11
11
  //
12
12
  // Every focused flow (add passkey, authenticator, recovery codes, password,
13
13
  // email/phone change, verify, photo) stacks its own dialog over this one;
14
- // fresh-auth step-ups come from the app's FreshAuthHost. Styles: the app's
15
- // theme/account.css (tokens only, both themes).
14
+ // fresh-auth step-ups come from the app's FreshAuthHost. Styles:
15
+ // styles/components/account.css, shipped in portal-mojo/styles.css (tokens
16
+ // only, both themes).
16
17
  import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
17
18
  import { hostedAuthUrl, logout, onAuth, redirectToHostedAuth, useAuthSnapshot, useMe, type Me } from '../client/runtime';
18
19
  import { fmt, modal, toast, useAvatarUrl, type UserMenuAuthMode } from '../ui';
@@ -4,8 +4,8 @@
4
4
  // portal-mojo/admin unchanged), MeSaveModel, and the self-service wire
5
5
  // functions. A page bundle bound to django-mojo contracts like an admin
6
6
  // domain, but it never imports the admin barrel and runs no setup at import.
7
- // Apps ship theme/account.css (plus user-admin.css and group-admin.css for
8
- // the reused API-key rows and secret reveal).
7
+ // Its styles ship in portal-mojo/styles.css: account.css, plus user-admin.css
8
+ // and group-admin.css for the reused API-key rows and secret reveal.
9
9
  export * from './AccountModal';
10
10
  export * from './AccountSections';
11
11
  export * from './api';
@@ -146,7 +146,7 @@ function guardedElement(section: AdminSection, route: AdminRoute, sections: read
146
146
  export function AdminGlobalScope({ children }: { children: ReactNode }) {
147
147
  const outer = useContext(GroupContext);
148
148
  if (!outer) return children;
149
- return createElement(GroupContext.Provider, { value: { ...outer, group: null, member: null, loading: false }, children });
149
+ return createElement(GroupContext.Provider, { value: { ...outer, group: null, member: null, loading: false, accessDenied: false }, children });
150
150
  }
151
151
 
152
152
  function routeVisible(section: AdminSection, route: AdminRoute, me: ReturnType<typeof useMe>['data']): boolean {
@@ -355,7 +355,7 @@ export function normalizeCredentialListParams(params: Params): Params {
355
355
  export function normalizeDomainListParams(params: Params): Params {
356
356
  return normalizeListParams(
357
357
  params,
358
- new Set(['group', 'provider', 'status']),
358
+ new Set(['group', 'group__isnull', 'provider', 'status']),
359
359
  new Set(['name', 'provider', 'status', 'expires', 'created']),
360
360
  'list',
361
361
  );
@@ -1,6 +1,7 @@
1
1
  import type { AdminSection } from '../core';
2
2
  import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS } from '../dns/models';
3
3
  import { BUCKET_MANAGE_PERMS, STORAGE_VIEW_PERMS } from '../storage/permissions';
4
+ import { SETTINGS_PERMISSIONS } from '../settings/model';
4
5
 
5
6
  export const DNS_ADMIN_SECTION: AdminSection = {
6
7
  id: 'dns', title: 'DNS', icon: 'bi-globe2', navigationGroup: 'infrastructure', basePath: 'dns', permissions: DNS_VIEW_PERMISSIONS,
@@ -14,11 +15,20 @@ export const DNS_ADMIN_SECTION: AdminSection = {
14
15
  ],
15
16
  };
16
17
  export const STORAGE_ADMIN_SECTION: AdminSection = {
17
- id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: 'infrastructure', permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files'],
18
+ id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: 'infrastructure', permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files', ...SETTINGS_PERMISSIONS],
18
19
  routes: [
19
20
  { path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(({ BucketsPage }) => ({ default: BucketsPage })), permissions: BUCKET_MANAGE_PERMS },
20
21
  { path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(({ BackendsPage }) => ({ default: BackendsPage })), permissions: STORAGE_VIEW_PERMS },
21
22
  { path: 'files', label: 'Files', loadComponent: () => import('../storage/FilesPage').then(({ FilesPage }) => ({ default: FilesPage })), permissions: STORAGE_VIEW_PERMS },
23
+ // Media rendering edits three Setting rows, so it carries the Settings page's gate, not the storage one.
24
+ { path: 'rendering', label: 'Media rendering', loadComponent: () => import('../storage/MediaRenderingPage').then(({ MediaRenderingPage }) => ({ default: MediaRenderingPage })), permissions: SETTINGS_PERMISSIONS },
22
25
  ],
23
26
  };
24
- export const INFRASTRUCTURE_ADMIN_SECTIONS = [DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION] as const;
27
+ export const EDGE_ADMIN_SECTION: AdminSection = {
28
+ id: 'edge', basePath: 'edge', title: 'Edge', icon: 'bi-hdd-network', navigationGroup: 'infrastructure', permissions: DNS_VIEW_PERMISSIONS,
29
+ routes: [
30
+ { path: 'vhosts', label: 'Vhosts', loadComponent: () => import('../edge/VhostsPage').then(({ VhostsPage }) => ({ default: VhostsPage })), permissions: DNS_VIEW_PERMISSIONS },
31
+ { path: 'upstreams', label: 'Upstreams', loadComponent: () => import('../edge/UpstreamsPage').then(({ UpstreamsPage }) => ({ default: UpstreamsPage })), permissions: DNS_VIEW_PERMISSIONS },
32
+ ],
33
+ };
34
+ export const INFRASTRUCTURE_ADMIN_SECTIONS = [DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION, EDGE_ADMIN_SECTION] as const;
@@ -1,5 +1,6 @@
1
1
  import type { AdminRoute, AdminSection } from '../core';
2
2
  import { BOUNCER_VIEW_PERMS } from '../bouncer/models';
3
+ import { EDGE_BLOCKLIST_VIEW_PERMS } from '../edge/permissions';
3
4
  import { LOGS_ADMIN_PERMISSIONS } from '../monitoring/models';
4
5
  import { GEOIP_VIEW_PERMS } from '../security/geoip/models';
5
6
  import { LOGIN_EVENT_VIEW_PERMS, USER_DEVICE_VIEW_PERMS } from '../security/devices/models';
@@ -43,12 +44,13 @@ export const GEOIP_ADMIN_SECTION: AdminSection = {
43
44
  };
44
45
  export const NETWORK_SECURITY_ADMIN_SECTION: AdminSection = {
45
46
  id: 'network-security', basePath: 'security/network', title: 'Network Security', icon: 'bi-hdd-network', navigationGroup: 'security',
46
- permissions: [...new Set([...GEOIP_VIEW_PERMS, ...LOGS_ADMIN_PERMISSIONS, ...IPSET_VIEW_PERMS, ...GEOFENCE_VIEW_PERMS])],
47
+ permissions: [...new Set([...GEOIP_VIEW_PERMS, ...LOGS_ADMIN_PERMISSIONS, ...IPSET_VIEW_PERMS, ...GEOFENCE_VIEW_PERMS, ...EDGE_BLOCKLIST_VIEW_PERMS])],
47
48
  routes: [
48
49
  { path: 'blocked-ips', label: 'Blocked IPs', loadComponent: () => import('../network/BlockedIPsPage').then(({ BlockedIPsPage }) => ({ default: BlockedIPsPage })), permissions: GEOIP_VIEW_PERMS },
49
50
  { path: 'firewall-log', label: 'Firewall Log', loadComponent: () => import('../network/FirewallLogPage').then(({ FirewallLogPage }) => ({ default: FirewallLogPage })), permissions: LOGS_ADMIN_PERMISSIONS },
50
51
  { path: 'ip-sets', label: 'IP Sets', loadComponent: () => import('../network/IPSetsPage').then(({ IPSetsPage }) => ({ default: IPSetsPage })), permissions: IPSET_VIEW_PERMS },
51
52
  { path: 'geofencing', label: 'Geofencing', loadComponent: () => import('../network/geofence/GeofencingPage').then(({ GeofencingPage }) => ({ default: GeofencingPage })), permissions: GEOFENCE_VIEW_PERMS },
53
+ { path: 'edge-blocklist', label: 'Edge Blocklist', loadComponent: () => import('../edge/BlocklistPage').then(({ BlocklistPage }) => ({ default: BlocklistPage })), permissions: EDGE_BLOCKLIST_VIEW_PERMS },
52
54
  ],
53
55
  };
54
56
 
@@ -0,0 +1,110 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { mojoDelete, mojoSave, useCan, withFreshAuth } from '../../client/runtime';
4
+ import { ArmedButton, Badge, ModelTable, fmt, modal, toast, type Column, type FilterDef, type Tone } from '../../ui';
5
+ import { EdgeBlocklistModel, sanitizeEdgeBlocklistRow, validateBlocklistValue, type EdgeBlocklistRow } from './models';
6
+ import { EDGE_BLOCKLIST_MANAGE_PERMS } from './permissions';
7
+
8
+ export const BLOCKLIST_MODES: readonly { value: string; label: string; tone: Tone; help: string }[] = [
9
+ { value: 'log', label: 'Log', tone: 'info', help: 'Matches are recorded and still served. Start here.' },
10
+ { value: 'enforce', label: 'Enforce', tone: 'danger', help: 'Matches are refused at the edge.' },
11
+ { value: 'allow', label: 'Allow', tone: 'success', help: 'An exemption: a match is never blocked by another entry.' },
12
+ { value: 'off', label: 'Off', tone: 'muted', help: 'Kept on file, does nothing.' },
13
+ ];
14
+ const KIND_LABELS: Record<string, string> = { ip: 'IP or network', ua: 'User agent' };
15
+
16
+ function modeMeta(mode: string) {
17
+ return BLOCKLIST_MODES.find((entry) => entry.value === mode) ?? { value: mode, label: mode, tone: 'muted' as Tone, help: '' };
18
+ }
19
+
20
+ function BlocklistEditor({ row, pending, close }: { row: EdgeBlocklistRow | null; pending: { current: boolean }; close: (result: 'saved' | 'deleted' | null) => void }) {
21
+ const queryClient = useQueryClient();
22
+ const [kind, setKind] = useState(row?.kind ?? 'ip');
23
+ const [value, setValue] = useState(row?.value ?? '');
24
+ const [mode, setMode] = useState(row?.mode ?? 'log');
25
+ const [note, setNote] = useState(row?.note ?? '');
26
+ const [busy, setBusy] = useState(false);
27
+ const [error, setError] = useState('');
28
+
29
+ const submit = async (event: React.FormEvent) => {
30
+ event.preventDefault();
31
+ if (busy) return;
32
+ const refusal = validateBlocklistValue(kind, value.trim());
33
+ if (refusal) { setError(refusal); return; }
34
+ setBusy(true); setError(''); pending.current = true;
35
+ try {
36
+ // The response row is authoritative: an address comes back as its network.
37
+ const saved = sanitizeEdgeBlocklistRow(await withFreshAuth(() => mojoSave<EdgeBlocklistRow>(EdgeBlocklistModel.endpoint, row?.id ?? null, { kind, value: value.trim(), mode, note: note.trim() })));
38
+ await EdgeBlocklistModel.invalidate(queryClient);
39
+ toast.success(saved.value === value.trim() ? `${saved.value} saved as ${modeMeta(saved.mode).label.toLowerCase()}.` : `Saved as ${saved.value} (${modeMeta(saved.mode).label.toLowerCase()}): the address was stored as its network.`);
40
+ close('saved');
41
+ } catch (cause) {
42
+ setError(cause instanceof Error ? cause.message : 'The entry was not saved');
43
+ } finally {
44
+ pending.current = false; setBusy(false);
45
+ }
46
+ };
47
+ const remove = async () => {
48
+ if (!row || busy) return;
49
+ // One pending state for save and delete: neither can start over the other.
50
+ setBusy(true); setError(''); pending.current = true;
51
+ try {
52
+ await withFreshAuth(() => mojoDelete(EdgeBlocklistModel.endpoint, row.id));
53
+ await EdgeBlocklistModel.invalidate(queryClient);
54
+ toast.success(`${row.value} removed from the blocklist.`);
55
+ close('deleted');
56
+ } catch (cause) {
57
+ setError(cause instanceof Error ? cause.message : 'The entry was not deleted');
58
+ } finally {
59
+ pending.current = false; setBusy(false);
60
+ }
61
+ };
62
+
63
+ return <div className="modal-pad edge-blocklist-editor"><h2 className="modal-title">{row ? 'Edit blocklist entry' : 'New blocklist entry'}</h2>
64
+ {error && <div className="form-alert" role="alert">{error}</div>}
65
+ <form onSubmit={(event) => void submit(event)}><div className="form-grid">
66
+ <label className="field"><span className="field-label">Matches on</span><select className="input" value={kind} disabled={busy} onChange={(event) => setKind(event.target.value)}><option value="ip">IP address or network</option><option value="ua">User agent pattern</option></select></label>
67
+ <label className="field"><span className="field-label">Value <em>*</em></span><input className="input mono" value={value} disabled={busy} onChange={(event) => setValue(event.target.value)} required autoComplete="off" placeholder={kind === 'ip' ? '203.0.113.0/24' : 'sqlmap'} /><span className="field-help">{kind === 'ip' ? 'One address or a CIDR network. An address inside a network is stored as the network.' : 'A pattern matched anywhere in the user agent, ignoring case. No spaces or quotes.'}</span></label>
68
+ <div className="field edge-mode-field"><span className="field-label">Mode</span>
69
+ <div className="edge-mode-control" role="radiogroup" aria-label="Mode">{BLOCKLIST_MODES.map((entry) => <label key={entry.value} className={`edge-mode-option${mode === entry.value ? ' is-selected' : ''}`}><input type="radio" name="edge-blocklist-mode" checked={mode === entry.value} disabled={busy} onChange={() => setMode(entry.value)} /> {entry.label}</label>)}</div>
70
+ <span className="field-help">{modeMeta(mode).help} Log first, watch the edge watch log for this entry, then switch to Enforce. A change reaches the whole fleet in about 10 minutes.</span>
71
+ </div>
72
+ <label className="field"><span className="field-label">Note</span><input className="input" value={note} disabled={busy} maxLength={255} onChange={(event) => setNote(event.target.value)} placeholder="Why this entry exists" /></label>
73
+ </div>
74
+ <div className="modal-actions">
75
+ {row && <ArmedButton icon="bi-trash" label="Delete" armedLabel="Click again to remove this entry from every node" disabled={busy} onConfirm={remove} />}
76
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
77
+ <button className="btn btn-primary" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
78
+ </div></form>
79
+ </div>;
80
+ }
81
+
82
+ export function openBlocklistEditor(row: EdgeBlocklistRow | null = null): Promise<'saved' | 'deleted' | null> {
83
+ // Escape and the backdrop are refused while a save or delete is in flight.
84
+ const pending = { current: false };
85
+ return modal.open<'saved' | 'deleted' | null>((close) => <BlocklistEditor row={row} pending={pending} close={close} />, { canDismiss: () => !pending.current }).then((value) => value ?? null);
86
+ }
87
+
88
+ const COLUMNS: Column<EdgeBlocklistRow>[] = [
89
+ { key: 'kind', label: 'Matches on', sortable: true, render: (row) => KIND_LABELS[row.kind] ?? row.kind },
90
+ { key: 'value', label: 'Value', sortable: true, hideable: false, render: (row) => <code>{row.value}</code> },
91
+ { key: 'mode', label: 'Mode', sortable: true, render: (row) => <Badge tone={modeMeta(row.mode).tone}>{modeMeta(row.mode).label}</Badge> },
92
+ { key: 'note', label: 'Note', sortable: false, render: (row) => row.note ? <span title={row.note}>{fmt.truncate(row.note, 60)}</span> : <span className="dim">—</span> },
93
+ { key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
94
+ ];
95
+
96
+ const FILTERS: FilterDef[] = [
97
+ { key: 'kind', label: 'Matches on', type: 'select', options: [{ value: 'ip', label: 'IP or network' }, { value: 'ua', label: 'User agent' }] },
98
+ { key: 'mode', label: 'Mode', type: 'select', options: BLOCKLIST_MODES.map((entry) => ({ value: entry.value, label: entry.label })) },
99
+ ];
100
+
101
+ export function BlocklistPage() {
102
+ const canManage = useCan(EDGE_BLOCKLIST_MANAGE_PERMS).can;
103
+ return <ModelTable<EdgeBlocklistRow>
104
+ model={EdgeBlocklistModel} eyebrow="Security · Network" title="Edge Blocklist"
105
+ searchable searchPlaceholder="Search value or note"
106
+ columns={COLUMNS} filters={FILTERS} defaultSort="kind"
107
+ columnChooser persistState persistKey="admin:edge:blocklist"
108
+ {...(canManage ? { addLabel: 'New entry', onAdd: () => void openBlocklistEditor(), onRowClick: (row: EdgeBlocklistRow) => void openBlocklistEditor(row) } : {})}
109
+ />;
110
+ }
@@ -0,0 +1,113 @@
1
+ import { useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useCan } from '../../client/runtime';
4
+ import { ArmedButton, Badge, CollectionSelect, ModelTable, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
5
+ import { declareUpstream, retireUpstream, setUpstreamEnabled, type DeclareUpstreamInput } from './api';
6
+ import { EDGE_MANAGE_PERMS, EdgeUpstreamModel, upstreamTarget, type EdgeUpstreamRow } from './models';
7
+
8
+ const RETIRE_CONSEQUENCE = 'Vhosts and routes that reach this upstream stop being served. They are not repointed. The upstream stays listed, disabled, so its history is kept.';
9
+
10
+ function DeclareUpstreamDialog({ pending, close }: { pending: { current: boolean }; close: (row: EdgeUpstreamRow | null) => void }) {
11
+ const [name, setName] = useState('');
12
+ const [kind, setKind] = useState<'http' | 'unix'>('http');
13
+ const [host, setHost] = useState('');
14
+ const [port, setPort] = useState('');
15
+ const [socketPath, setSocketPath] = useState('');
16
+ const [group, setGroup] = useState<number | null>(null);
17
+ const [busy, setBusy] = useState(false);
18
+ const [error, setError] = useState('');
19
+
20
+ const submit = async (event: React.FormEvent) => {
21
+ event.preventDefault();
22
+ if (busy) return;
23
+ if (kind === 'http' && !/^\d+$/.test(port)) { setError('Enter the port as a number.'); return; }
24
+ // Only the chosen kind's destination is sent; the other branch's values vanish.
25
+ const input: DeclareUpstreamInput = kind === 'http'
26
+ ? { name: name.trim(), kind, host: host.trim().toLowerCase(), port: Number(port), group }
27
+ : { name: name.trim(), kind, socket_path: socketPath.trim(), group };
28
+ setBusy(true); setError(''); pending.current = true;
29
+ try {
30
+ close(await declareUpstream(input));
31
+ } catch (cause) {
32
+ setError(cause instanceof Error ? cause.message : 'The upstream was not declared');
33
+ } finally {
34
+ pending.current = false; setBusy(false);
35
+ }
36
+ };
37
+
38
+ return <div className="modal-pad edge-upstream-dialog"><h2 className="modal-title">Declare upstream</h2>
39
+ <p className="dim">An upstream is a destination vhosts may proxy to. Only platform administrators can declare one, and its destination cannot be changed afterwards.</p>
40
+ {error && <div className="form-alert" role="alert">{error}</div>}
41
+ <form onSubmit={(event) => void submit(event)}><div className="form-grid">
42
+ <label className="field"><span className="field-label">Name <em>*</em></span><input className="input" value={name} disabled={busy} onChange={(event) => setName(event.target.value)} required autoComplete="off" placeholder="orders-api" /><span className="field-help">Lowercase letters, digits, '-' or '_'.</span></label>
43
+ <label className="field"><span className="field-label">Reached over</span><select className="input" value={kind} disabled={busy} onChange={(event) => setKind(event.target.value === 'unix' ? 'unix' : 'http')}><option value="http">HTTP: host and port</option><option value="unix">Unix socket</option></select></label>
44
+ {kind === 'http' && <label className="field"><span className="field-label">Host <em>*</em></span><input className="input" value={host} disabled={busy} onChange={(event) => setHost(event.target.value)} required autoComplete="off" placeholder="10.0.4.12" /></label>}
45
+ {kind === 'http' && <label className="field"><span className="field-label">Port <em>*</em></span><input className="input" inputMode="numeric" value={port} disabled={busy} onChange={(event) => setPort(event.target.value.trim())} required placeholder="8080" /></label>}
46
+ {kind === 'unix' && <label className="field"><span className="field-label">Socket path <em>*</em></span><input className="input" value={socketPath} disabled={busy} onChange={(event) => setSocketPath(event.target.value)} required autoComplete="off" placeholder="/run/mojo/app.sock" /><span className="field-help">Must sit under the deployment's socket directory.</span></label>}
47
+ <CollectionSelect<{ id: number; name: string }> endpoint="/api/group" value={group} onChange={(id) => setGroup(id == null ? null : Number(id))} label="Owning group" disabled={busy} placeholder="Shared with every group" help="Leave empty for a shared upstream. Choose a group to keep it to that group's vhosts." />
48
+ </div>
49
+ <div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy}>{busy ? 'Declaring…' : 'Declare upstream'}</button></div></form>
50
+ </div>;
51
+ }
52
+
53
+ export function openDeclareUpstream(): Promise<EdgeUpstreamRow | null> {
54
+ // Escape and the backdrop are refused while the declare is in flight.
55
+ const pending = { current: false };
56
+ return modal.open<EdgeUpstreamRow | null>((close) => <DeclareUpstreamDialog pending={pending} close={close} />, { canDismiss: () => !pending.current }).then((value) => value ?? null);
57
+ }
58
+
59
+ const FILTERS: FilterDef[] = [
60
+ { key: 'is_enabled', label: 'State', type: 'boolean', trueLabel: 'Enabled', falseLabel: 'Disabled' },
61
+ { key: 'kind', label: 'Reached over', type: 'select', options: [{ value: 'http', label: 'HTTP' }, { value: 'unix', label: 'Unix socket' }] },
62
+ ];
63
+
64
+ export function UpstreamsPage() {
65
+ const queryClient = useQueryClient();
66
+ const { can: canManage, me } = useCan(EDGE_MANAGE_PERMS);
67
+ // Declare and retire are `require_platform_admin` on the server.
68
+ const superuser = me?.is_superuser === true;
69
+
70
+ const run = async (work: () => Promise<EdgeUpstreamRow>, done: (row: EdgeUpstreamRow) => string) => {
71
+ try {
72
+ toast.success(done(await work()));
73
+ } catch (error) {
74
+ toast.error(error instanceof Error ? error.message : 'The change was refused');
75
+ } finally {
76
+ await EdgeUpstreamModel.invalidate(queryClient);
77
+ }
78
+ };
79
+ const retire = async (row: EdgeUpstreamRow) => {
80
+ const ok = await modal.confirm({ title: `Retire ${row.name}?`, message: RETIRE_CONSEQUENCE, confirmText: 'Retire upstream', danger: true });
81
+ if (ok) await run(() => retireUpstream(row.id), (saved) => `${saved.name} retired.`);
82
+ };
83
+ const declare = async () => {
84
+ const row = await openDeclareUpstream();
85
+ if (!row) return;
86
+ await EdgeUpstreamModel.invalidate(queryClient);
87
+ toast.success(`${row.name} declared.`);
88
+ };
89
+
90
+ const columns: Column<EdgeUpstreamRow>[] = [
91
+ { key: 'name', label: 'Name', sortable: true, hideable: false, render: (row) => <code>{row.name}</code> },
92
+ { key: 'kind', label: 'Reached over', sortable: true, render: (row) => row.kind === 'unix' ? 'Unix socket' : 'HTTP' },
93
+ { key: 'target', label: 'Destination', sortable: false, render: (row) => <code>{upstreamTarget(row)}</code> },
94
+ { key: 'group', label: 'Scope', sortable: false, render: (row) => row.group ? <Badge tone="info">{row.group.name}</Badge> : <Badge tone="primary">Shared</Badge> },
95
+ { key: 'is_enabled', label: 'State', sortable: true, render: (row) => row.is_enabled ? <Badge tone="success">Enabled</Badge> : <Badge tone="muted">Disabled</Badge> },
96
+ { key: 'created', label: 'Declared', sortable: true, render: (row) => fmt.date(row.created) },
97
+ ...(canManage ? [{
98
+ key: 'actions', label: '', sortable: false, hideable: false, align: 'end' as const,
99
+ render: (row: EdgeUpstreamRow) => <span className="edge-row-actions" onClick={(event) => event.stopPropagation()}>
100
+ <button type="button" className="btn btn-compact" onClick={() => void run(() => setUpstreamEnabled(row.id, !row.is_enabled), (saved) => `${saved.name} ${saved.is_enabled ? 'enabled' : 'disabled'}.`)}>{row.is_enabled ? 'Disable' : 'Enable'}</button>
101
+ {superuser && row.is_enabled && <ArmedButton className="btn-compact" label="Retire" armedLabel="Click again to review" onConfirm={() => retire(row)} />}
102
+ </span>,
103
+ }] : []),
104
+ ];
105
+
106
+ return <ModelTable<EdgeUpstreamRow>
107
+ model={EdgeUpstreamModel} eyebrow="Infrastructure · Edge" title="Upstreams"
108
+ searchable searchPlaceholder="Search name or kind"
109
+ columns={columns} filters={FILTERS} defaultSort="name"
110
+ columnChooser persistState persistKey="admin:edge:upstreams"
111
+ {...(superuser ? { addLabel: 'Declare upstream', onAdd: () => void declare() } : {})}
112
+ />;
113
+ }