portal-mojo 0.2.19 → 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 (157) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-credentials.md +27 -0
  3. package/docs/admin-deployments.md +201 -0
  4. package/docs/admin-dns-certificates.md +160 -0
  5. package/docs/admin-dns-domains.md +92 -0
  6. package/docs/admin-dns.md +18 -0
  7. package/docs/admin-edge.md +200 -196
  8. package/docs/admin-fleet-configuration.md +98 -2
  9. package/docs/admin-geolocation.md +100 -0
  10. package/docs/admin-identity-directories.md +12 -0
  11. package/docs/admin-maintenance.md +133 -0
  12. package/docs/admin-modals.md +45 -2
  13. package/docs/admin-operational-fleet.md +206 -0
  14. package/docs/admin-presentation.md +51 -0
  15. package/docs/admin-signin.md +12 -2
  16. package/docs/auth-pages.md +18 -6
  17. package/docs/auth.md +16 -2
  18. package/docs/client.md +7 -0
  19. package/docs/collection-select.md +14 -0
  20. package/docs/combobox.md +18 -0
  21. package/docs/popover.md +5 -4
  22. package/package.json +1 -1
  23. package/src/admin/core/OperationsHelp.tsx +10 -0
  24. package/src/admin/core/index.ts +6 -4
  25. package/src/admin/core/route-context.ts +6 -0
  26. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  27. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  28. package/src/admin/credentials/group-api-keys.tsx +5 -3
  29. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  30. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  31. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  32. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  33. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  34. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  35. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  36. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  37. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  38. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  39. package/src/admin/deployments/api.ts +158 -0
  40. package/src/admin/deployments/authority-state.ts +9 -0
  41. package/src/admin/deployments/deployment-state.ts +13 -0
  42. package/src/admin/deployments/index.ts +4 -0
  43. package/src/admin/deployments/onboarding-state.ts +11 -0
  44. package/src/admin/deployments/permissions.ts +37 -0
  45. package/src/admin/deployments/receipts.ts +78 -0
  46. package/src/admin/deployments/sanitize.ts +73 -0
  47. package/src/admin/deployments/shared.tsx +39 -0
  48. package/src/admin/deployments/types.ts +238 -0
  49. package/src/admin/deployments/upload.ts +53 -0
  50. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  51. package/src/admin/dns/CertificateDetail.tsx +8 -3
  52. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  53. package/src/admin/dns/DomainDetail.tsx +14 -4
  54. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  55. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  56. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  57. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  58. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  59. package/src/admin/dns/DomainsPage.tsx +26 -13
  60. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  61. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  62. package/src/admin/dns/api.ts +4 -2
  63. package/src/admin/dns/domain-navigation.ts +16 -0
  64. package/src/admin/dns/registrar-api.ts +13 -0
  65. package/src/admin/dns/shared-validation-api.ts +90 -0
  66. package/src/admin/dns/shared-validation-data.ts +106 -0
  67. package/src/admin/domains/assistant.ts +5 -4
  68. package/src/admin/domains/communications.ts +10 -9
  69. package/src/admin/domains/identity.ts +8 -7
  70. package/src/admin/domains/infrastructure.ts +19 -13
  71. package/src/admin/domains/lazy-page.ts +6 -0
  72. package/src/admin/domains/observability.ts +6 -5
  73. package/src/admin/domains/operations.ts +27 -10
  74. package/src/admin/domains/security.ts +16 -15
  75. package/src/admin/edge/EdgeHelp.tsx +10 -0
  76. package/src/admin/edge/ServingReadiness.tsx +54 -0
  77. package/src/admin/edge/VhostDetail.tsx +145 -139
  78. package/src/admin/edge/VhostWizard.tsx +204 -115
  79. package/src/admin/edge/VhostsPage.tsx +27 -28
  80. package/src/admin/edge/api.ts +24 -10
  81. package/src/admin/edge/models.ts +4 -4
  82. package/src/admin/edge/serving-data.ts +78 -0
  83. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  84. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  85. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  86. package/src/admin/fleet/FleetHelp.tsx +7 -0
  87. package/src/admin/fleet/FleetOverview.tsx +150 -0
  88. package/src/admin/fleet/FleetPage.tsx +11 -0
  89. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  90. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  91. package/src/admin/fleet/access.ts +22 -0
  92. package/src/admin/fleet/api.ts +77 -0
  93. package/src/admin/fleet/data.ts +148 -0
  94. package/src/admin/fleet/index.ts +8 -0
  95. package/src/admin/fleet/operations.ts +179 -0
  96. package/src/admin/fleet/standalone-data.ts +87 -0
  97. package/src/admin/fleet/types.ts +208 -0
  98. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  99. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  100. package/src/admin/fleet-configuration/data.ts +4 -0
  101. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  102. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  103. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  104. package/src/admin/geolocation/data.ts +138 -0
  105. package/src/admin/geolocation/index.ts +3 -0
  106. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  107. package/src/admin/identity/groups/models.ts +2 -0
  108. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  109. package/src/admin/index.ts +8 -0
  110. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  111. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  112. package/src/admin/maintenance/data.ts +486 -0
  113. package/src/admin/maintenance/index.ts +3 -0
  114. package/src/admin/maintenance/operations.ts +208 -0
  115. package/src/admin/public/infrastructure.ts +1 -0
  116. package/src/admin/public/operations.ts +3 -0
  117. package/src/admin/registry.ts +6 -2
  118. package/src/admin/settings/SettingDetail.tsx +7 -2
  119. package/src/admin/settings/SettingEditor.tsx +2 -1
  120. package/src/admin/settings/SettingsPage.tsx +3 -1
  121. package/src/admin/settings/model.ts +1 -0
  122. package/src/admin/signin/SigninPage.tsx +17 -3
  123. package/src/admin/signin/api.ts +2 -0
  124. package/src/charts/MetricsChartView.tsx +4 -11
  125. package/src/charts/MetricsMiniWidget.tsx +3 -15
  126. package/src/client/auth.ts +77 -25
  127. package/src/client/client.ts +6 -0
  128. package/src/client/deployments-mock.ts +375 -0
  129. package/src/client/domain-operations-mock.ts +37 -0
  130. package/src/client/fleet-mock.ts +73 -1
  131. package/src/client/geo-provider-mock.ts +82 -0
  132. package/src/client/index.ts +2 -2
  133. package/src/client/maintenance-mock.ts +285 -0
  134. package/src/client/mock.ts +280 -11
  135. package/src/client/operational-fleet-mock.ts +279 -0
  136. package/src/client/operations-mock.ts +85 -0
  137. package/src/client/serving-mock.ts +22 -0
  138. package/src/styles/components/admin-deployments.css +35 -0
  139. package/src/styles/components/admin-dns-certificates.css +33 -0
  140. package/src/styles/components/admin-domains.css +12 -0
  141. package/src/styles/components/admin-edge.css +125 -1
  142. package/src/styles/components/admin-geolocation.css +40 -0
  143. package/src/styles/components/admin-maintenance.css +127 -0
  144. package/src/styles/components/admin-operational-fleet.css +38 -0
  145. package/src/styles/components/admin-operations.css +9 -0
  146. package/src/styles/components/admin-signin.css +2 -0
  147. package/src/styles/components/combobox.css +12 -2
  148. package/src/styles/components/fleet-page.css +9 -0
  149. package/src/styles/index.css +7 -0
  150. package/src/ui/CollectionSelect.tsx +30 -8
  151. package/src/ui/ComboBox.tsx +39 -8
  152. package/src/ui/FilterBar.tsx +3 -5
  153. package/src/ui/ModelTable.tsx +2 -8
  154. package/src/ui/Popover.tsx +15 -7
  155. package/src/ui/date/TimePicker.tsx +4 -14
  156. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  157. package/src/ui/schema-form-core.tsx +3 -5
