portal-mojo 0.2.20 → 0.2.21

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 (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,52 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { hasPermission, useMe } from '../../client/runtime';
4
+ import { fetchDomainOverview, saveDomainRegistrarSettings } from './registrar-api';
5
+ import { DNS_MANAGE_PERMISSIONS, DomainModel, type DomainRow } from './models';
6
+ import { modal } from '../../ui';
7
+ import { redactRegistrarError } from './purchase-data';
8
+
9
+ export function DomainRegistrarSettings({ domain, onBusyChange, close }: { domain: DomainRow; onBusyChange?: (busy: boolean) => void; close?: () => void }) {
10
+ const { data: me } = useMe(); const queryClient = useQueryClient();
11
+ const [autoRenew, setAutoRenew] = useState(domain.auto_renew === true);
12
+ const [privacy, setPrivacy] = useState(domain.privacy === true);
13
+ const [live, setLive] = useState(domain);
14
+ const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [saved, setSaved] = useState(false);
15
+ const [uncertain, setUncertain] = useState(false); const inFlight = useRef(false);
16
+ if (domain.provider !== 'route53' || !hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)) return null;
17
+ const changed = autoRenew !== (live.auto_renew === true) || privacy !== (live.privacy === true);
18
+ const submit = async (event: React.FormEvent) => {
19
+ event.preventDefault(); if (inFlight.current || uncertain || !changed) return;
20
+ const changes: Partial<Pick<DomainRow, 'auto_renew' | 'privacy'>> = {};
21
+ if (autoRenew !== (live.auto_renew === true)) changes.auto_renew = autoRenew;
22
+ if (privacy !== (live.privacy === true)) changes.privacy = privacy;
23
+ inFlight.current = true; onBusyChange?.(true); setBusy(true); setError(''); setSaved(false);
24
+ let failed = false;
25
+ try { await saveDomainRegistrarSettings(domain.id, changes); }
26
+ catch (reason) { failed = true; setUncertain(true); setError(`${redactRegistrarError(reason)} The registrar outcome is uncertain. Inspect the registrar before another change.`); }
27
+ finally {
28
+ try {
29
+ const fresh = await fetchDomainOverview(domain.id);
30
+ queryClient.setQueryData(DomainModel.keys.one(domain.id), fresh);
31
+ setLive(fresh); setAutoRenew(fresh.auto_renew === true); setPrivacy(fresh.privacy === true);
32
+ if (!failed) setSaved(true);
33
+ } catch { setUncertain(true); setError('The authoritative domain could not be refreshed. Inspect the registrar and reopen this domain before making another change.'); }
34
+ await DomainModel.invalidate(queryClient);
35
+ inFlight.current = false; onBusyChange?.(false); setBusy(false);
36
+ }
37
+ };
38
+ return <form className="modal-pad domains-registrar-settings" onSubmit={(event) => void submit(event)}>
39
+ <h2 className="modal-title">Registrar settings</h2><p className="dim">Route 53 only. A database save does not prove that the registrar applied the change.</p>
40
+ {error && <div className="form-alert" role="alert">{error}</div>}
41
+ {live.last_error && <div className="form-alert" role="status"><b>Registrar warning</b><p>{redactRegistrarError(live.last_error)}</p><p>This may be historical. Inspect the registrar before assuming the setting was applied.</p></div>}
42
+ {saved && <p role="status">Domain refreshed after save. Check the registrar for application of these settings.</p>}
43
+ <label className="field"><span><input type="checkbox" checked={autoRenew} disabled={busy || uncertain} onChange={(event) => setAutoRenew(event.target.checked)} /> Auto-renew</span></label>
44
+ <label className="field"><span><input type="checkbox" checked={privacy} disabled={busy || uncertain} onChange={(event) => setPrivacy(event.target.checked)} /> WHOIS privacy</span></label>
45
+ <div className="modal-actions">{close && <button className="btn" type="button" disabled={busy} onClick={close}>Close</button>}<button className="btn btn-primary" type="submit" disabled={busy || uncertain || !changed}>{busy ? 'Saving and refreshing…' : 'Save registrar settings'}</button></div>
46
+ </form>;
47
+ }
48
+
49
+ export function showDomainRegistrarSettings(domain: DomainRow): Promise<unknown> {
50
+ let busy = false;
51
+ return modal.open((close) => <DomainRegistrarSettings domain={domain} close={() => close(null)} onBusyChange={(value) => { busy = value; }} />, { size: 'md', canDismiss: () => !busy });
52
+ }
@@ -1,19 +1,12 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
+ import { useLocation, useNavigate } from 'react-router-dom';
2
3
  import { Badge, ModelTable, fmt, modal, type Column, type FilterDef } from '../../ui';
3
4
  import { DomainDetail } from './DomainDetail';
4
5
  import { hasPermission, useMe } from '../../client/runtime';
5
- import { openSharedValidation } from './SharedValidationDialog';
6
6
  import { DomainModel, DNS_MANAGE_PERMISSIONS, type DomainRow } from './models';
7
7
  import { providerLabel } from './data';
8
+ import { domainRecordsHref, domainScopeLabel, supportsDomainRecords } from './domain-navigation';
8
9
 
