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.
- package/README.md +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +6 -2
- package/docs/account.md +4 -3
- 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/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 +9 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +1 -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 +3 -2
- package/src/account/AccountModal.tsx +3 -2
- package/src/account/index.ts +2 -2
- package/src/admin/core/index.ts +1 -1
- 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/sections/actions.tsx +3 -3
- 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 +18 -4
- 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 +958 -14
- 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/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +3 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/toast.tsx +1 -1
package/docs/menus-and-access.md
CHANGED
|
@@ -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: `
|
|
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 `
|
|
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`
|
package/docs/record-feed.md
CHANGED
|
@@ -221,8 +221,8 @@ surfaces local rejection through the same alert region.
|
|
|
221
221
|
|
|
222
222
|
## Styling
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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` /
|
package/docs/timepicker.md
CHANGED
|
@@ -150,7 +150,7 @@ zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `'UTC'` on failure)
|
|
|
150
150
|
|
|
151
151
|
## Styling
|
|
152
152
|
|
|
153
|
-
`
|
|
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
|
package/docs/timezone-select.md
CHANGED
|
@@ -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
|
|
144
|
-
pixel comes from
|
|
145
|
-
|
|
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 — `
|
|
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
|
|
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
|
|
302
|
-
|
|
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.
|
|
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:
|
|
15
|
-
//
|
|
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';
|
package/src/account/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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';
|
package/src/admin/core/index.ts
CHANGED
|
@@ -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 {
|
package/src/admin/dns/models.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|