@@ -124,12 +124,18 @@ Client surface (`portal-mojo/client`):
124
124
  - `withFreshAuth(fn)` — run fn; on a 440 prompt, then retry **once** after a
125
125
  successful re-login; a dismissed prompt rejects with the ORIGINAL error.
126
126
 
127
- The modal re-prompts credentials OVER the current screen — page state
128
- survives. It pins the identity to the current session's email (step-up is
129
- "prove you are still you"), offers password + passkey + the MFA step, and
130
- keeps the session in its original storage (`sessionIsPersistent()` →
131
- `remember`). Dev affordance: `__mojo.requestFreshAuth()` in the console
132
- exercises the modal without a 440.
127
+ In hosted mode (including packaged Admin), the native dialog offers Cancel
128
+ and Continue to sign-in. Continue opens the existing configured sign-in page
129
+ with `force_reauth=1`, so Google-only deployments can use Google. It preserves
130
+ the exact return route and original storage choice. The pending challenge
131
+ resolves false before unloading: after returning, re-read and deliberately
132
+ retry the action. Picking another account starts a new session and screen;
133
+ the former account's operation is never carried over.
134
+
135
+ In explicit in-app/mock mode the modal re-prompts credentials over the current
136
+ screen, pins the current email when available, and keeps the existing
137
+ password/passkey/MFA and storage behavior. Dev affordance:
138
+ `__mojo.requestFreshAuth()` exercises the modal without a 440.
133
139
 
134
140
  Call-site pattern:
135
141
 
@@ -172,6 +178,12 @@ bouncer-enforcing deployments.
172
178
 
173
179
  ## Mock affordances
174
180
 
181
+ `npm run verify:hosted-reauth` covers the mounted hosted confirmation and
182
+ return/storage paths. For native dialog, responsive and theme checks, run
183
+ `node scripts/serve-hosted-reauth.mjs` and open its printed local URL. The
184
+ fixture uses the actual Portal host/bridge and a simulated Google-only sign-in
185
+ page; it does not prove a live OAuth provider exchange.
186
+
175
187
  Under the mock transport the pages show inline dev hints: password `mojo`
176
188
  for any seeded user (e.g. `ian@mojoverify.com`); Maya (seeded user 2) requires
177
189
  MFA; TOTP `123456`, recovery `mock-recovery`, and SMS `654321`; reset code
package/docs/auth.md CHANGED
@@ -102,11 +102,25 @@ its bot-check interstitial). Flow:
102
102
  `<origin>/auth?redirect=<HASH-FREE current url>`. Hash-free is
103
103
  load-bearing: the page string-appends `?auth_code=<code>` to the
104
104
  redirect, and a `#` in it would swallow the code.
105
- 2. User signs in on the hosted page; it mints a single-use 60s handoff code
105
+ 2. User signs in using that page's configured methods (Google, password,
106
+ passkey, etc.). Same-origin return uses its existing stored session;
107
+ a cross-origin return mints a single-use 60s handoff code
106
108
  (`POST /api/auth/handoff`) and navigates back with `?auth_code=`.
107
109
  3. `handleAuthCodeFromURL()` (boot) exchanges it (`POST /api/auth/exchange`),
108
110
  scrubs the code from the URL synchronously, restores the stashed route.
109
- Handles a hash-embedded `#/route?auth_code=` landing too.
111
+ It also restores same-origin returns without a code. Handles a
112
+ hash-embedded `#/route?auth_code=` landing too. Await this at boot before
113
+ creating the router.
114
+
115
+ `redirectToHostedAuth({forceReauth: true})` uses the same page with
116
+ `force_reauth=1`, bypassing its automatic existing-session check. It retains
117
+ the initiating persistent/session-only choice, including when the hosted
118
+ page writes localStorage. `remember` may explicitly supply the choice captured
119
+ when the challenge opened. Tab-local return metadata contains only the route,
120
+ destination, time and storage choice; it is consumed once, expires after 30
121
+ minutes, and applies only at the matching origin/path/query. It never carries
122
+ an operation or establishes freshness. The server still enforces `auth_time`.
123
+ When storage cannot retain the choice, step-up navigation returns false.
110
124
 
111
125
  `hostedAuthUrl()` returns null under the mock transport — there is no page
112
126
  to host; the UI should fall back to a dev affordance (`__mojo.login(...)`).
package/docs/client.md CHANGED
@@ -458,3 +458,10 @@ now **rejects** a flat HTTP-200 refusal (top-level `success:false`) with
458
458
  - `err.errorCode` carries the refusal code and `err.data` the full reply, so
459
459
  the server's reason is no longer lost.
460
460
  - `defineModel` actions and `mojoAction` behave as before.
461
+
462
+ `mojoCall` accepts optional `idempotencyKey` for a reviewed operation receipt. The
463
+ transport validates 1–128 ASCII receipt characters (`A-Z`, `a-z`, `0-9`, `.`, `_`,
464
+ `:`, `-`) and emits only the `Idempotency-Key` header. It does not expose arbitrary
465
+ custom headers or record the key with sensitive bodies. Preserve the same receipt
466
+ for a deliberate retry of the same operation; observe uncertain outcomes before
467
+ creating another operation.
@@ -130,3 +130,17 @@ too — there is no parallel fetch path.
130
130
  handed a picker that says to pick a group they can't pick there.
131
131
  - Don't wire `onChange` to a per-keystroke autosave expectation — it fires
132
132
  on commit (that is the point; see the B3 autosave contract).
133
+
134
+ ### Optional ingress projection
135
+
136
+ `sanitizeRow={(row) => publicProjection(row)}` projects list and one-record
137
+ responses before Query cache storage. Such pickers use a separate cache keyed
138
+ by the sanitizer identity, auth UID and session revision; they never scan or
139
+ reuse raw endpoint caches. Keep the callback stable. Default consumers retain
140
+ the existing shared model keys and behavior. The callback must return only the
141
+ approved fields or reject malformed input. Full selected objects also need to
142
+ be projected by the caller before passing them in.
143
+
144
+ An endpoint with query-based hydration should hydrate through its own safe
145
+ `?id=` helper and pass the full selected object; do not pass a bare id that
146
+ would invent a `/<id>` record route.
package/docs/combobox.md CHANGED
@@ -19,6 +19,7 @@ interface ComboOption {
19
19
  value: ComboValue;
20
20
  label: string; // what the input displays
21
21
  description?: string; // muted second line in the list
22
+ disabled?: boolean; // shown but never committed
22
23
  meta?: Record<string, unknown>; // opaque; returned via onSelect
23
24
  }