9
- const columns: Column<DomainRow>[] = [
10
- { key: 'name', label: 'Domain', sortable: true, hideable: false, render: (row) => <code>{row.name}</code> },
11
- { key: 'provider', label: 'Provider', sortable: true, render: (row) => providerLabel(row.provider) },
12
- { key: 'status', label: 'Status', sortable: true, render: (row) => <Badge tone={fmt.inferTone(row.status)}>{row.status}</Badge> },
13
- { key: 'group', label: 'Group', render: (row) => typeof row.group === 'object' ? row.group?.name ?? 'Platform' : row.group ?? 'Platform' },
14
- { key: 'expires', label: 'Expires', sortable: true, render: (row) => fmt.date(row.expires) },
15
- { key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
16
- ];
17
10
  const filters: FilterDef[] = [
18
11
  { key: 'provider', label: 'Provider', type: 'select', options: [{ value: 'route53', label: 'Route 53' }, { value: 'godaddy', label: 'GoDaddy' }, { value: 'mojo', label: 'Mojo certificate-only' }] },
19
12
  { key: 'status', label: 'Status', type: 'select', options: ['pending', 'registering', 'active'].map((value) => ({ value, label: value })) },
@@ -22,10 +15,26 @@ const filters: FilterDef[] = [
22
15
  export function DomainsPage() {
23
16
  const queryClient = useQueryClient();
24
17
  const { data: me } = useMe();
18
+ const location = useLocation(); const navigate = useNavigate();
25
19
  const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
26
- const open = async (row: DomainRow) => {
27
- await DomainModel.fetchOne(queryClient, row.id);
28
- await modal.detail((close) => <DomainDetail id={row.id} onClose={() => close(null)} />);
20
+ const start = async (initialMode: 'register' | 'adopt' | 'purchase') => {
21
+ const { showDomainOnboarding } = await import('./DomainOnboardingDialog');
22
+ await showDomainOnboarding({ initialMode });
23
+ };
24
+ const open = (row: DomainRow) => {
25
+ void DomainModel.fetchOne(queryClient, row.id).catch(() => undefined);
26
+ void modal.detail((close) => <DomainDetail id={row.id} onClose={() => close(null)} />);
29
27
  };
30
- return <ModelTable<DomainRow> model={DomainModel} eyebrow="Infrastructure · DNS" title="Domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={(row) => void open(row)} {...(canManage ? { onAdd: () => void openSharedValidation(), addLabel: 'Shared validation setup' } : {})} />;
28
+ const columns: Column<DomainRow>[] = [
29
+ { key: 'name', label: 'Domain', sortable: true, hideable: false, render: (row) => <span className="ops-cell"><strong>{row.name}</strong><small>{providerLabel(row.provider)}</small></span> },
30
+ { key: 'status', label: 'Status', sortable: true, render: (row) => <Badge tone={fmt.inferTone(row.status)}>{row.status}</Badge> },
31
+ { key: 'expires', label: 'Expires', sortable: true, render: (row) => fmt.date(row.expires) },
32
+ { key: 'group', label: 'Scope', render: (row) => <span className="domains-scope">{domainScopeLabel(row.group)}</span> },
33
+ { key: 'records', label: 'DNS Records', render: (row) => supportsDomainRecords(row.provider) ? <button className="btn btn-sm" type="button" onClick={(event) => { event.stopPropagation(); navigate(domainRecordsHref(location.pathname, row.id)); }}>DNS Records <i className="bi bi-arrow-right" /></button> : <span className="dim">{row.provider === 'mojo' ? 'Certificate-only' : 'Provider unavailable'}</span> },
34
+ ];
35
+ return <div className="domains-page">
36
+ <header className="domains-header"><div><div className="eyebrow">Network &amp; hosting</div><h1>Domains</h1><p className="dim">Manage the names this installation controls.</p></div>{canManage && <div className="domains-actions"><button className="btn" onClick={() => void start('register')}>Register existing</button>{me?.is_superuser === true && <button className="btn" onClick={() => void start('adopt')}>Adopt</button>}<button className="btn btn-primary" onClick={() => void start('purchase')}>Buy domain</button></div>}</header>
37
+ <ModelTable<DomainRow> model={DomainModel} title="Managed domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={open} />
38
+ {canManage && <details className="domains-advanced"><summary>Advanced</summary><p className="dim">Connect external domains to shared wildcard validation, or review controller allocations and trust.</p><button className="btn" onClick={() => void import('./SharedValidationDialog').then(({ openSharedValidation }) => openSharedValidation())}>Shared validation setup</button></details>}
39
+ </div>;
31
40
  }
@@ -258,7 +258,7 @@ export function SharedValidationDialog({ domain: fixedDomain, close, onBusy }: {
258
258
  <label className="field"><span className="field-label">Existing validation target (optional for inspection)</span><input ref={allocationRef} className="input" maxLength={253} disabled={busy} placeholder="existing-target.acme-hub.example" onChange={() => setInspected(null)} /><span className="field-help">Only the public target hostname supplied by its controller. Knowing it does not grant issuance authority.</span></label>
259
259
  <div className="dns-shared-actions"><button className="btn" disabled={busy} onClick={inspect}>Inspect plan</button><button className="btn" disabled={busy || plan.loading || !inspected} onClick={() => void refresh()}>Refresh</button></div>
260
260
  {plan.loading && inspected && <p className="dim">Checking validation…</p>}{plan.error && <div className="dns-errors" role="alert">{plan.error.message}{r && ' Displaying the last successful check.'}</div>}
261
- {r && <><ValidationFacts readiness={r} />{r.connection_request && canManage && <div className="dns-shared-request"><strong>Public connection request</strong><RequestFacts request={r.connection_request} /><p>Send this file to the allocation controller. They open their deployment’s Domains → Shared validation setup → Controller review and import it.</p><div className="dns-shared-actions"><button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('download')}>Download request</button>{typeof navigator.canShare === 'function' && typeof navigator.share === 'function' && <button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('share')}>Share request</button>}<button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('copy')}>Copy request</button></div></div>}
261
+ {r && <><ValidationFacts readiness={r} />{r.connection_request && canManage && <div className="dns-shared-request"><strong>Public connection request</strong><RequestFacts request={r.connection_request} /><p>Send this file to the allocation controller. They open their deployment’s Domains → Advanced → Shared validation setup → Controller review and import it.</p><div className="dns-shared-actions"><button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('download')}>Download request</button>{typeof navigator.canShare === 'function' && typeof navigator.share === 'function' && <button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('share')}>Share request</button>}<button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('copy')}>Copy request</button></div></div>}
262
262
  {canManage && <div className="dns-shared-actions">
263
263
  {r.capabilities.can_connect && r.next_action.code !== 'grant_trust' && <button className="btn btn-primary" disabled={busy || !!plan.error || plan.loading || refreshRequired} onClick={() => void localAction('connect')}>{r.connection ? 'Connect approved validation' : 'Prepare connection'}</button>}
264
264
  {r.capabilities.can_restart && r.trust.state === 'revoked' && <button className="btn btn-primary" disabled={busy || !!plan.error || plan.loading || refreshRequired} onClick={() => void localAction('restart')}>Prepare fresh request</button>}
@@ -226,8 +226,10 @@ export function quoteDomain(input: { group: number; domain: string; years?: numb
226
226
  return postData('/api/dnsman/registrar/quote', input);
227
227
  }
228
228
 
229
- export async function purchaseDomain(input: { group: number; purchase: number; confirm_token: string }): Promise<RegistrarPurchaseResult> {
230
- return sanitizePurchaseResult(await postData('/api/dnsman/registrar/purchase', input));
229
+ export async function purchaseDomain(input: { group: number; purchase: number; confirm_token: string; confirm_domain: string; confirm_price: string }): Promise<RegistrarPurchaseResult> {
230
+ // Snapshot once: the safe 440 continuation must use the identical quote.
231
+ const body = Object.freeze({ ...input });
232
+ return sanitizePurchaseResult(await withFreshAuth(() => postData('/api/dnsman/registrar/purchase', body)));
231
233
  }
232
234
 
233
235
  /** Detail and its error remain modal-local; neither enters Query cache. */
@@ -0,0 +1,16 @@
1
+ import type { DomainRow } from './models';
2
+
3
+ /** The observed DNS route supplies the mount, including embedded prefixes. */
4
+ export function dnsAdminMount(pathname: string): string {
5
+ const marker = pathname.indexOf('/dns/');
6
+ return marker < 0 ? '' : pathname.slice(0, marker);
7
+ }
8
+ export function domainRecordsHref(pathname: string, id: number): string {
9
+ return `${dnsAdminMount(pathname)}/dns/records?domain=${encodeURIComponent(id)}`;
10
+ }
11
+ export function domainScopeLabel(group: DomainRow['group']): string {
12
+ return group == null ? 'Platform' : typeof group === 'number' ? `Group #${group}` : group.name;
13
+ }
14
+ export function supportsDomainRecords(provider: string): boolean {
15
+ return provider === 'route53' || provider === 'godaddy';
16
+ }
@@ -0,0 +1,13 @@
1
+ import { mojoCall } from '../../client/runtime';
2
+ import { sanitizeDomainRow, type DomainRow } from './models';
3
+
4
+ /** Registrar writes are deliberately narrower than the generic model surface. */
5
+ export async function saveDomainRegistrarSettings(id: number, changes: Partial<Pick<DomainRow, 'auto_renew' | 'privacy'>>): Promise<void> {
6
+ if (Object.keys(changes).length === 0 || Object.entries(changes).some(([key, value]) => !['auto_renew', 'privacy'].includes(key) || typeof value !== 'boolean')) throw new Error('Only changed boolean registrar settings may be saved');
7
+ await mojoCall(`/api/dnsman/domain/${id}`, { method: 'POST', body: { ...changes } });
8
+ }
9
+
10
+ /** Authoritative uncached read after every attempted registrar write. */
11
+ export async function fetchDomainOverview(id: number): Promise<DomainRow> {
12
+ return sanitizeDomainRow((await mojoCall(`/api/dnsman/domain/${id}`)).data);
13
+ }
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { ASSISTANT_PERMISSIONS } from '../assistant/permissions';
3
4
 
@@ -5,10 +6,10 @@ export { ASSISTANT_PERMISSIONS } from '../assistant/permissions';
5
6
  export const ASSISTANT_ADMIN_SECTION: AdminSection = {
6
7
  id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
7
8
  routes: [
8
- { path: 'setup', label: 'Setup', requireSuperuser: true, loadComponent: () => import('../assistant/AssistantSetupPage').then(({ AssistantSetupPage }) => ({ default: AssistantSetupPage })) },
9
- { path: 'conversations', label: 'Conversations', loadComponent: () => import('../assistant/pages').then(({ ConversationsPage }) => ({ default: ConversationsPage })), permissions: ASSISTANT_PERMISSIONS },
10
- { path: 'skills', label: 'Skills', loadComponent: () => import('../assistant/pages').then(({ SkillsPage }) => ({ default: SkillsPage })), permissions: ASSISTANT_PERMISSIONS },
11
- { path: 'memories', label: 'Memory', loadComponent: () => import('../assistant/pages').then(({ MemoriesPage }) => ({ default: MemoriesPage })), permissions: ['sys.assistant'] },
9
+ { path: 'setup', label: 'Setup', requireSuperuser: true, loadComponent: () => import('../assistant/AssistantSetupPage').then(namedPage('AssistantSetupPage')) },
10
+ { path: 'conversations', label: 'Conversations', loadComponent: () => import('../assistant/pages').then(namedPage('ConversationsPage')), permissions: ASSISTANT_PERMISSIONS },
11
+ { path: 'skills', label: 'Skills', loadComponent: () => import('../assistant/pages').then(namedPage('SkillsPage')), permissions: ASSISTANT_PERMISSIONS },
12
+ { path: 'memories', label: 'Memory', loadComponent: () => import('../assistant/pages').then(namedPage('MemoriesPage')), permissions: ['sys.assistant'] },
12
13
  ],
13
14
  };
14
15
  export const ASSISTANT_ADMIN_SECTIONS = [ASSISTANT_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { EMAIL_ADMIN_PERMISSIONS, PUBLIC_MESSAGE_VIEW_PERMISSIONS } from '../messaging/models';
3
4
  import { PUSH_ADMIN_PERMISSIONS } from '../messaging/push/models';
@@ -7,29 +8,29 @@ import { SHORTLINK_MANAGE_PERMISSIONS } from '../shortlinks/models';
7
8
  export const SHORTLINKS_ADMIN_SECTION: AdminSection = {
8
9
  id: 'shortlinks', basePath: 'shortlinks', title: 'Shortlinks', icon: 'bi-link-45deg', navigationGroup: 'communications', permissions: SHORTLINK_MANAGE_PERMISSIONS,
9
10
  routes: [
10
- { path: 'links', label: 'Links', loadComponent: () => import('../shortlinks/ShortlinksPage').then(({ ShortlinksPage }) => ({ default: ShortlinksPage })), permissions: SHORTLINK_MANAGE_PERMISSIONS },
11
- { path: 'history', label: 'Click history', loadComponent: () => import('../shortlinks/ShortlinkHistoryPage').then(({ ShortlinkHistoryPage }) => ({ default: ShortlinkHistoryPage })), permissions: SHORTLINK_MANAGE_PERMISSIONS },
11
+ { path: 'links', label: 'Links', loadComponent: () => import('../shortlinks/ShortlinksPage').then(namedPage('ShortlinksPage')), permissions: SHORTLINK_MANAGE_PERMISSIONS },
12
+ { path: 'history', label: 'Click history', loadComponent: () => import('../shortlinks/ShortlinkHistoryPage').then(namedPage('ShortlinkHistoryPage')), permissions: SHORTLINK_MANAGE_PERMISSIONS },
12
13
  ],
13
14
  };
14
15
  export const EMAIL_ADMIN_SECTION: AdminSection = {
15
16
  id: 'email', basePath: 'email', title: 'Email', icon: 'bi-envelope-at', navigationGroup: 'communications', permissions: EMAIL_ADMIN_PERMISSIONS,
16
17
  routes: [
17
- { path: 'domains', label: 'Email Domains', loadComponent: () => import('../messaging/EmailDomainsPage').then(({ EmailDomainsPage }) => ({ default: EmailDomainsPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
18
- { path: 'mailboxes', label: 'Mailboxes', loadComponent: () => import('../messaging/MailboxesPage').then(({ MailboxesPage }) => ({ default: MailboxesPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
19
- { path: 'sent', label: 'Sent Messages', loadComponent: () => import('../messaging/SentMessagesPage').then(({ SentMessagesPage }) => ({ default: SentMessagesPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
20
- { path: 'templates', label: 'Email Templates', loadComponent: () => import('../messaging/EmailTemplatesPage').then(({ EmailTemplatesPage }) => ({ default: EmailTemplatesPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
18
+ { path: 'domains', label: 'Email Domains', loadComponent: () => import('../messaging/EmailDomainsPage').then(namedPage('EmailDomainsPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
19
+ { path: 'mailboxes', label: 'Mailboxes', loadComponent: () => import('../messaging/MailboxesPage').then(namedPage('MailboxesPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
20
+ { path: 'sent', label: 'Sent Messages', loadComponent: () => import('../messaging/SentMessagesPage').then(namedPage('SentMessagesPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
21
+ { path: 'templates', label: 'Email Templates', loadComponent: () => import('../messaging/EmailTemplatesPage').then(namedPage('EmailTemplatesPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
21
22
  ],
22
23
  };
23
24
  export const PUBLIC_MESSAGES_ADMIN_SECTION: AdminSection = {
24
25
  id: 'public-messages', basePath: 'messaging', title: 'Contact Messages', icon: 'bi-chat-left-text', navigationGroup: 'communications', permissions: PUBLIC_MESSAGE_VIEW_PERMISSIONS,
25
- routes: [{ path: 'public-messages', label: 'Contact Messages', loadComponent: () => import('../messaging/PublicMessagesPage').then(({ PublicMessagesPage }) => ({ default: PublicMessagesPage })), permissions: PUBLIC_MESSAGE_VIEW_PERMISSIONS }],
26
+ routes: [{ path: 'public-messages', label: 'Contact Messages', loadComponent: () => import('../messaging/PublicMessagesPage').then(namedPage('PublicMessagesPage')), permissions: PUBLIC_MESSAGE_VIEW_PERMISSIONS }],
26
27
  };
27
28
  export const PUSH_ADMIN_SECTION: AdminSection = {
28
29
  id: 'push', basePath: 'push', title: 'Push Notifications', icon: 'bi-bell', navigationGroup: 'communications', permissions: PUSH_ADMIN_PERMISSIONS,
29
- routes: [{ path: '', label: 'Push Notifications', loadComponent: () => import('../messaging/push/PushPage').then(({ PushPage }) => ({ default: PushPage })), permissions: PUSH_ADMIN_PERMISSIONS }],
30
+ routes: [{ path: '', label: 'Push Notifications', loadComponent: () => import('../messaging/push/PushPage').then(namedPage('PushPage')), permissions: PUSH_ADMIN_PERMISSIONS }],
30
31
  };
31
32
  export const PHONE_HUB_ADMIN_SECTION: AdminSection = {
32
33
  id: 'phonehub', basePath: 'phonehub', title: 'Phone Hub', icon: 'bi-phone', navigationGroup: 'communications', permissions: PHONE_HUB_ADMIN_PERMISSIONS,
33
- routes: [{ path: '', label: 'Phone Hub', loadComponent: () => import('../phonehub/PhoneHubPage').then(({ PhoneHubPage }) => ({ default: PhoneHubPage })), permissions: PHONE_HUB_ADMIN_PERMISSIONS }],
34
+ routes: [{ path: '', label: 'Phone Hub', loadComponent: () => import('../phonehub/PhoneHubPage').then(namedPage('PhoneHubPage')), permissions: PHONE_HUB_ADMIN_PERMISSIONS }],
34
35
  };
35
36
  export const COMMUNICATIONS_ADMIN_SECTIONS = [SHORTLINKS_ADMIN_SECTION, EMAIL_ADMIN_SECTION, PUBLIC_MESSAGES_ADMIN_SECTION, PUSH_ADMIN_SECTION, PHONE_HUB_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
3
4
  import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
@@ -7,35 +8,35 @@ import { GROUP_VIEW_PERMS } from '../identity/groups/group-permissions';
7
8
 
8
9
  export const USERS_ADMIN_SECTION: AdminSection = {
9
10
  id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
10
- routes: [{ path: 'users', label: 'Users', loadComponent: () => import('../identity/users/UsersPage').then(({ UsersPage }) => ({ default: UsersPage })), permissions: USER_VIEW_PERMISSIONS }],
11
+ routes: [{ path: 'users', label: 'Users', loadComponent: () => import('../identity/users/UsersPage').then(namedPage('UsersPage')), permissions: USER_VIEW_PERMISSIONS }],
11
12
  };
12
13
 
13
14
  export const MEMBERS_ADMIN_SECTION: AdminSection = {
14
15
  id: 'members', basePath: '', title: 'Members', icon: 'bi-person-badge', navigationGroup: 'identity-access', permissions: MEMBER_READ_PERMISSIONS,
15
- routes: [{ path: 'members', label: 'Members', loadComponent: () => import('../identity/members/MembersPage').then(({ MembersPage }) => ({ default: MembersPage })), permissions: MEMBER_READ_PERMISSIONS }],
16
+ routes: [{ path: 'members', label: 'Members', loadComponent: () => import('../identity/members/MembersPage').then(namedPage('MembersPage')), permissions: MEMBER_READ_PERMISSIONS }],
16
17
  };
17
18
 
18
19
  export const CREDENTIALS_ADMIN_SECTION: AdminSection = {
19
20
  id: 'credentials', basePath: '', title: 'Credentials', icon: 'bi-key', navigationGroup: 'identity-access', permissions: GLOBAL_CREDENTIAL_PERMS,
20
21
  routes: [
21
- { path: 'api-keys', label: 'Group API Keys', loadComponent: () => import('../credentials/group-api-keys').then(({ GroupApiKeysPage }) => ({ default: GroupApiKeysPage })), permissions: GLOBAL_CREDENTIAL_PERMS },
22
- { path: 'webhook-subscriptions', label: 'Webhooks', loadComponent: () => import('../credentials/webhook-subscriptions').then(({ WebhookSubscriptionsPage }) => ({ default: WebhookSubscriptionsPage })), permissions: GLOBAL_CREDENTIAL_PERMS },
22
+ { path: 'api-keys', label: 'Group API Keys', loadComponent: () => import('../credentials/group-api-keys').then(namedPage('GroupApiKeysPage')), permissions: GLOBAL_CREDENTIAL_PERMS },
23
+ { path: 'webhook-subscriptions', label: 'Webhooks', loadComponent: () => import('../credentials/webhook-subscriptions').then(namedPage('WebhookSubscriptionsPage')), permissions: GLOBAL_CREDENTIAL_PERMS },
23
24
  ],
24
25
  };
25
26
 
26
27
  export const SIGNIN_ADMIN_SECTION: AdminSection = {
27
28
  id: 'signin', basePath: 'system/sign-in', title: 'Sign-in', icon: 'bi-box-arrow-in-right', navigationGroup: 'identity-access', permissions: SIGNIN_ADMIN_PERMISSIONS,
28
- routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
29
+ routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(namedPage('SigninPage')), permissions: SIGNIN_ADMIN_PERMISSIONS }],
29
30
  };
30
31
 
31
32
  export const GROUPS_ADMIN_SECTION: AdminSection = {
32
33
  id: 'groups', basePath: '', title: 'Groups', icon: 'bi-diagram-3', navigationGroup: 'identity-access', permissions: GROUP_VIEW_PERMS,
33
- routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(({ GroupsPage }) => ({ default: GroupsPage })), permissions: GROUP_VIEW_PERMS }],
34
+ routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(namedPage('GroupsPage')), permissions: GROUP_VIEW_PERMS }],
34
35
  };
35
36
 
36
37
  export const PERSONAL_API_KEYS_ADMIN_SECTION: AdminSection = {
37
38
  id: 'personal-api-keys', basePath: '', title: 'Personal API Keys', icon: 'bi-key', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
38
- routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(({ PersonalApiKeysPage }) => ({ default: PersonalApiKeysPage })) }],
39
+ routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(namedPage('PersonalApiKeysPage')) }],
39
40
  };
40
41
 
41
42
  export const IDENTITY_ADMIN_SECTIONS = [
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS } from '../dns/models';
3
4
  import { BUCKET_MANAGE_PERMS, STORAGE_VIEW_PERMS } from '../storage/permissions';
@@ -6,29 +7,34 @@ import { SETTINGS_PERMISSIONS } from '../settings/model';
6
7
  export const DNS_ADMIN_SECTION: AdminSection = {
7
8
  id: 'dns', title: 'DNS', icon: 'bi-globe2', navigationGroup: 'infrastructure', basePath: 'dns', permissions: DNS_VIEW_PERMISSIONS,
8
9
  routes: [
9
- { path: 'domains', label: 'Domains', loadComponent: () => import('../dns/DomainsPage').then(({ DomainsPage }) => ({ default: DomainsPage })), permissions: DNS_VIEW_PERMISSIONS },
10
- { path: 'records', label: 'DNS Records', loadComponent: () => import('../dns/DnsRecordsPage').then(({ DnsRecordsPage }) => ({ default: DnsRecordsPage })), permissions: DNS_VIEW_PERMISSIONS },
11
- { path: 'certificates', label: 'Certificates', loadComponent: () => import('../dns/CertificatesPage').then(({ CertificatesPage }) => ({ default: CertificatesPage })), permissions: DNS_VIEW_PERMISSIONS },
12
- { path: 'purchases', label: 'Domain Purchases', loadComponent: () => import('../dns/DomainPurchasesPage').then(({ DomainPurchasesPage }) => ({ default: DomainPurchasesPage })), permissions: DNS_VIEW_PERMISSIONS },
13
- { path: 'registrant', label: 'Registrant Contact', loadComponent: () => import('../dns/RegistrantContactPage').then(({ RegistrantContactPage }) => ({ default: RegistrantContactPage })), permissions: DNS_MANAGE_PERMISSIONS },
14
- { path: 'credentials', label: 'Provider Credentials', loadComponent: () => import('../dns/ProviderCredentialsPage').then(({ ProviderCredentialsPage }) => ({ default: ProviderCredentialsPage })), permissions: DNS_VIEW_PERMISSIONS },
10
+ { path: 'domains', label: 'Domains', loadComponent: () => import('../dns/DomainsPage').then(namedPage('DomainsPage')), permissions: DNS_VIEW_PERMISSIONS },
11
+ { path: 'records', label: 'DNS Records', loadComponent: () => import('../dns/DnsRecordsPage').then(namedPage('DnsRecordsPage')), permissions: DNS_VIEW_PERMISSIONS },
12
+ { path: 'certificates', label: 'Certificates', loadComponent: () => import('../dns/CertificatesPage').then(namedPage('CertificatesPage')), permissions: DNS_VIEW_PERMISSIONS },
13
+ { path: 'purchases', label: 'Domain Purchases', loadComponent: () => import('../dns/DomainPurchasesPage').then(namedPage('DomainPurchasesPage')), permissions: DNS_VIEW_PERMISSIONS },
14
+ { path: 'registrant', label: 'Registrant Contact', loadComponent: () => import('../dns/RegistrantContactPage').then(namedPage('RegistrantContactPage')), permissions: DNS_MANAGE_PERMISSIONS },
15
+ { path: 'credentials', label: 'Provider Credentials', loadComponent: () => import('../dns/ProviderCredentialsPage').then(namedPage('ProviderCredentialsPage')), permissions: DNS_VIEW_PERMISSIONS },
15
16
  ],
16
17
  };
17
18
  export const STORAGE_ADMIN_SECTION: AdminSection = {
18
19
  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],
19
20
  routes: [
20
- { path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(({ BucketsPage }) => ({ default: BucketsPage })), permissions: BUCKET_MANAGE_PERMS },
21
- { path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(({ BackendsPage }) => ({ default: BackendsPage })), permissions: STORAGE_VIEW_PERMS },
22
- { path: 'files', label: 'Files', loadComponent: () => import('../storage/FilesPage').then(({ FilesPage }) => ({ default: FilesPage })), permissions: STORAGE_VIEW_PERMS },
21
+ { path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(namedPage('BucketsPage')), permissions: BUCKET_MANAGE_PERMS },
22
+ { path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(namedPage('BackendsPage')), permissions: STORAGE_VIEW_PERMS },
23
+ { path: 'files', label: 'Files', loadComponent: () => import('../storage/FilesPage').then(namedPage('FilesPage')), permissions: STORAGE_VIEW_PERMS },
23
24
  // 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 },
25
+ { path: 'rendering', label: 'Media rendering', loadComponent: () => import('../storage/MediaRenderingPage').then(namedPage('MediaRenderingPage')), permissions: SETTINGS_PERMISSIONS },
25
26
  ],
26
27
  };
27
28
  export const EDGE_ADMIN_SECTION: AdminSection = {
28
29
  id: 'edge', basePath: 'edge', title: 'Edge', icon: 'bi-hdd-network', navigationGroup: 'infrastructure', permissions: DNS_VIEW_PERMISSIONS,
29
30
  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 },
31
+ { path: 'vhosts', label: 'Serving', loadComponent: () => import('../edge/VhostsPage').then(namedPage('VhostsPage')), permissions: DNS_VIEW_PERMISSIONS },
32
+ { path: 'upstreams', label: 'Upstreams', loadComponent: () => import('../edge/UpstreamsPage').then(namedPage('UpstreamsPage')), permissions: DNS_VIEW_PERMISSIONS },
32
33
  ],
33
34
  };
34
- export const INFRASTRUCTURE_ADMIN_SECTIONS = [DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION, EDGE_ADMIN_SECTION] as const;
35
+ export const FLEET_ADMIN_SECTION: AdminSection = {
36
+ id: 'fleet', title: 'Fleet', icon: 'bi-cloud', navigationGroup: 'operations',
37
+ permissions: ['sys.view_admin', 'sys.manage_users', 'sys.manage_settings', 'sys.admin', 'sys.manage_aws'],
38
+ routes: [{ path: '', label: 'Fleet', loadComponent: () => import('../fleet/FleetPage').then(namedPage('FleetPage')) }],
39
+ };
40
+ export const INFRASTRUCTURE_ADMIN_SECTIONS = [FLEET_ADMIN_SECTION, DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION, EDGE_ADMIN_SECTION] as const;
@@ -0,0 +1,6 @@
1
+ import type { ComponentType } from 'react';
2
+
3
+ /** Adapt an explicit named page export; every route keeps its literal lazy import. */
4
+ export function namedPage<TName extends string>(name: TName) {
5
+ return <TModule extends Record<TName, ComponentType>>(module: TModule) => ({ default: module[name] });
6
+ }
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { CLOUDWATCH_PERMISSIONS } from '../cloudwatch/data';
3
4
  import { DASHBOARD_METRIC_PERMISSIONS } from '../dashboard/data';
@@ -10,19 +11,19 @@ import { SECURITY_VIEW_PERMS } from '../security-permissions';
10
11
  export const ADMIN_DASHBOARD_PERMISSIONS = [...new Set([...DASHBOARD_METRIC_PERMISSIONS, ...SECURITY_VIEW_PERMS, ...JOBS_VIEW_PERMS, ...EMAIL_ADMIN_PERMISSIONS, ...LOGIN_SUMMARY_PERMS])];
11
12
  export const DASHBOARD_ADMIN_SECTION: AdminSection = {
12
13
  id: 'dashboard', basePath: '', title: 'Dashboard', icon: 'bi-grid-1x2', navigationGroup: null, permissions: ADMIN_DASHBOARD_PERMISSIONS,
13
- routes: [{ path: '', label: 'Dashboard', loadComponent: () => import('../dashboard/AdminDashboardPage').then(({ AdminDashboardPage }) => ({ default: AdminDashboardPage })), permissions: ADMIN_DASHBOARD_PERMISSIONS, fallbackToFirstVisible: true }],
14
+ routes: [{ path: '', label: 'Dashboard', loadComponent: () => import('../dashboard/AdminDashboardPage').then(namedPage('AdminDashboardPage')), permissions: ADMIN_DASHBOARD_PERMISSIONS, fallbackToFirstVisible: true }],
14
15
  };
15
16
  export const MONITORING_ADMIN_SECTION: AdminSection = {
16
17
  id: 'monitoring', basePath: '', title: 'Monitoring', icon: 'bi-activity', navigationGroup: 'observability',
17
18
  permissions: [...new Set([...LOGS_ADMIN_PERMISSIONS, ...METRICS_EXPLORER_PERMISSIONS, ...METRICS_PERMISSIONS_ADMIN_PERMISSIONS])],
18
19
  routes: [
19
- { path: 'logs', label: 'Logs', loadComponent: () => import('../monitoring/LogsPage').then(({ LogsPage }) => ({ default: LogsPage })), permissions: LOGS_ADMIN_PERMISSIONS },
20
- { path: 'metrics/explorer', label: 'Metrics Explorer', loadComponent: () => import('../monitoring/MetricsExplorerPage').then(({ MetricsExplorerPage }) => ({ default: MetricsExplorerPage })), permissions: METRICS_EXPLORER_PERMISSIONS },
21
- { path: 'metrics/permissions', label: 'Metrics Permissions', loadComponent: () => import('../monitoring/MetricsPermissionsPage').then(({ MetricsPermissionsPage }) => ({ default: MetricsPermissionsPage })), permissions: METRICS_PERMISSIONS_ADMIN_PERMISSIONS },
20
+ { path: 'logs', label: 'Logs', loadComponent: () => import('../monitoring/LogsPage').then(namedPage('LogsPage')), permissions: LOGS_ADMIN_PERMISSIONS },
21
+ { path: 'metrics/explorer', label: 'Metrics Explorer', loadComponent: () => import('../monitoring/MetricsExplorerPage').then(namedPage('MetricsExplorerPage')), permissions: METRICS_EXPLORER_PERMISSIONS },
22
+ { path: 'metrics/permissions', label: 'Metrics Permissions', loadComponent: () => import('../monitoring/MetricsPermissionsPage').then(namedPage('MetricsPermissionsPage')), permissions: METRICS_PERMISSIONS_ADMIN_PERMISSIONS },
22
23
  ],
23
24
  };
24
25
  export const CLOUDWATCH_ADMIN_SECTION: AdminSection = {
25
26
  id: 'cloudwatch', title: 'CloudWatch', icon: 'bi-clouds', navigationGroup: 'observability', permissions: CLOUDWATCH_PERMISSIONS,
26
- routes: [{ path: '', label: 'CloudWatch', loadComponent: () => import('../cloudwatch/CloudWatchDashboardPage').then(({ CloudWatchDashboardPage }) => ({ default: CloudWatchDashboardPage })), permissions: CLOUDWATCH_PERMISSIONS }],
27
+ routes: [{ path: '', label: 'CloudWatch', loadComponent: () => import('../cloudwatch/CloudWatchDashboardPage').then(namedPage('CloudWatchDashboardPage')), permissions: CLOUDWATCH_PERMISSIONS }],
27
28
  };
28
29
  export const OBSERVABILITY_ADMIN_SECTIONS = [DASHBOARD_ADMIN_SECTION, MONITORING_ADMIN_SECTION, CLOUDWATCH_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { JOBS_VIEW_PERMS, SCHEDULED_TASK_VIEW_PERMS } from '../jobs/models';
3
4
  import { SETTINGS_PERMISSIONS } from '../settings/model';
@@ -6,22 +7,36 @@ const navigationGroup = 'operations';
6
7
 
7
8
  export const SETTINGS_ADMIN_SECTION: AdminSection = {
8
9
  id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup, permissions: SETTINGS_PERMISSIONS,
9
- routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(({ SettingsPage }) => ({ default: SettingsPage })), permissions: SETTINGS_PERMISSIONS }],
10
+ routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(namedPage('SettingsPage')), permissions: SETTINGS_PERMISSIONS }],
10
11
  };
11
12
  // Empty any-of permissions admit only the existing literal-superuser bypass.
12
13
  // A global admin permission alone must not expose this configuration surface.
13
14
  export const FLEET_CONFIGURATION_ADMIN_SECTION: AdminSection = {
14
15
  id: 'fleet-configuration', title: 'Fleet Configuration', icon: 'bi-hdd-network',
15
16
  navigationGroup, permissions: [],
16
- routes: ['', 'shared-validation'].map((path) => ({ path, requireSuperuser: true, loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(({ FleetConfigurationPage }) => ({ default: FleetConfigurationPage })) })),
17
+ routes: ['', 'shared-validation'].map((path) => ({ path, requireSuperuser: true, loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(namedPage('FleetConfigurationPage')) })),
17
18
  };
18
19
  export const JOBS_ADMIN_SECTION: AdminSection = {
19
20
  id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup, permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
20
21
  routes: [
21
- { path: '', label: 'Dashboard', loadComponent: () => import('../jobs/JobDashboardPage').then(({ JobDashboardPage }) => ({ default: JobDashboardPage })), permissions: JOBS_VIEW_PERMS },
22
- { path: 'runners', label: 'Runners', loadComponent: () => import('../jobs/JobRunnersPage').then(({ JobRunnersPage }) => ({ default: JobRunnersPage })), permissions: JOBS_VIEW_PERMS },
23
- { path: 'list', label: 'Jobs', loadComponent: () => import('../jobs/JobsTablePage').then(({ JobsTablePage }) => ({ default: JobsTablePage })), permissions: JOBS_VIEW_PERMS },
24
- { path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(({ ScheduledTasksPage }) => ({ default: ScheduledTasksPage })), permissions: SCHEDULED_TASK_VIEW_PERMS },
22
+ { path: '', label: 'Dashboard', loadComponent: () => import('../jobs/JobDashboardPage').then(namedPage('JobDashboardPage')), permissions: JOBS_VIEW_PERMS },
23
+ { path: 'runners', label: 'Runners', loadComponent: () => import('../jobs/JobRunnersPage').then(namedPage('JobRunnersPage')), permissions: JOBS_VIEW_PERMS },
24
+ { path: 'list', label: 'Jobs', loadComponent: () => import('../jobs/JobsTablePage').then(namedPage('JobsTablePage')), permissions: JOBS_VIEW_PERMS },
25
+ { path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(namedPage('ScheduledTasksPage')), permissions: SCHEDULED_TASK_VIEW_PERMS },
25
26
  ],
26
27
  };
27
- export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
28
+ export const DEPLOYMENTS_ADMIN_SECTION: AdminSection = {
29
+ id: 'deployments', title: 'Deployments', icon: 'bi-rocket', navigationGroup,
30
+ permissions: ['sys.view_platform', 'sys.manage_platform', 'sys.admin', 'sys.view_dns', 'sys.manage_dns', 'sys.security'],
31
+ routes: [{ path: '', label: 'Deployments', loadComponent: () => import('../deployments/DeploymentsPage').then(namedPage('DeploymentsPage')) }],
32
+ };
33
+ export const MAINTENANCE_ADMIN_SECTION: AdminSection = {
34
+ id: 'maintenance', title: 'Maintenance', icon: 'bi-tools', navigationGroup,
35
+ permissions: ['sys.manage_aws', 'sys.view_platform', 'sys.manage_platform', 'sys.admin'],
36
+ routes: [{ path: '', label: 'Maintenance', loadComponent: () => import('../maintenance/MaintenancePage').then(namedPage('MaintenancePage')) }],
37
+ };
38
+ export const GEOLOCATION_ADMIN_SECTION: AdminSection = {
39
+ id: 'geolocation', title: 'GeoLocation', icon: 'bi-geo-alt', navigationGroup, permissions: [],
40
+ routes: [{ path: '', label: 'GeoLocation', requireSuperuser: true, loadComponent: () => import('../geolocation/GeoLocationPage').then(namedPage('GeoLocationPage')) }],
41
+ };
42
+ export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION, MAINTENANCE_ADMIN_SECTION, DEPLOYMENTS_ADMIN_SECTION, GEOLOCATION_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminRoute, AdminSection } from '../core';
2
3
  import { BOUNCER_VIEW_PERMS } from '../bouncer/models';
3
4
  import { EDGE_BLOCKLIST_VIEW_PERMS } from '../edge/permissions';
@@ -9,48 +10,48 @@ import { GEOFENCE_VIEW_PERMS } from '../network/geofence/permissions';
9
10
  import { IPSET_VIEW_PERMS } from '../network/permissions';
10
11
 
11
12
  export const INCIDENTS_ADMIN_ROUTES: AdminRoute[] = [
12
- { path: 'incidents', label: 'Incidents', loadComponent: () => import('../incidents/IncidentsPage').then(({ IncidentsPage }) => ({ default: IncidentsPage })), permissions: SECURITY_VIEW_PERMS },
13
- { path: 'events', label: 'Events', loadComponent: () => import('../incidents/EventsPage').then(({ EventsPage }) => ({ default: EventsPage })), permissions: SECURITY_VIEW_PERMS },
13
+ { path: 'incidents', label: 'Incidents', loadComponent: () => import('../incidents/IncidentsPage').then(namedPage('IncidentsPage')), permissions: SECURITY_VIEW_PERMS },
14
+ { path: 'events', label: 'Events', loadComponent: () => import('../incidents/EventsPage').then(namedPage('EventsPage')), permissions: SECURITY_VIEW_PERMS },
14
15
  ];
15
16
  export const RULES_ADMIN_ROUTES: AdminRoute[] = [
16
- { path: 'rules', label: 'Rule Engine', loadComponent: () => import('../rules/RuleSetsPage').then(({ RuleSetsPage }) => ({ default: RuleSetsPage })), permissions: SECURITY_VIEW_PERMS },
17
+ { path: 'rules', label: 'Rule Engine', loadComponent: () => import('../rules/RuleSetsPage').then(namedPage('RuleSetsPage')), permissions: SECURITY_VIEW_PERMS },
17
18
  ];
18
19
 
19
20
  export const SECURITY_OPERATIONS_ADMIN_SECTION: AdminSection = {
20
21
  id: 'security-operations', basePath: 'security', title: 'Security Operations', icon: 'bi-shield-check', navigationGroup: 'security', permissions: SECURITY_VIEW_PERMS,
21
22
  routes: [
22
- { path: 'tickets', label: 'Tickets', loadComponent: () => import('../security/tickets').then(({ TicketsPage }) => ({ default: TicketsPage })), permissions: SECURITY_VIEW_PERMS },
23
+ { path: 'tickets', label: 'Tickets', loadComponent: () => import('../security/tickets').then(namedPage('TicketsPage')), permissions: SECURITY_VIEW_PERMS },
23
24
  ...INCIDENTS_ADMIN_ROUTES, ...RULES_ADMIN_ROUTES,
24
25
  ],
25
26
  };
26
27
  export const BOUNCER_ADMIN_SECTION: AdminSection = {
27
28
  id: 'bouncer', basePath: 'security/bouncer', title: 'Bouncer', icon: 'bi-shield-check', navigationGroup: 'security', permissions: BOUNCER_VIEW_PERMS,
28
29
  routes: [
29
- { path: 'signals', label: 'Signal Decisions', loadComponent: () => import('../bouncer/signals').then(({ BouncerSignalsPage }) => ({ default: BouncerSignalsPage })), permissions: BOUNCER_VIEW_PERMS },
30
- { path: 'devices', label: 'Device Reputation', loadComponent: () => import('../bouncer/devices').then(({ BouncerDevicesPage }) => ({ default: BouncerDevicesPage })), permissions: BOUNCER_VIEW_PERMS },
31
- { path: 'signatures', label: 'Bot Signatures', loadComponent: () => import('../bouncer/signatures').then(({ BotSignaturesPage }) => ({ default: BotSignaturesPage })), permissions: BOUNCER_VIEW_PERMS },
30
+ { path: 'signals', label: 'Signal Decisions', loadComponent: () => import('../bouncer/signals').then(namedPage('BouncerSignalsPage')), permissions: BOUNCER_VIEW_PERMS },
31
+ { path: 'devices', label: 'Device Reputation', loadComponent: () => import('../bouncer/devices').then(namedPage('BouncerDevicesPage')), permissions: BOUNCER_VIEW_PERMS },
32
+ { path: 'signatures', label: 'Bot Signatures', loadComponent: () => import('../bouncer/signatures').then(namedPage('BotSignaturesPage')), permissions: BOUNCER_VIEW_PERMS },
32
33
  ],
33
34
  };
34
35
  export const DEVICE_INTEL_ADMIN_SECTION: AdminSection = {
35
36
  id: 'device-intel', basePath: 'security/devices', title: 'Devices & Logins', icon: 'bi-laptop', navigationGroup: 'security', permissions: [...new Set([...USER_DEVICE_VIEW_PERMS, ...LOGIN_EVENT_VIEW_PERMS])],
36
37
  routes: [
37
- { path: 'user-devices', label: 'User Devices', loadComponent: () => import('../security/devices/UserDevicesPage').then(({ UserDevicesPage }) => ({ default: UserDevicesPage })), permissions: USER_DEVICE_VIEW_PERMS },
38
- { path: 'login-locations', label: 'Login Locations', loadComponent: () => import('../security/devices/LoginLocationsPage').then(({ LoginLocationsPage }) => ({ default: LoginLocationsPage })), permissions: LOGIN_EVENT_VIEW_PERMS },
38
+ { path: 'user-devices', label: 'User Devices', loadComponent: () => import('../security/devices/UserDevicesPage').then(namedPage('UserDevicesPage')), permissions: USER_DEVICE_VIEW_PERMS },
39
+ { path: 'login-locations', label: 'Login Locations', loadComponent: () => import('../security/devices/LoginLocationsPage').then(namedPage('LoginLocationsPage')), permissions: LOGIN_EVENT_VIEW_PERMS },
39
40
  ],
40
41
  };
41
42
  export const GEOIP_ADMIN_SECTION: AdminSection = {
42
43
  id: 'geoip', basePath: 'security/geoip', title: 'IP Intelligence', icon: 'bi-globe2', navigationGroup: 'security', permissions: GEOIP_VIEW_PERMS,
43
- routes: [{ path: 'cache', label: 'GeoIP Cache', loadComponent: () => import('../security/geoip/GeoIpCachePage').then(({ GeoIpCachePage }) => ({ default: GeoIpCachePage })), permissions: GEOIP_VIEW_PERMS }],
44
+ routes: [{ path: 'cache', label: 'GeoIP Cache', loadComponent: () => import('../security/geoip/GeoIpCachePage').then(namedPage('GeoIpCachePage')), permissions: GEOIP_VIEW_PERMS }],
44
45
  };
45
46
  export const NETWORK_SECURITY_ADMIN_SECTION: AdminSection = {
46
47
  id: 'network-security', basePath: 'security/network', title: 'Network Security', icon: 'bi-hdd-network', navigationGroup: 'security',
47
48
  permissions: [...new Set([...GEOIP_VIEW_PERMS, ...LOGS_ADMIN_PERMISSIONS, ...IPSET_VIEW_PERMS, ...GEOFENCE_VIEW_PERMS, ...EDGE_BLOCKLIST_VIEW_PERMS])],
48
49
  routes: [
49
- { path: 'blocked-ips', label: 'Blocked IPs', loadComponent: () => import('../network/BlockedIPsPage').then(({ BlockedIPsPage }) => ({ default: BlockedIPsPage })), permissions: GEOIP_VIEW_PERMS },
50
- { path: 'firewall-log', label: 'Firewall Log', loadComponent: () => import('../network/FirewallLogPage').then(({ FirewallLogPage }) => ({ default: FirewallLogPage })), permissions: LOGS_ADMIN_PERMISSIONS },
51
- { path: 'ip-sets', label: 'IP Sets', loadComponent: () => import('../network/IPSetsPage').then(({ IPSetsPage }) => ({ default: IPSetsPage })), permissions: IPSET_VIEW_PERMS },
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 },
50
+ { path: 'blocked-ips', label: 'Blocked IPs', loadComponent: () => import('../network/BlockedIPsPage').then(namedPage('BlockedIPsPage')), permissions: GEOIP_VIEW_PERMS },
51
+ { path: 'firewall-log', label: 'Firewall Log', loadComponent: () => import('../network/FirewallLogPage').then(namedPage('FirewallLogPage')), permissions: LOGS_ADMIN_PERMISSIONS },
52
+ { path: 'ip-sets', label: 'IP Sets', loadComponent: () => import('../network/IPSetsPage').then(namedPage('IPSetsPage')), permissions: IPSET_VIEW_PERMS },
53
+ { path: 'geofencing', label: 'Geofencing', loadComponent: () => import('../network/geofence/GeofencingPage').then(namedPage('GeofencingPage')), permissions: GEOFENCE_VIEW_PERMS },
54
+ { path: 'edge-blocklist', label: 'Edge Blocklist', loadComponent: () => import('../edge/BlocklistPage').then(namedPage('BlocklistPage')), permissions: EDGE_BLOCKLIST_VIEW_PERMS },
54
55
  ],
55
56
  };
56
57
 
@@ -0,0 +1,10 @@
1
+ import { useId, useRef, useState, type ReactNode } from 'react';
2
+ import { Popover } from '../../ui';
3
+
4
+ /** Short, keyboard-accessible help, including inside native dialogs. */
5
+ export function EdgeHelp({ label, children }: { label: string; children: ReactNode }) {
6
+ const anchor = useRef<HTMLButtonElement>(null);
7
+ const [open, setOpen] = useState(false);
8
+ const id = useId();
9
+ return <span className="edge-help"><button ref={anchor} type="button" className="btn-icon" aria-label={label} aria-expanded={open} aria-controls={open ? id : undefined} onClick={() => setOpen(value => !value)}><i className="bi bi-info-circle" aria-hidden="true" /></button><Popover id={id} anchorRef={anchor} open={open} onClose={() => setOpen(false)} className="edge-help-popover" aria-label={label}>{children}</Popover></span>;
10
+ }