24
25
 
@@ -32,6 +33,8 @@ interface ComboOption {
32
33
  minChars={0} // typing auto-opens the list only at ≥ N chars
33
34
  maxSuggestions={10} // caps the rendered list AND keyboard wraparound
34
35
  placeholder disabled readOnly required id className
36
+ inputRef={fieldRef} // input focus target for form validation
37
+ aria-label="Output" aria-describedby="output-help" aria-invalid={invalid}
35
38
  />
36
39
  ```
37
40
 
@@ -103,3 +106,18 @@ strings.
103
106
  containers and native dialogs do not clip it.
104
107
  - Styles live in `packages/portal-mojo/src/styles/components/combobox.css` (tokens only, both
105
108
  themes) and ship in `portal-mojo/styles.css`.
109
+
110
+ ## Fixed-choice dropdowns
111
+
112
+ Use `allowCustom={false}` for enums and filters. Pass the same string/number
113
+ type to `value` and each option. Disabled options remain visible (including
114
+ a saved, unavailable selection); pointer, typed settlement and arrow navigation
115
+ cannot commit them. Permission loss closes the list and discards the draft.
116
+ Use `id` with a label or `aria-label`/`aria-labelledby`; description and validation
117
+ attributes reach the input. `inputRef` supports validation focus.
118
+
119
+ For short fixed lists that must show every choice, set `maxSuggestions` to the
120
+ list length; searchable catalogs may retain the default cap. Shared tables,
121
+ chart settings, filters and schema select fields use this same component.
122
+
123
+ The chevron is a pointer shortcut for the input. It is hidden from assistive technology so a wrapping field label does not include redundant button text; the combobox input owns the name and keyboard interaction.
package/docs/popover.md CHANGED
@@ -95,9 +95,10 @@ that here would close both layers on one keypress.)
95
95
  Outside-mousedown does NOT stop propagation: the click still activates
96
96
  whatever was under it (click a second trigger → this popover closes and
97
97
  that one opens). Clicks on the anchor are never "outside" — the anchor's
98
- own click handler owns its toggle. The listener attach is deferred one
99
- tick so the interaction that opened the popover can't immediately close
100
- it (source parity).
98
+ own click handler owns its toggle. Outside-click registration waits one tick so the opener cannot close its own
99
+ menu. Escape registration runs in a layout effect, before the opening frame
100
+ paints. Only the most recently opened popover handles Escape; a dialog opened
101
+ above its owner keeps its own dismissal behavior.
101
102
 
102
103
  ## Styling
103
104
 
@@ -125,7 +126,7 @@ baked in 20px padding + 260px min-width for its one tenant, the calendar
125
126
  to the consuming control.
126
127
  - The opening interaction can be `click` or `mousedown`-driven; both are
127
128
  safe. But if you open programmatically and simultaneously flip other
128
- document state, remember listeners attach a tick later.
129
+ document state, only outside-click registration waits a tick. Escape protection is immediate.
129
130
  - Placement flips only bottom→top (per source). If content can be taller
130
131
  than the viewport, cap and scroll it in YOUR content styles.
131
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
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",
@@ -0,0 +1,10 @@
1
+ import { useId, useRef, useState, type ReactNode } from 'react';
2
+ import { Popover } from '../../ui/Popover';
3
+
4
+ /** Keep explanatory copy one deliberate action away from the working surface. */
5
+ export function OperationsHelp({ label, children }: { label: string; children: ReactNode }) {
6
+ const anchor = useRef<HTMLButtonElement>(null);
7
+ const [open, setOpen] = useState(false);
8
+ const id = useId();
9
+ return <><button ref={anchor} type="button" className="ops-help" aria-label={`About ${label}`} aria-expanded={open} aria-controls={open ? id : undefined} onClick={event => { event.preventDefault(); setOpen(value => !value); }}><i className="bi bi-question-circle" aria-hidden="true" /></button><Popover anchorRef={anchor} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="ops-help-content">{children}</Popover></>;
10
+ }
@@ -7,6 +7,7 @@ import { isChunkLoadError } from '../../ui/route-error';
7
7
  import { GroupContext } from '../../client/group-context';
8
8
  import { hasPermission, useMe } from '../../client/me';
9
9
  import type { PermSpec } from '../../client/model';
10
+ import { AdminRouteContext, type AdminRouteContextValue } from './route-context';
10
11
  import { Guarded } from '../../ui/Guarded';
11
12
  import type { MenuConfig, MenuItem } from '../../ui/menu-registry';
12
13
 
@@ -127,7 +128,7 @@ function routeElement(route: AdminRoute): ReactNode {
127
128
  : createElement(route.component);
128
129
  }
129
130
 
130
- function guardedElement(section: AdminSection, route: AdminRoute, sections: readonly AdminSection[], mount: string): ReactNode {
131
+ function guardedElement(section: AdminSection, route: AdminRoute, sections: readonly AdminSection[], mount: string, context: AdminRouteContextValue): ReactNode {
131
132
  const fallback = route.fallbackToFirstVisible
132
133
  ? createElement(AdminMountLanding, { sections: sections.filter((candidate) => candidate !== section), mount })
133
134
  : createElement(AdminDenied);
@@ -146,9 +147,9 @@ function guardedElement(section: AdminSection, route: AdminRoute, sections: read
146
147
  // navigation between packaged admin pages silently stopped rendering
147
148
  // (reproduced in both the reference portal and a consumer). The key
148
149
  // forces a fresh mount, and a fresh Suspense, per route.
149
- return createElement(AdminGlobalScope, { key: relativePath(mount, section.basePath ?? section.id, route.path), children: createElement(Guarded, {
150
+ return createElement(AdminRouteContext.Provider, { key: relativePath(mount, section.basePath ?? section.id, route.path), value: context, children: createElement(AdminGlobalScope, { children: createElement(Guarded, {
150
151
  permission: section.permissions, fallback, children: routeGuarded,
151
- }) });
152
+ }) }) });
152
153
  }
153
154
 
154
155
  /** Admin is global even when embedded beneath a product portal's provider. */
@@ -182,6 +183,7 @@ function AdminMountLanding({ sections, mount }: { sections: readonly AdminSectio
182
183
  export function adminSectionRoutes(sections: readonly AdminSection[], opts: { mount?: string } = {}): RouteObject[] {
183
184
  const mount = relativePath(opts.mount ?? '');
184
185
  const routes: RouteObject[] = [];
186
+ const context: AdminRouteContextValue = { mount: absolutePath(mount), paths: sections.flatMap((section) => section.routes.map((route) => absolutePath(mount, section.basePath ?? section.id, route.path))) };
185
187
  const rootSections = sections.filter((section) => relativePath(section.basePath ?? section.id) === '');
186
188
  const rootHasIndex = rootSections.some((section) => section.routes.some((route) => relativePath(route.path) === ''));
187
189
  if (mount && rootSections.length > 0 && !rootHasIndex) routes.push({
@@ -198,7 +200,7 @@ export function adminSectionRoutes(sections: readonly AdminSection[], opts: { mo
198
200
  });
199
201
  for (const route of section.routes) routes.push({
200
202
  path: relativePath(mount, base, route.path),
201
- element: guardedElement(section, route, sections, mount),
203
+ element: guardedElement(section, route, sections, mount, context),
202
204
  });
203
205
  }
204
206
  return routes;
@@ -0,0 +1,6 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /** Internal registration facts for mount-aware links; never an authority decision. */
4
+ export interface AdminRouteContextValue { mount: string; paths: readonly string[] }
5
+ export const AdminRouteContext = createContext<AdminRouteContextValue | null>(null);
6
+ export const useAdminRouteContext = () => useContext(AdminRouteContext);
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { requestFreshAuth } from '../../client/runtime';
4
+ import { CollectionSelect, FlatRow, modal } from '../../ui';
5
+ import { SecretBox } from './secret-dialog';
6
+ import { OperatorError, usePrivateOperator, projectIdentity } from '../fleet-configuration/shared-validation-data';
7
+ import { issueAcmeKey, projectKeyGroup, readAcmeRecipient, readKeyGroup, readRecipientKeyInventory, revealRecipientKey, type AcmeRecipient, type KeyGroup, type KeySummary } from './acme-consumer-key';
8
+ import { GroupApiKeyModel, type CredentialGroup } from './models';
9
+
10
+
11
+ export function AcmeConsumerKeyButton({ group }: { group?: CredentialGroup }) {
12
+ const owner = usePrivateOperator();
13
+ return owner.allowed ? <button className="btn btn-compact" onClick={() => void openAcmeConsumerKey(group)}>Create ACME consumer key</button> : null;
14
+ }
15
+
16
+ export function openAcmeConsumerKey(group?: CredentialGroup) {
17
+ let pending = false;
18
+ return modal.open((close) => <AcmeConsumerKeyDialog group={group} close={() => close(null)} onBusy={(busy) => { pending = busy; }} />, { size: 'lg', canDismiss: () => !pending });
19
+ }
20
+ export function AcmeConsumerKeyDialog({ group: fixedGroup, close, onBusy }: { group?: CredentialGroup; close: () => void; onBusy?: (busy: boolean) => void }) {
21
+ const owner = usePrivateOperator(), qc = useQueryClient();
22
+ const [group, setGroup] = useState<KeyGroup | null>(null), [recipient, setRecipient] = useState<AcmeRecipient | null>(null);
23
+ const [inventory, setInventory] = useState<KeySummary[] | null>(null), [blocked, setBlocked] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
24
+ const name = useRef<HTMLInputElement>(null), flight = useRef(false), generation = useRef(0), recipientOwner = useRef('');
25
+ const selected = useRef(group?.id); selected.current = group?.id;
26
+ const opener = useRef<HTMLElement | null>(null);
27
+ const pending = (value: boolean) => {
28
+ if (value) opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
29
+ flight.current = value; setBusy(value); onBusy?.(value);
30
+ if (!value) { const fence = owner.fence(), target = opener.current; requestAnimationFrame(() => { try { fence.beforeSend(); if (target?.isConnected) target.focus(); } catch { /* Former owner cannot regain focus. */ } }); }
31
+ };
32
+ useEffect(() => () => { generation.current++; onBusy?.(false); }, []);
33
+ useEffect(() => { setRecipient(null); setInventory(null); setBlocked(true); if (name.current) name.current.value = ''; }, [owner.key]);
34
+ const failure = async (reason: unknown) => { const safe = reason instanceof OperatorError ? reason : new OperatorError('unavailable'); setError(safe.message); setBlocked(true); if (safe.kind === 'fresh') await requestFreshAuth(); };
35
+ useEffect(() => {
36
+ if (!fixedGroup || !owner.allowed) return;
37
+ const fence = owner.fence(); void readKeyGroup(fixedGroup.id, fence).then((g) => { fence.beforeSend(); setGroup(g); }).catch((e) => { if (!fence.signal?.aborted) void failure(e); });
38
+ }, [fixedGroup?.id, owner.key]);
39
+ useEffect(() => { setRecipient(null); setInventory(null); setError(''); setBlocked(false); generation.current++; }, [group?.id]);
40
+ const refresh = async () => {
41
+ if (flight.current || !group || !owner.allowed) return;
42
+ const fence = owner.fence(), id = group.id, run = ++generation.current; pending(true); setError(''); setRecipient(null);
43
+ try {
44
+ const current = await readKeyGroup(id, fence); fence.beforeSend();
45
+ if (selected.current !== id || generation.current !== run) return;
46
+ if (!current.uuid) throw new OperatorError('invalid'); projectIdentity(current.uuid);
47
+ const [person, keys] = await Promise.all([readAcmeRecipient(current.uuid, fence), readRecipientKeyInventory(id, fence)]); fence.beforeSend();
48
+ if (selected.current !== id || generation.current !== run) return;
49
+ recipientOwner.current = owner.key; setGroup(current); setRecipient(person); setInventory(keys); setBlocked(false);
50
+ } catch (e) { if (!fence.signal?.aborted && generation.current === run) await failure(e); }
51
+ finally { if (!fence.signal?.aborted) pending(false); }
52
+ };
53
+ const displaySecret = async (value: string, title: string) => {
54
+ const fence = owner.fence(); let secret: string | null = value;
55
+ try { fence.beforeSend(); await modal.open((done) => <div className="modal-pad"><h2 className="modal-title">{title}</h2><p>This transient display is not stored in Portal. A later explicit Token reveal in the key inventory is audited and remains available.</p><SecretBox secret={secret ?? ''} ariaLabel="ACME consumer token" /><p>Enter the replacement normally in the receiving deployment’s protected connection form. Publish, Apply and verify every expected node before explicitly deactivating the old hub key. Issuance alone grants no allocation trust or hub activation.</p><div className="modal-actions"><button className="btn" onClick={() => done(null)}>Close token</button></div></div>, { size: 'lg', signal: fence.signal }); }
56
+ finally { secret = null; value = ''; }
57
+ };
58
+ const issue = async () => {
59
+ if (flight.current || !owner.allowed || recipientOwner.current !== owner.key || !recipient?.can_issue || !group || blocked) return;
60
+ const fence = owner.fence(), person = recipient, id = group.id, keyName = name.current?.value.trim() ?? ''; if (!keyName) { setError('Name this replacement key.'); return; }
61
+ const run = generation.current; pending(true); setError(''); let attempted = false;
62
+ try {
63
+ const confirmed = await modal.open<boolean>((done) => <div className="modal-pad"><h2 className="modal-title">Issue protected ACME consumer key?</h2><div className="modal-message"><p>{person.consumer_label} · <code>{person.consumer_project_uuid}</code></p><p>Key: {keyName}</p><p>Grant this exact original hub project the protected ACME validation consumer purpose. The fixed grant is dnsman_acme_federation, with no linked user or override. This does not grant allocation trust or enable shared validation. The existing key stays active.</p></div><div className="modal-actions"><button className="btn" onClick={() => done(false)}>Cancel</button><button className="btn btn-primary" onClick={() => done(true)}>Issue replacement key</button></div></div>, { size: 'md', signal: fence.signal });
64
+ if (!confirmed) return; fence.beforeSend(); if (selected.current !== id || generation.current !== run) throw new OperatorError('conflict');
65
+ attempted = true; const result = await issueAcmeKey(person, keyName, fence); fence.beforeSend();
66
+ if (selected.current !== id || generation.current !== run) { result.token = ''; return; }
67
+ setNotice(`Key ${result.key.name} issued. The old key was not retired.`);
68
+ try { await displaySecret(result.token, 'ACME consumer key issued'); } finally { result.token = ''; }
69
+ } catch (e) { if (!fence.signal?.aborted) await failure(e); }
70
+ finally {
71
+ if (attempted) { if (name.current) name.current.value = ''; await GroupApiKeyModel.invalidate(qc); if (!fence.signal?.aborted && selected.current === id) { try { const keys = await readRecipientKeyInventory(id, fence); fence.beforeSend(); setInventory(keys); } catch { setBlocked(true); setError('Issuance may have persisted. Refresh the Group key inventory before issuing another key.'); } } }
72
+ if (!fence.signal?.aborted) pending(false);
73
+ }
74
+ };
75
+ const reveal = async (row: KeySummary) => {
76
+ if (flight.current || !owner.allowed || recipientOwner.current !== owner.key) return; const fence = owner.fence(); pending(true);
77
+ try { const value = await revealRecipientKey(row.id, fence); fence.beforeSend(); await displaySecret(value, `Audited token — ${row.name}`); }
78
+ catch (e) { if (!fence.signal?.aborted) await failure(e); } finally { if (!fence.signal?.aborted) pending(false); }
79
+ };
80
+ if (!owner.allowed) return <div className="modal-pad"><h2>Protected key access unavailable</h2><p>A current interactive literal superuser is required.</p><button className="btn" onClick={close}>Close</button></div>;
81
+ return <div className="modal-pad shared-validation-operator" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><h2 className="modal-title">Create ACME consumer key</h2><p>Run this form on the approved hub. Choose its original recipient Group by name; a downstream tenant or acting user is not that identity.</p>
82
+ {fixedGroup ? <FlatRow label="Selected hub Group">{group?.name ?? fixedGroup.name}</FlatRow> : <CollectionSelect<KeyGroup> endpoint="/api/group" sanitizeRow={projectKeyGroup} value={group} label="Original hub recipient Group" disabled={busy} onChange={(_id, row) => setGroup(row ?? null)} />}
83
+ {group && <><FlatRow label="Group UUID"><code>{group.uuid ?? 'No valid UUID reported'}</code></FlatRow><button className="btn" disabled={busy} onClick={() => void refresh()}>Resolve recipient and refresh keys</button></>}
84
+ {error && <p className="form-alert" role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}
85
+ {recipient && <div className="dns-shared-request"><FlatRow label="Actual active hub project">{recipient.consumer_label}</FlatRow><FlatRow label="Exact UUID"><code>{recipient.consumer_project_uuid}</code></FlatRow><FlatRow label="Purpose">ACME validation consumer</FlatRow><FlatRow label="Fixed protected grant"><code>{recipient.permission}</code></FlatRow><p>{recipient.grant_explanation}</p><p>Issuing a key does not grant allocation trust or activate the hub.</p><label className="field"><span className="field-label">Replacement key name</span><input className="input" ref={name} maxLength={200} disabled={busy || blocked || !recipient.can_issue} placeholder="Project shared validation" /></label><button className="btn btn-primary" disabled={busy || blocked || !recipient.can_issue} onClick={() => void issue()}>Review protected key issuance</button>{!recipient.can_issue && <p>Protected issuance is unavailable for this operator.</p>}</div>}
86
+ <h3>Recent Group key inventory</h3><p>After an uncertain response, Refresh this inventory and use audited reveal before creating another successor. Up to 100 recent keys are shown; generic key management remains in the existing inventory.</p>{inventory?.map((row) => <div className="dns-shared-request" key={row.id}><strong>{row.name} · #{row.id}</strong><p>{row.is_active ? 'Active' : 'Inactive'}</p><button className="btn" disabled={busy} onClick={() => void reveal(row)}>Reveal audited token</button></div>)}
87
+ <p>Replacement → protected connection Preview/Publish → Apply → verify every expected node → explicitly review/deactivate the old hub key. No key is rotated or retired by this form.</p><div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{busy ? 'Operation pending…' : 'Close'}</button></div></div>;
88
+ }
@@ -0,0 +1,34 @@
1
+ import { choice, flag, label, nullable, object, OperatorError, positive, privateCall, projectIdentity, type PrivateScope } from '../fleet-configuration/shared-validation-data';
2
+ import { GroupApiKeyModel } from './models';
3
+ export const ACME_RECIPIENT_ENDPOINT = '/api/account/admin/fleet/shared-validation/recipient';
4
+ export type KeyGroup = { id: number; name: string; uuid: string | null };
5
+ export interface AcmeRecipient { purpose: 'acme_validation_consumer'; consumer_project_uuid: string; consumer_label: string; permission: 'dnsman_acme_federation'; can_issue: boolean; grant_explanation: string }
6
+ export interface KeySummary { id: number; name: string; is_active: boolean }
7
+ export function projectKeyGroup(v: unknown): KeyGroup { const r = object(v); return { id: positive(r.id), name: label(r.name), uuid: r.uuid == null ? null : label(r.uuid, 36) }; }
8
+ export async function readKeyGroup(id: number, scope?: PrivateScope) { const g = projectKeyGroup(await privateCall(`/api/group/${positive(id)}`, null, scope)); if (g.id !== id) throw new OperatorError('invalid'); return g; }
9
+ export function parseAcmeRecipient(v: unknown, expected: string): AcmeRecipient {
10
+ const r = object(v); const out = { purpose: choice(r.purpose, ['acme_validation_consumer']), consumer_project_uuid: projectIdentity(r.consumer_project_uuid), consumer_label: label(r.consumer_label), permission: choice(r.permission, ['dnsman_acme_federation']), can_issue: flag(r.can_issue), grant_explanation: label(r.grant_explanation, 300) };
11
+ if (out.consumer_project_uuid !== expected) throw new OperatorError('invalid'); return out;
12
+ }
13
+ export const readAcmeRecipient = async (consumer: string, scope?: PrivateScope) => parseAcmeRecipient(await privateCall(ACME_RECIPIENT_ENDPOINT, null, scope, { consumer_project_uuid: projectIdentity(consumer) }), consumer);
14
+ export async function issueAcmeKey(recipient: AcmeRecipient, name: string, scope?: PrivateScope): Promise<{ key: KeySummary; token: string }> {
15
+ if (!recipient.can_issue || !name.trim()) throw new OperatorError('denied');
16
+ const r = object(await privateCall(ACME_RECIPIENT_ENDPOINT, { consumer_project_uuid: projectIdentity(recipient.consumer_project_uuid), expected_consumer_label: recipient.consumer_label, label: label(name.trim()), purpose: 'acme_validation_consumer' }, scope));
17
+ let secret = r.token; delete r.token;
18
+ try {
19
+ if (r.purpose !== 'acme_validation_consumer' || r.consumer_project_uuid !== recipient.consumer_project_uuid || r.consumer_label !== recipient.consumer_label) throw new OperatorError('invalid');
20
+ const key = object(r.api_key); if (key.permission !== 'dnsman_acme_federation' || key.is_active !== true || typeof secret !== 'string' || !secret || secret.length > 4096 || /\s/.test(secret)) throw new OperatorError('invalid');
21
+ return { key: { id: positive(key.id), name: label(key.name), is_active: true }, token: secret };
22
+ } finally { secret = null; }
23
+ }
24
+ export async function readRecipientKeyInventory(group: number, scope?: PrivateScope): Promise<KeySummary[]> {
25
+ const rows = await privateCall(GroupApiKeyModel.endpoint, null, scope, { group: positive(group), size: 100, sort: '-created', graph: 'default' });
26
+ if (!Array.isArray(rows) || rows.length > 100) throw new OperatorError('invalid');
27
+ return rows.map((v) => { const r = object(v), g = r.group && typeof r.group === 'object' ? object(r.group).id : r.group; if (g !== group) throw new OperatorError('invalid'); return { id: positive(r.id), name: label(r.name), is_active: flag(r.is_active) }; });
28
+ }
29
+ /** Same explicit audited graph as the existing Token control; no Query cache. */
30
+ export async function revealRecipientKey(id: number, scope?: PrivateScope): Promise<string> {
31
+ const r = object(await privateCall(`${GroupApiKeyModel.endpoint}/${positive(id)}`, null, scope, { graph: 'token' }));
32
+ const value = nullable(r.token, (v) => label(v, 4096)); delete r.token;
33
+ if (!value) throw new OperatorError('unavailable'); return value;
34
+ }
@@ -1,5 +1,5 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
- import { useContext, useSyncExternalStore } from 'react';
2
+ import { lazy, Suspense, useContext, useSyncExternalStore } from 'react';
3
3
  import {
4
4
  GroupContext,
5
5
  hasPermission,
@@ -42,6 +42,7 @@ import {
42
42
  type GroupApiKeyRow,
43
43
  } from './models';
44
44
  import { showSecretDialog } from './secret-dialog';
45
+ const ProtectedKeyButton = lazy(() => import('./AcmeConsumerKeyDialog').then((m) => ({ default: m.AcmeConsumerKeyButton })));
45
46
 
46
47
  function groupLabel(group: GroupApiKeyRow['group']): string {
47
48
  if (group && typeof group === 'object') return group.name;
@@ -362,6 +363,7 @@ function GroupApiKeysContent({ group, permission }: { group: CredentialGroup; pe
362
363
  <button className="btn btn-primary btn-compact" onClick={() => void actions.createKey(group)}>
363
364
  <i className="bi bi-key" /> Create Key
364
365
  </button>
366
+ <Suspense fallback={null}><ProtectedKeyButton group={group} /></Suspense>
365
367
  </div>
366
368
  )}
367
369
  {!isPending && rows.length === 0 && <p className="dim-italic">No API keys yet.</p>}
@@ -489,7 +491,7 @@ export function GroupApiKeysPage() {
489
491
  void modal.detail((close) => <GroupApiKeyDetail id={row.id} onClose={() => close(null)} />);
490
492
  };
491
493
  return (
492
- <ModelTable<GroupApiKeyRow>
494
+ <><Suspense fallback={null}><ProtectedKeyButton /></Suspense><ModelTable<GroupApiKeyRow>
493
495
  model={GroupApiKeyModel}
494
496
  eyebrow="Account"
495
497
  title="Group API Keys"
@@ -507,6 +509,6 @@ export function GroupApiKeysPage() {
507
509
  onRowClick={openDetail}
508
510
  addLabel="New API Key"
509
511
  onAdd={actions.canManage ? () => void actions.createKey() : undefined}
510
- />
512
+ /></>
511
513
  );
512
514
  }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useMe } from '../../client/runtime';
4
+ import { Tabs } from '../../ui/Tabs';
5
+ import { datetime } from '../../ui/format';
6
+ import { modal } from '../../ui/modal';
7
+ import { canReadApi, canRecoverApi, isInteractive, assertOwner } from './permissions';
8
+ import { getPlatform, recoverApi } from './api';
9
+ import { apiActions, platformProof } from './deployment-state';
10
+ import { makeReceipt, settleReceipt } from './receipts';
11
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
12
+ import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange, useWrite } from './shared';
13
+ import type { ApiAction } from './types';
14
+ export function ApiDeploymentDetail({ id, onClose }: {
15
+ id: string;
16
+ onClose: () => void;
17
+ }) {
18
+ const me = useMe(), s = useDeploymentOwner(), queryClient = useQueryClient();
19
+ const [tracking, setTracking] = useState<string | null>(null);
20
+ useCloseOnOwnerChange(s.key, onClose);
21
+ const q = useQuery({
22
+ queryKey: ['deployments', s.key, 'api-detail', id], queryFn: ({ signal }) => getPlatform(s.owner, signal), enabled: canReadApi(me.data) && isInteractive(), retry: false
23
+ });
24
+ const selected = q.data?.deployments.data?.items.find(x => x.id === id);
25
+ const identity = tracking ?? (selected && ['requested', 'queued', 'canary', 'fleet', 'deploying'].includes(selected.status) ? id : null);
26
+ const observed = useDeploymentObservation(s.owner, identity, async (signal) => { const p = await getPlatform(s.owner, signal); const a = p.deployments.data?.items.find(x => x.id === identity); if (!a)
27
+ throw new Error('Tracked deployment not observed'); return a; }, a => ['converged', 'failed', 'superseded', 'verified', 'partial', 'unknown'].includes(a.status));
28
+ const row = observed.value ?? selected, latest = q.data?.deployments.data?.items[0]?.id === row?.id, can = canRecoverApi(me.data) && isInteractive();
29
+ useEffect(() => { if (observed.state === 'terminal') void queryClient.invalidateQueries({ queryKey: ['deployments', s.key] }); }, [observed.state, observed.value?.id, observed.value?.status, queryClient, s.key]);
30
+ const openRecovery = async (action: ApiAction) => { let busy = false; await modal.open(close => <ApiRecoveryDialog id={row!.id} action={action} onBusy={value => { busy = value; }} onClose={() => close(null)} onResult={result => { setTracking(result); void q.refetch(); }}/>, { signal: s.signal, canDismiss: () => !busy }); };
31
+ if (!canReadApi(me.data) || !isInteractive())
32
+ return <div className="modal-pad"><p>API authority is no longer available.</p><button className="btn" onClick={onClose}>Close</button></div>;
33
+ return <div className="modal-pad admin-deployments deployment-dialog"><header className="deployment-dialog-header"><div><h2 className="modal-title">API deployment {row && <DeploymentHelp label="API attempt information"><Facts rows={[["Attempt", row.id], ["Source", row.source], ["Actor", row.actor], ["Retry of", row.retry_of]]}/><p>Reported success and restart proof are separate. Recovery applies to the latest attempt and does not accept an arbitrary commit.</p></DeploymentHelp>}</h2><p className="dim mono">{row?.sha.slice(0, 12) || 'Attempt details'}</p></div><div className="deployment-actions"><button className="btn" disabled={q.isFetching} onClick={() => { void q.refetch(); observed.refresh(); }}>Refresh evidence</button><button className="btn" aria-label="Close API deployment" onClick={onClose}>Close</button></div></header>
34
+ {q.isError && <ErrorNotice>Deployment observation unavailable. {q.data ? 'Previous evidence is shown.' : 'Refresh to try again.'}</ErrorNotice>}
35
+ {row && <div className="deployment-toolbar"><Status value={row.status}/>{can && latest && <div className="deployment-actions">{apiActions(row.status).map(action => <button key={action} className="btn" onClick={() => void openRecovery(action)}>{action === 'retry' ? 'Retry same commit' : action === 'verify' ? 'Verify nodes' : 'Converge fleet'}</button>)}</div>}</div>}
36
+ <Tabs ariaLabel="API deployment details" items={[
37
+ {key:'overview',label:'Overview',panel:<div className="deployment-tab-content">{row ? <><Facts rows={[["Commit", <code>{row.sha}</code>], ['Evidence', platformProof(row)], ['Started', row.started ? datetime(row.started) : row.created ? datetime(row.created) : 'Time unavailable'], ['Finished', row.finished ? datetime(row.finished) : 'Not finished']]}/>{row.detail && <p>{row.detail}</p>}</> : <p>{q.isPending ? 'Loading attempt…' : q.data?.deployments.data ? 'Attempt unavailable in the latest 50 records.' : 'The collector did not return deployment evidence.'}</p>}</div>},
38
+ {key:'nodes',label:'Nodes',panel:<div className="deployment-tab-content">{row?.node_evidence.length ? row.node_evidence.map((n,i)=><article className="deployment-evidence" key={i}><strong>{n.runner}</strong><Status value={n.status}/>{n.sha && <p className="mono">{n.sha}</p>}{n.detail && <p>{n.detail}</p>}</article>) : <p>No node proof observed.</p>}</div>},
39
+ {key:'activity',label:'Activity',panel:<div className="deployment-tab-content">{row?.diagnosis.length ? <><h3>Failure diagnosis</h3>{row.diagnosis.map((d,i)=><article className="deployment-evidence" key={i}><strong>{d.runner}</strong><Status value={d.status}/><p>{d.detail}</p>{d.stderr_tail && <pre className="deployment-code">{d.stderr_tail}</pre>}</article>)}</> : <p>No failure diagnosis recorded.</p>}{!!row?.transitions.length && <><h3>Activity</h3>{row.transitions.map((t,i)=><p key={i}>{t.runner} · {t.status}{t.detail && ` · ${t.detail}`}</p>)}</>}</div>}
40
+ ]}/>
41
+ {identity && <p className="dim" role="status">{observed.state === 'observing' ? 'Observing this attempt…' : observed.state === 'terminal' ? 'Observation complete.' : 'Observation stopped; outcome may be unknown. Refresh to observe again.'}</p>}
42
+ </div>;
43
+ }
44
+ function ApiRecoveryDialog({ id, action, onClose, onBusy, onResult }: {
45
+ id: string;
46
+ action: ApiAction;
47
+ onClose: () => void;
48
+ onBusy: (v: boolean) => void;
49
+ onResult: (id: string) => void;
50
+ }) {
51
+ const me = useMe(), s = useDeploymentOwner(), w = useWrite(s.owner, s.signal, onBusy);
52
+ const execute = () => w.run(async () => { if (!canRecoverApi(me.data) || !isInteractive())
53
+ throw new Error('API recovery authority unavailable'); const fresh = await getPlatform(s.owner, s.signal), source = fresh.deployments.data?.items.find(x => x.id === id); if (!source || !apiActions(source.status).includes(action) || fresh.deployments.data?.items[0]?.id !== id)
54
+ throw new Error('Attempt was replaced or changed. Refresh and review again.'); assertOwner(s.owner, s.signal); const receipt = makeReceipt(s.owner, 'api', `${id}:${action}`, 0, id); const result = await recoverApi(s.owner, receipt, id, action, s.signal); if (action === 'retry' && result.sha !== source.sha)
55
+ throw new Error('Retry did not preserve source SHA'); settleReceipt(receipt); onResult(result.id); onClose(); });
56
+ const label = { retry: 'Retry same commit', verify: 'Verify nodes', converge: 'Converge fleet' }[action];
57
+ return <div className="modal-pad admin-deployments"><h2 className="modal-title">{label} <DeploymentHelp label="API recovery target">Attempt <code>{id}</code>. The latest attempt and authority are checked before sending. A retry preserves its commit and tracks a new attempt.</DeploymentHelp></h2><p className="modal-message">{action === 'retry' ? 'Deploy the same commit as a new attempt.' : action === 'verify' ? 'Refresh runtime proof from the nodes in this attempt.' : 'Apply the current desired serving configuration across the fleet, then verify it.'}</p>{w.error && <ErrorNotice>{w.error}</ErrorNotice>}<div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={w.busy} onClick={() => void execute()}>{label}</button></div></div>;
58
+ }
@@ -0,0 +1,73 @@
1
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { registerNonFilterParams, useMe } from '../../client/runtime';
4
+ import { Tabs } from '../../ui/Tabs';
5
+ import { datetime } from '../../ui/format';
6
+ import { modal } from '../../ui/modal';
7
+ import { backendGlobalCan } from '../fleet/access';
8
+ import { useAdminRouteContext } from '../core/route-context';
9
+ import { canOnboard, canReadApi, canReadBootstrap, canReadWebApps, isInteractive, assertOwner } from './permissions';
10
+ import { getBootstrap, getPlatform, getWebApps } from './api';
11
+ import { useDeploymentOwner } from './useDeploymentObservation';
12
+ import { DeploymentHelp, ErrorNotice, Facts, Status } from './shared';
13
+ import type { DeploymentsDestinations } from './types';
14
+ import { platformProof } from './deployment-state';
15
+ registerNonFilterParams('deployment_lane', 'deployment_app', 'deployment_tab');
16
+ export function DeploymentsPage({ destinations }: {
17
+ destinations?: DeploymentsDestinations;
18
+ } = {}) {
19
+ const me = useMe(), scope = useDeploymentOwner(), queryClient = useQueryClient(), [params, setParams] = useSearchParams();
20
+ const registered = useAdminRouteContext();
21
+ const mount = registered?.mount === '/' ? '' : registered?.mount;
22
+ const candidates = destinations ?? (mount !== undefined ? { domains: `${mount}/dns/domains`, serving: `${mount}/edge/vhosts` } : {});
23
+ const admittedDestinations: DeploymentsDestinations = {};
24
+ if (backendGlobalCan(me.data, ['view_dns', 'manage_dns', 'security'])) for (const key of ['domains', 'serving'] as const) {
25
+ const path = candidates[key];
26
+ if (path && (!registered || registered.paths.includes(path))) admittedDestinations[key] = path;
27
+ }
28
+ const interactive = isInteractive(), api = interactive && canReadApi(me.data), web = interactive && canReadWebApps(me.data);
29
+ const lane = params.get('deployment_lane') === 'webapps' && web ? 'webapps' : api ? 'api' : 'webapps';
30
+ const bootstrap = useQuery({
31
+ queryKey: ['deployments', scope.key, 'bootstrap'], queryFn: ({ signal }) => getBootstrap(scope.owner, signal), enabled: web && lane === 'webapps' && canReadBootstrap(me.data), retry: false
32
+ });
33
+ const select = (lane: string) => { setParams({ ...Object.fromEntries([...params].filter(([name]) => name === 'demo')), deployment_lane: lane }, { replace: true }); };
34
+ const newApp = async () => { const { WebAppOnboardingDialog } = await import('./WebAppOnboardingDialog'); assertOwner(scope.owner, scope.signal); let busy = false; await modal.open(close => <WebAppOnboardingDialog destinations={admittedDestinations} bootstrap={bootstrap.data!} onChanged={() => { void queryClient.invalidateQueries({ queryKey: ['deployments', scope.key] }); }} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, {
35
+ signal: scope.signal, canDismiss: () => !busy, size: 'lg'
36
+ }); await queryClient.invalidateQueries({ queryKey: ['deployments', scope.key] }); };
37
+ if (!api && !web)
38
+ return <div className="panel panel-pad"><h1>Deployments</h1><p>API or WebApp read authority in this global Admin workspace is required.</p></div>;
39
+ const items = [...(api ? [{
40
+ key: 'api', label: 'API', panel: <ApiLane key={scope.key}/>
41
+ }] : []), ...(web ? [{
42
+ key: 'webapps', label: 'WebApps', panel: <WebAppsLane key={scope.key} destinations={admittedDestinations}/>
43
+ }] : [])];
44
+ return <div className="admin-deployments"><header className="page-header"><div><h1>Deployments</h1><p className="dim">Review releases and deployment progress.</p></div>{lane === 'webapps' && web && canOnboard(bootstrap.data) && <button className="btn btn-primary" onClick={() => void newApp()}>New WebApp</button>}</header>{items.length === 1 ? items[0]!.panel : <Tabs ariaLabel="Deployment type" items={items} activeKey={lane} onActiveKeyChange={select}/>}</div>;
45
+ }
46
+ function ApiLane() {
47
+ const me = useMe(), s = useDeploymentOwner();
48
+ const q = useQuery({
49
+ queryKey: ['deployments', s.key, 'platform', 'deployments,api'], queryFn: ({ signal }) => getPlatform(s.owner, signal), enabled: canReadApi(me.data) && isInteractive(), retry: false
50
+ });
51
+ const inspect = async (id: string) => { const { ApiDeploymentDetail } = await import('./ApiDeploymentDetail'); assertOwner(s.owner, s.signal); await modal.open(close => <ApiDeploymentDetail id={id} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' }); };
52
+ const d = q.data?.deployments, serving = d?.data?.currently_serving;
53
+ const stale = d?.status === 'stale' || !!d?.stale_after && Date.parse(d.stale_after) <= Date.now();
54
+ return <section><div className="deployment-toolbar"><h2>API</h2><button className="btn" disabled={q.isFetching} onClick={() => void q.refetch()}>Refresh API</button></div>
55
+ {q.isError && <ErrorNotice>API observation unavailable. {q.data ? 'Previous evidence is shown below.' : 'Refresh to try again.'}</ErrorNotice>}
56
+ {q.isPending ? <p role="status">Loading API evidence…</p> : !d?.data ? <div className="panel panel-pad"><h3>Deployment observation unavailable</h3><p>{d?.reason || 'The collector did not return deployment evidence. Refresh to try again.'}</p><Status value={d?.status ?? 'unknown'}/></div> : <>
57
+ {stale && <p className="text-warn" role="status">This deployment observation is stale. Refresh before acting.</p>}
58
+ <div className="deployment-current"><div className="deployment-toolbar"><div className="eyebrow">Last convergence</div><DeploymentHelp label="API convergence"><Facts rows={[["Collector", <Status value={d.status}/>], ["Observed", d.observed_at ? datetime(d.observed_at) : 'Time unavailable'], ['Framework', serving?.framework_version], ['Framework mode', d.data.framework_pin.mode], ['Coordination', d.data.coordination.state]]}/><p>Last convergence is dated evidence, not fresh proof for every current node. Framework changes belong to Maintenance.</p></DeploymentHelp></div><strong className="mono" title={serving?.sha}>{serving?.sha.slice(0, 12) ?? 'No convergence recorded'}</strong><p className="dim">{serving?.converged_at ? datetime(serving.converged_at) : 'Convergence time unknown'}</p></div>
59
+ <h3>Latest attempts</h3>{d.data.items.map((row, i) => <button className="deployment-record" key={row.id} onClick={() => void inspect(row.id)}><span><strong>{i === 0 ? 'Latest · ' : ''}{row.sha.slice(0, 12)}</strong><small>{platformProof(row)}</small></span><Status value={row.status}/></button>)}{!d.data.items.length && <p>No deployment attempts recorded.</p>}
60
+ {d.data.items.length >= d.data.limit && d.data.limit > 0 && <p className="dim">Showing the latest {d.data.limit} attempts.</p>}
61
+ </>}
62
+ </section>;
63
+ }
64
+ function WebAppsLane({ destinations }: {
65
+ destinations?: DeploymentsDestinations;
66
+ }) {
67
+ const me = useMe(), s = useDeploymentOwner();
68
+ const q = useQuery({
69
+ queryKey: ['deployments', s.key, 'summaries'], queryFn: ({ signal }) => getWebApps(s.owner, signal), enabled: canReadWebApps(me.data) && isInteractive(), retry: false
70
+ });
71
+ const inspect = async (id: number) => { const { WebAppDetail } = await import('./WebAppDetail'); assertOwner(s.owner, s.signal); await modal.open(close => <WebAppDetail id={id} destinations={destinations} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' }); };
72
+ return <section><div className="deployment-toolbar"><h2>WebApps</h2><button className="btn" disabled={q.isFetching} onClick={() => void q.refetch()}>Refresh WebApps</button></div>{q.isError ? <ErrorNotice>WebApp summary unavailable. Refresh to try again.</ErrorNotice> : q.isPending ? <p>Loading WebApps…</p> : <>{q.data?.truncated && <p className="text-warn">More apps exist; this list is truncated.</p>}{q.data?.items.map(row => <button className="deployment-record" key={row.webapp.id} onClick={() => void inspect(row.webapp.id)}><span><strong>{row.webapp.display_name || row.webapp.slug}</strong><small>{row.address?.hostname || 'No public address'}</small><small>Desired version: {row.current_release?.version ?? 'No release'}</small></span><span className="deployment-attempt-summary"><small>Latest attempt{row.latest_deployment?.release?.version && row.latest_deployment.release.version !== row.current_release?.version ? ` · ${row.latest_deployment.release.version}` : ''}</small><Status value={row.latest_deployment?.status ?? 'not started'}/></span></button>)}{!q.data?.items.length && <p>No WebApps yet.</p>}</>}</section>;
73
+ }