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
@@ -0,0 +1,166 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { requestFreshAuth } from '../../client/runtime';
4
+ import { FlatRow, fmt, modal } from '../../ui';
5
+ import { applyConnection, connectionAuthority, connectionOperationFinished as operationFinished, connectionVerified, enableHub, nodeVerified, OperatorError, previewConnection, publishConnection, readConnection, readConnectionHistory, readConnectionOperation, readHubStatus, usePrivateOperator, type ConnectionInput, type ConnectionOperation, type ConnectionState, type HubStatus, type Preview, type SafeHistory } from './shared-validation-data';
6
+ import { legacyDestination, readOperatorDestinations, type OperatorDestinations } from './operator-destinations';
7
+
8
+ const layout = { display: 'grid', gap: 16, minWidth: 0, overflowWrap: 'anywhere' as const };
9
+ export function SharedValidationConnectionPage() {
10
+ const owner = usePrivateOperator();
11
+ if (!owner.allowed) return <div className="panel panel-pad">Shared validation connection requires an interactive deployment operator.</div>;
12
+ return <div className="page" style={layout}><header><div className="eyebrow">Operations · Fleet Configuration</div><h1>Shared validation</h1><p className="dim">Share DNS validation while each issuer keeps its own certificates and private keys.</p></header><SharedValidationConnectionPanel key={owner.key} /></div>;
13
+ }
14
+ export function SharedValidationConnectionPanel({ pollInterval = 3000, pollLimit = 120 }: { pollInterval?: number; pollLimit?: number }) {
15
+ const owner = usePrivateOperator();
16
+ return owner.allowed ? <ConnectionContent key={owner.key} pollInterval={pollInterval} pollLimit={pollLimit} /> : <p>Deployment operator access is unavailable.</p>;
17
+ }
18
+ function ConnectionContent({ pollInterval, pollLimit }: { pollInterval: number; pollLimit: number }) {
19
+ const owner = usePrivateOperator();
20
+ const [state, setState] = useState<ConnectionState | null>(null), [history, setHistory] = useState<SafeHistory | null>(null);
21
+ const [destinations, setDestinations] = useState<OperatorDestinations | null>(null), [destinationError, setDestinationError] = useState('');
22
+ const [profileId, setProfileId] = useState(''), [keyAction, setKeyAction] = useState<ConnectionInput['key_action']>('keep');
23
+ const input = useRef<HTMLInputElement>(null), draft = useRef<ConnectionInput | null>(null);
24
+ const [preview, setPreview] = useState<Preview | null>(null), [operation, setOperation] = useState<ConnectionOperation | null>(null);
25
+ const [error, setError] = useState(''), [notice, setNotice] = useState(''), [busy, setBusy] = useState(false), [loading, setLoading] = useState(false), [blocked, setBlocked] = useState(true), [timedOut, setTimedOut] = useState(false);
26
+ const flight = useRef(false), generation = useRef(0), ticks = useRef(0), previewEpoch = useRef(0), observationEpoch = useRef(0), authority = useRef<string | null>(null);
27
+ const clearDraft = () => { previewEpoch.current++; draft.current = null; setPreview(null); if (input.current) input.current.value = ''; };
28
+ useEffect(() => () => { draft.current = null; if (input.current) input.current.value = ''; }, [owner.key]);
29
+ const failure = async (reason: unknown) => {
30
+ const safe = reason instanceof OperatorError ? reason : new OperatorError('unavailable'); setError(safe.message); setBlocked(true); clearDraft();
31
+ if (safe.kind === 'fresh') await requestFreshAuth();
32
+ };
33
+ const refresh = async (clear = true) => {
34
+ if (!owner.allowed) return false;
35
+ const fence = owner.fence(), run = ++generation.current, tracked = clear ? operation : null; if (clear) observationEpoch.current++; if (clear) clearDraft(); setLoading(true);
36
+ try {
37
+ const results = await Promise.allSettled([readConnection(fence), readConnectionHistory(fence), readOperatorDestinations(fence), tracked ? readConnectionOperation(tracked.operation_id, fence, tracked.revision) : Promise.resolve(null)]);
38
+ fence.beforeSend(); if (generation.current !== run) return false;
39
+ const main = results[0]; if (main.status === 'rejected') throw main.reason;
40
+ if (tracked && results[3].status === 'rejected') throw results[3].reason;
41
+ const nextAuthority = connectionAuthority(main.value);
42
+ if (authority.current !== null && authority.current !== nextAuthority) { clearDraft(); setNotice('Configuration authority changed. Preview again before publication.'); }
43
+ authority.current = nextAuthority;
44
+ if (clear) { if (tracked) setOperation(main.value.revision === tracked.revision && results[3].status === 'fulfilled' ? results[3].value : null); ticks.current = 0; setTimedOut(false); }
45
+ setState(main.value); setProfileId((old) => main.value.profiles.some((p) => p.id === old) ? old : main.value.selected_profile_id ?? main.value.profiles[0]?.id ?? '');
46
+ setHistory(results[1].status === 'fulfilled' ? results[1].value : null);
47
+ setDestinations(results[2].status === 'fulfilled' ? results[2].value : null);
48
+ setDestinationError(results[2].status === 'rejected' ? 'Existing Admin destinations could not be verified. Refresh before opening them.' : '');
49
+ setBlocked(false); setError(''); return true;
50
+ } catch (reason) { if (!fence.signal?.aborted && generation.current === run) await failure(reason); return false; }
51
+ finally { if (!fence.signal?.aborted && generation.current === run) setLoading(false); }
52
+ };
53
+ useEffect(() => { void refresh(); return () => { generation.current++; }; }, [owner.key]); // owner identity owns all state
54
+ useEffect(() => {
55
+ if (!operation || operationFinished(operation) || busy || loading || blocked || !owner.allowed || ticks.current >= pollLimit) return;
56
+ const timer = setTimeout(() => {
57
+ ticks.current++; const fence = owner.fence(), epoch = observationEpoch.current;
58
+ void readConnectionOperation(operation.operation_id, fence, operation.revision).then(async (next) => {
59
+ fence.beforeSend(); if (observationEpoch.current !== epoch) return; setOperation(next); await refresh(false); if (observationEpoch.current !== epoch) return; if (ticks.current >= pollLimit && !operationFinished(next)) setTimedOut(true);
60
+ }).catch((reason) => { if (!fence.signal?.aborted && observationEpoch.current === epoch) void failure(reason); });
61
+ }, pollInterval);
62
+ return () => clearTimeout(timer);
63
+ }, [operation, busy, loading, blocked, owner.key, pollInterval, pollLimit]);
64
+ const selected = state?.profiles.find((p) => p.id === profileId);
65
+ const change = () => { previewEpoch.current++; draft.current = null; setPreview(null); setNotice(''); };
66
+ const run = async (action: 'preview' | 'publish' | 'apply') => {
67
+ if (flight.current || !owner.allowed || !state?.available || blocked || loading) return;
68
+ const fence = owner.fence(); const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const current = state; observationEpoch.current++; flight.current = true; setBusy(true); setError(''); setNotice(''); let attempted = false;
69
+ try {
70
+ fence.beforeSend();
71
+ if (action === 'preview') {
72
+ if (!current.catalog_revision || !selected) throw new OperatorError('invalid');
73
+ const candidate: ConnectionInput = { expected_revision: current.revision, expected_catalog_revision: current.catalog_revision, profile_id: selected.id, key_action: keyAction, ...(keyAction === 'replace' ? { replacement_key: input.current?.value ?? '' } : {}) };
74
+ const epoch = previewEpoch.current, expectedAuthority = authority.current;
75
+ const result = await previewConnection(candidate, keyAction === 'clear' ? null : selected, fence); fence.beforeSend(); if (previewEpoch.current !== epoch || authority.current !== expectedAuthority) throw new OperatorError('conflict'); draft.current = candidate; setPreview(result);
76
+ setNotice('Preview checked the candidate identity. Nothing was saved or applied.');
77
+ } else {
78
+ if (action === 'publish' && (!preview?.can_publish || !draft.current)) return;
79
+ if (action === 'apply' && !current.revision) return;
80
+ const frozen = draft.current;
81
+ const confirmed = await modal.open<boolean>((close) => <div className="modal-pad"><h2 className="modal-title">{action === 'publish' ? 'Publish shared validation connection?' : 'Apply configuration to every expected node?'}</h2><div className="modal-message"><p>{action === 'publish' ? `Profile: ${preview?.profile_id ?? 'Disabled connection'}` : `Published revision: ${current.revision}`}</p><p>Expected project: {preview?.expected_identity?.consumer_label ?? current.expected_identity?.consumer_label ?? 'Disabled'} · <code>{preview?.expected_identity?.consumer_project_uuid ?? current.expected_identity?.consumer_project_uuid ?? '—'}</code></p><p>{action === 'publish' ? 'Publish the reviewed connection bundle. Config-sync may restart services; node activation must be verified separately. This does not grant allocation trust, retire a key or activate hub sharing.' : 'Ask every expected node to sync this exact revision. Application and worker processes may restart. Missing nodes remain unconfirmed; existing release holds are unchanged.'}</p>{action === 'publish' && keyAction === 'clear' && <p className="text-warn">Disable the whole connection and mask base credentials. This does not delete base keys or revoke remote trust. Existing identity rows can refuse this operation.</p>}</div><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Cancel</button><button className="btn btn-primary" onClick={() => close(true)}>{action === 'publish' ? 'Publish connection' : 'Apply now'}</button></div></div>, { size: 'md', signal: fence.signal });
82
+ if (!confirmed) return; fence.beforeSend();
83
+ if (action === 'publish' && frozen !== draft.current) throw new OperatorError('conflict');
84
+ attempted = true;
85
+ if (action === 'publish') { await publishConnection(frozen!, fence); fence.beforeSend(); setOperation(null); setNotice('Published. Apply and verify every expected node separately. The old key is still active.'); }
86
+ else { const result = await applyConnection(current.revision!, fence); fence.beforeSend(); setOperation(result); ticks.current = 0; setTimedOut(false); setNotice('Apply requested. Waiting for revision-bound process and identity proof.'); }
87
+ clearDraft(); await refresh(false);
88
+ }
89
+ } catch (reason) {
90
+ if (!fence.signal?.aborted) {
91
+ if (attempted) { clearDraft(); await refresh(false); }
92
+ await failure(reason);
93
+ }
94
+ } finally { if (!fence.signal?.aborted) { setBusy(false); requestAnimationFrame(() => { try { fence.beforeSend(); if (opener?.isConnected) opener.focus(); } catch { /* Former owner cannot regain focus. */ } }); } flight.current = false; }
95
+ };
96
+ if (!owner.allowed) return <p>Deployment operator access is no longer available.</p>;
97
+ const report = operation?.revision === state?.revision ? operation : state?.fleet;
98
+ const currentReport = !blocked && !loading;
99
+ return <div className="shared-validation-operator" style={layout}>
100
+ <HubSharing key={owner.key} />
101
+ <section className="panel panel-pad" aria-label="Shared validation connection" style={layout}>
102
+ <h2>Protected connection</h2>
103
+ <p>Use a protected ACME consumer key issued for the original hub project. The approved profile fixes the hub origin and project identity.</p>
104
+ <div><button className="btn" disabled={busy || loading} onClick={() => void refresh()}>Refresh connection status</button>{loading && <span role="status"> Checking current state…</span>}</div>
105
+ {error && <p className="form-alert" role="alert">{error}{state && ' Earlier observations are not current confirmation.'}</p>}{notice && <p role="status">{notice}</p>}
106
+ {state && <><FlatRow label="Published revision"><code>{state.revision ?? 'Not published'}</code></FlatRow><FlatRow label="This process loaded"><code>{state.loaded_revision ?? 'Not reported'}</code></FlatRow><FlatRow label="Credential">{state.credential.configured ? 'Configured (hidden)' : 'Not configured'} · {state.credential.source}</FlatRow><FlatRow label="Loaded identity">{state.loaded_identity?.state ?? 'Unavailable'} · <code>{state.loaded_identity?.observed_project_uuid ?? 'Not observed'}</code></FlatRow><FlatRow label="Identity checked">{state.loaded_identity?.checked_at ? fmt.datetime(state.loaded_identity.checked_at) : 'Not observed'}</FlatRow>
107
+ {state.loaded_identity?.state === 'verified' && !state.loaded_identity.sharing_ready && <p className="dns-warning">The connection identity is verified. Enable sharing on the approved hub, then refresh this connection.</p>}
108
+ {!state.available && <div className="dns-blocked"><strong>Connection setup unavailable</strong><ul>{state.unavailable_reasons.map((reason) => <li key={reason}>{reason.replaceAll('_', ' ')}</li>)}</ul><p>Use the permitted System Setup destination below to inspect publisher, schema, delegation and node enrollment. An empty profile catalog cannot be filled in this form.</p></div>}
109
+ <fieldset disabled={busy || loading || blocked || !state.available} style={{ border: 0, padding: 0, ...layout }}>
110
+ <label className="field"><span className="field-label">Approved hub profile</span><select className="input" value={profileId} onChange={(e) => { change(); setProfileId(e.target.value); }}><option value="">Choose an approved profile</option>{state.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.label}</option>)}</select></label>
111
+ {selected && <><FlatRow label="Approved hub"><code>{selected.origin}</code></FlatRow><FlatRow label="Expected original hub project">{selected.consumer_label} · <code>{selected.consumer_project_uuid}</code></FlatRow></>}
112
+ <label className="field"><span className="field-label">Protected key action</span><select className="input" value={keyAction} onChange={(e) => { change(); if (input.current) input.current.value = ''; setKeyAction(e.target.value as ConnectionInput['key_action']); }}><option value="keep">Keep current key</option><option value="replace">Replace key</option><option value="clear">Disable connection</option></select></label>
113
+ {keyAction === 'replace' && <label className="field"><span className="field-label">Replacement protected key</span><input className="input" type="password" ref={input} autoComplete="off" spellCheck={false} maxLength={4096} onChange={change} /><span className="field-help">Write only. Blank preserves the existing key. The value remains only in this mounted form until the attempt completes.</span></label>}
114
+ {keyAction === 'clear' && <p className="text-warn">Disable the whole bundle and mask base credentials; remote trust and base keys remain. Retained shared identities can make disabling ineligible.</p>}
115
+ <div><button className="btn" disabled={!selected} onClick={() => void run('preview')}>Preview connection</button></div>
116
+ {preview && <div className="dns-shared-request"><h3>Revision and identity preview</h3><FlatRow label="Current revision"><code>{preview.revision ?? 'Unpublished'}</code></FlatRow><FlatRow label="Observed project"><code>{preview.identity.observed_project_uuid ?? 'Disabled'}</code></FlatRow><FlatRow label="Identity">{preview.identity.state} · {preview.identity.checked_at ? fmt.datetime(preview.identity.checked_at) : 'Not applicable'}</FlatRow><p>{preview.identity.sharing_ready ? 'Hub reports sharing ready.' : 'Hub sharing activation is not established by this preview.'}</p><p>Credential changes: {preview.credential_changed ? 'Yes' : 'No'}. Apply and every-node verification remain separate. Retiring the old key is a later explicit action.</p><button className="btn btn-primary" disabled={!preview.can_publish} onClick={() => void run('publish')}>Review publication</button></div>}
117
+ </fieldset>
118
+ <div><button className="btn btn-primary" disabled={busy || loading || blocked || !state.available || !state.revision || !!operation && !operationFinished(operation)} onClick={() => void run('apply')}>Review apply to nodes</button></div>
119
+ </>}
120
+ </section>
121
+ {state && report && <section className="panel panel-pad" aria-label="Every expected node" style={layout}><h2>Every expected node</h2><p role="status" className={currentReport && connectionVerified(state, report) ? 'text-ok' : 'text-warn'}>{currentReport && connectionVerified(state, report) ? 'Connection applied and verified on every expected node' : 'Not confirmed on every expected node'} · {report.status}</p><p className="dim">Combined proof covers the request service where enabled, job engine and scheduler at the target revision. Worker-only exemption is server-owned. Offline nodes stay in the denominator; a completed dispatch job is not process proof.</p>{!report.nodes.length && <p>No expected-node evidence is available.</p>}{report.nodes.map((node) => <article className="dns-shared-request" key={node.hostname}><h3>{node.hostname}</h3><FlatRow label="Revision"><code>{node.revision}</code></FlatRow><FlatRow label="Publication / installation">{node.published ? 'Published' : 'Unconfirmed'} · {node.installed ? 'Installed' : 'Not installed'}</FlatRow><FlatRow label="Restart">{node.restarted ? 'Reported restarted' : node.restart_requested ? 'Requested' : 'Unconfirmed'}</FlatRow><FlatRow label="Request + engine + scheduler proof">{currentReport && nodeVerified(node, report, state.revision, state.profiles.find((p) => p.id === state.selected_profile_id)) ? 'Confirmed at target revision and identity' : 'Unconfirmed'}</FlatRow><FlatRow label="Observed hub project"><code>{node.shared_validation?.observed_project_uuid ?? 'Unavailable'}</code></FlatRow><FlatRow label="Identity check">{node.shared_validation?.checked_at ? fmt.datetime(node.shared_validation.checked_at) : 'Unavailable'} · {node.shared_validation?.sharing_ready ? 'Sharing ready' : 'Sharing readiness not established'}</FlatRow><p>{node.error_code ?? node.status}</p></article>)}{timedOut && <p className="text-warn">Automatic observations reached their limit. Refresh explicitly for current status.</p>}</section>}
122
+ <section className="panel panel-pad" style={layout}><h2>Existing deployment controls</h2><p>Update every expected node through the existing deployment controls before enabling sharing. Each control keeps its own confirmation and release permissions.</p>{destinationError && <p role="alert">{destinationError}</p>}{destinations?.external && <p>External infrastructure mode: existing controls retain their IaC restrictions.</p>}<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>{(['setup', 'deployments', 'topology', 'capacity'] as const).map((kind) => { const href = destinations && legacyDestination(destinations, kind); return href ? <a key={kind} className="btn" href={href}>{({ setup: 'Open System Setup', deployments: 'Open Deployments and framework policy', topology: 'Open expected topology', capacity: 'Open Fleet Scaling' })[kind]}</a> : <span key={kind} className="dim">{kind}: destination unavailable</span>; })}</div></section>
123
+ <section className="panel panel-pad" style={layout}><h2>Replace first, retire later</h2><p>On the approved hub, use Group API Keys → Create ACME consumer key for the exact original project. Enter the replacement here, Preview and Publish, then Apply and verify every expected node. Only then explicitly review/deactivate the old key in the hub inventory. No key is retired automatically.</p><p>Existing audited Token reveal can recover an issued key. In-place rotation invalidates the previous token immediately and is not this staged rollout.</p><Link className="btn" to="../../api-keys" relative="path">Open this deployment’s Group API Keys</Link><p className="dim">This local inventory is not a claim that this deployment is the approved hub or that it observed another deployment’s nodes.</p></section>
124
+ <section className="panel panel-pad"><h2>Version history</h2>{history ? <>{history.versions.map((version) => <p key={version.version_id}><code>{version.version_id}</code> · {version.published_at ? fmt.datetime(version.published_at) : 'Time unavailable'}{version.current ? ' · current' : ''}</p>)}{history.truncated && <p>Only recent versions are shown.</p>}</> : <p>History unavailable. Use Refresh; secrets are never displayed here.</p>}</section>
125
+ </div>;
126
+ }
127
+
128
+ const hubMessages: Record<NonNullable<HubStatus['reason']>, string> = {
129
+ hub_unconfigured: 'This deployment is not a validation hub. Configure its connection below; enable sharing on the approved hub.',
130
+ hub_unavailable: 'Hub status is unavailable. Refresh to try again.',
131
+ deployment_required: 'Deploy the compatible framework to every expected node first.',
132
+ deployment_in_progress: 'Wait for the current deployment to finish, then refresh.',
133
+ deployment_not_converged: 'Finish the deployment on every expected node, then refresh.',
134
+ deployment_proof_unavailable: 'Some nodes have not confirmed the deployed framework. Check Deployments, then refresh.',
135
+ framework_upgrade_required: 'Update every expected node to a compatible framework release, then refresh.',
136
+ };
137
+ function HubSharing() {
138
+ const owner = usePrivateOperator();
139
+ const [state, setState] = useState<HubStatus | null>(null), [error, setError] = useState(''), [busy, setBusy] = useState(false), [loading, setLoading] = useState(true);
140
+ const flight = useRef(false), generation = useRef(0);
141
+ const refresh = async () => {
142
+ const fence = owner.fence(), current = ++generation.current; setLoading(true);
143
+ try { const next = await readHubStatus(fence); fence.beforeSend(); if (current === generation.current) { setState(next); setError(''); } }
144
+ catch (reason) { if (!fence.signal?.aborted && current === generation.current) setError((reason instanceof OperatorError ? reason : new OperatorError('unavailable')).message); }
145
+ finally { if (!fence.signal?.aborted && current === generation.current) setLoading(false); }
146
+ };
147
+ useEffect(() => { void refresh(); return () => { generation.current++; }; }, [owner.key]);
148
+ const enable = async () => {
149
+ if (flight.current || loading || error || !state?.can_enable || !state.deployment_uuid) return;
150
+ const fence = owner.fence(), deployment = state.deployment_uuid, opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
151
+ flight.current = true; setBusy(true);
152
+ try {
153
+ fence.beforeSend();
154
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Enable shared validation?</h2><div className="modal-message"><p>Enable this hub after the verified deployment <code>{deployment}</code>.</p><p>Trusted projects can share DNS validation. Each issuer keeps its own certificates, accounts and private keys. Allocation trust still requires explicit approval.</p><p>Keep the compatible framework installed on every node after enabling sharing.</p></div><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Cancel</button><button className="btn btn-primary" onClick={() => close(true)}>Enable sharing</button></div></div>, { size: 'md', signal: fence.signal });
155
+ if (!confirmed) return; fence.beforeSend();
156
+ const next = await enableHub(deployment, fence); fence.beforeSend(); setState(next); setError('');
157
+ } catch (reason) {
158
+ if (!fence.signal?.aborted) {
159
+ const safe = reason instanceof OperatorError ? reason : new OperatorError('unavailable');
160
+ await refresh(); try { fence.beforeSend(); } catch { return; } setError(`${safe.message} Refresh hub status before trying again.`);
161
+ if (safe.kind === 'fresh') await requestFreshAuth();
162
+ }
163
+ } finally { flight.current = false; if (!fence.signal?.aborted) { setBusy(false); requestAnimationFrame(() => { try { fence.beforeSend(); if (opener?.isConnected) opener.focus(); } catch { /* The former session no longer owns focus. */ } }); } }
164
+ };
165
+ return <section className="panel panel-pad" aria-label="Validation hub" style={layout}><h2>Validation hub</h2><p>Enable sharing once after updating every node through the existing deployment controls. Configured issuers renew automatically.</p><p role="status">{loading ? 'Checking hub status…' : error ? 'Current hub status is unavailable.' : state?.enabled ? 'Sharing is enabled.' : state?.reason ? hubMessages[state.reason] : 'Ready to enable sharing.'}</p>{state?.enabled && !state.configured && <p className="text-warn">Hub DNS configuration is unavailable. Check System Setup, then refresh.</p>}{error && <p className="form-alert" role="alert">{error}</p>}<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}><button className="btn" disabled={busy || loading} onClick={() => void refresh()}>Refresh hub status</button>{!state?.enabled && <button className="btn btn-primary" disabled={busy || loading || !!error || !state?.can_enable} onClick={() => void enable()}>Enable shared validation</button>}</div></section>;
166
+ }
@@ -87,3 +87,7 @@ export async function readFleetOperation(id: string): Promise<FleetOperation> {
87
87
  export async function writeFleet<T>(body: Record<string, unknown>): Promise<T> {
88
88
  return withFreshAuth(async () => (await mojoCall(FLEET_ENDPOINT, { method: 'POST', body })).data as T);
89
89
  }
90
+
91
+ /** Managed bundle fields are edited together in their dedicated operator topic. */
92
+ export const MANAGED_CONNECTION_KEYS = ['DNSMAN_ACME_HUB_PROFILE_ID', 'DNSMAN_ACME_HUB_URL', 'DNSMAN_ACME_HUB_API_KEY'] as const;
93
+ export const isManagedConnectionKey = (key: string) => (MANAGED_CONNECTION_KEYS as readonly string[]).includes(key);
@@ -0,0 +1,20 @@
1
+ import { apiOrigin } from '../../client/client';
2
+ import { flag, object, OperatorError, privateCall, type PrivateScope } from './shared-validation-data';
3
+ export interface OperatorDestinations { admin_path: string; setup: boolean; deployments: boolean; topology: boolean; capacity: boolean; external: boolean }
4
+ export function parseDestinations(v: unknown): OperatorDestinations {
5
+ const r = object(v), c = object(r.capabilities), f = object(r.features), platform = object(f.platform), pc = object(platform.capabilities), settings = object(f.settings), sc = object(settings.capabilities);
6
+ if (typeof r.admin_path !== 'string' || !/^\/[A-Za-z0-9_-]+\/$/.test(r.admin_path)) throw new OperatorError('invalid');
7
+ return { admin_path: r.admin_path, setup: flag(platform.enabled) && flag(pc.setup) && flag(c.setup), deployments: flag(platform.enabled) && flag(pc.view), topology: flag(settings.enabled) && flag(sc.owner_edit), capacity: flag(platform.enabled) && flag(pc.capacity), external: object(r.infrastructure).mode === 'external' };
8
+ }
9
+ export const readOperatorDestinations = async (scope?: PrivateScope) => parseDestinations(await privateCall('/api/account/admin/bootstrap', null, scope));
10
+ /** The backend capacity hint is semantic; the actual legacy route is fleet. */
11
+ export function legacyDestination(d: OperatorDestinations, kind: 'setup' | 'deployments' | 'topology' | 'capacity'): string | null {
12
+ if (!d[kind]) return null;
13
+ const base = new URL(apiOrigin() || '/', window.location.origin);
14
+ if (base.origin !== window.location.origin) return null;
15
+ return `${d.admin_path}#/${{ setup: 'setup', deployments: 'deployments', topology: 'settings?focus=topology', capacity: 'fleet' }[kind]}`;
16
+ }
17
+ export function fleetConnectionRoute(pathname: string): string {
18
+ const marker = pathname.lastIndexOf('/dns'); const fleet = pathname.lastIndexOf('/fleet-configuration');
19
+ return `${marker >= 0 ? pathname.slice(0, marker) : fleet >= 0 ? pathname.slice(0, fleet) : ''}/fleet-configuration/shared-validation`;
20
+ }
@@ -0,0 +1,143 @@
1
+ /** Private operator boundary. Credential-bearing requests never use Query/Mutation or replay. */
2
+ import { useEffect, useRef } from 'react';
3
+ import { apiOrigin, usingMockTransport } from '../../client/client';
4
+ import { getAuthSessionRevision, getAuthSnapshot, mojoCall, MojoError, useAuthSnapshot, useMe, type Params } from '../../client/runtime';
5
+ // This private protocol boundary does not import the generic replaying writer.
6
+ const FLEET_ENDPOINT = '/api/account/admin/fleet';
7
+ const FLEET_HEALTH_SCOPE = 'request_service_jobs_and_dependencies';
8
+
9
+ export const CONNECTION_ENDPOINT = `${FLEET_ENDPOINT}/shared-validation`;
10
+ export interface PrivateScope { signal?: AbortSignal; beforeSend?: () => void }
11
+ export class OperatorError extends Error {
12
+ constructor(public kind: 'invalid' | 'recipient' | 'unsupported' | 'denied' | 'fresh' | 'conflict' | 'busy' | 'unavailable' | 'uncertain', public code: string | null = null) {
13
+ super({ recipient: 'The original hub recipient Group is unavailable or inactive. Resolve the current Group before continuing.', invalid: 'The operator service returned an incompatible response. Refresh before continuing.', unsupported: 'This backend does not support shared-validation configuration. Existing settings remain available.', denied: 'This action requires a current interactive deployment operator.', fresh: 'Authenticate again, Refresh and deliberately repeat the action. Nothing is replayed.', conflict: 'The profile, recipient or publication changed. Refresh and review again before continuing.', busy: 'Configuration is busy. Refresh and deliberately retry after the other operation finishes.', unavailable: 'The operator service is unavailable. Refresh to check its current prerequisites.', uncertain: 'The operation may have persisted. Refresh authoritative status or key inventory before another attempt.' }[kind]);
14
+ }
15
+ }
16
+ const invalid = (): never => { throw new OperatorError('invalid'); };
17
+ export const object = (v: unknown): Record<string, unknown> => v != null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : invalid();
18
+ export const label = (v: unknown, max = 200): string => typeof v === 'string' && v.length <= max && !/[\x00-\x1f\x7f]/.test(v) ? v : invalid();
19
+ export const flag = (v: unknown): boolean => typeof v === 'boolean' ? v : invalid();
20
+ export const nullable = <T>(v: unknown, parse: (x: unknown) => T): T | null => v === null ? null : parse(v);
21
+ export const choice = <T extends string>(v: unknown, values: readonly T[]): T => values.includes(v as T) ? v as T : invalid();
22
+ export const uuid = (v: unknown): string => typeof v === 'string' && /^[a-f0-9]{32}$/.test(v) ? v : invalid();
23
+ /** Only project identities accept legacy Group IDs; revision/operation UUIDs stay strict. */
24
+ export const projectIdentity = (v: unknown): string => typeof v === 'string' && /^[a-f0-9]{16}$/.test(v) ? v : uuid(v);
25
+ const profileId = (v: unknown): string => typeof v === 'string' && /^[a-z][a-z0-9_-]{0,63}$/.test(v) ? v : invalid();
26
+ export const token = (v: unknown): string => typeof v === 'string' && /^[a-z0-9_.-]{1,80}$/.test(v) ? v : invalid();
27
+ const catalog = (v: unknown): string => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v) ? v : invalid();
28
+ export const timestamp = (v: unknown): string => { const s = label(v, 40); return /^\d{4}-\d\d-\d\dT/.test(s) && Number.isFinite(Date.parse(s)) ? s : invalid(); };
29
+ export const positive = (v: unknown): number => Number.isSafeInteger(v) && Number(v) > 0 ? Number(v) : invalid();
30
+ const bounded = (v: unknown, max: number): unknown[] => Array.isArray(v) && v.length <= max ? v : invalid();
31
+ function origin(v: unknown): string { const s = label(v, 512); try { const u = new URL(s); return !u.username && !u.password && u.origin === s && (u.protocol === 'https:' || u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) ? s : invalid(); } catch { return invalid(); } }
32
+ export interface Identity { consumer_project_uuid: string; consumer_label: string }
33
+ export interface HubProfile extends Identity { id: string; label: string; origin: string }
34
+ export interface Observation { state: 'disabled' | 'verified' | 'unavailable'; profile_id: string | null; observed_project_uuid: string | null; checked_at: string | null; error_code: string | null; sharing_ready: boolean }
35
+ export interface ConnectionNode { hostname: string; revision: string; published: boolean; installed: boolean; restart_requested: boolean; restarted: boolean; healthy: boolean; status: string; error_code: string | null; shared_validation: Observation | null }
36
+ export interface ConnectionReport { status: string; nodes: ConnectionNode[]; healthy_everywhere: boolean; health_scope?: string; observed_at?: string; error_code?: string }
37
+ export interface ConnectionState { schema_version: 1; available: boolean; unavailable_reasons: string[]; catalog_revision: string | null; profiles: HubProfile[]; selected_profile_id: string | null; expected_identity: Identity | null; loaded_identity: Observation | null; credential: { configured: boolean; source: 'published' | 'static' | 'none' | 'unknown'; overridden: boolean }; revision: string | null; loaded_revision: string | null; pending_restart: boolean; fleet: ConnectionReport }
38
+ export interface ConnectionInput { expected_revision: string | null; expected_catalog_revision: string; profile_id: string; key_action: 'keep' | 'replace' | 'clear'; replacement_key?: string }
39
+ export interface Preview { can_publish: boolean; revision: string | null; catalog_revision: string; profile_id: string | null; expected_identity: Identity | null; identity: Observation; credential_changed: boolean; consequences: string[] }
40
+ export interface Publication { published: true; revision: string; version_id: string | null; pending_restart: true; applied: false }
41
+ export interface ConnectionOperation extends ConnectionReport { operation_id: string; revision: string; job_status?: string }
42
+ /** Private observation stops on terminal operation or failed dispatch, never completed dispatch alone. */
43
+ export const connectionOperationFinished = (operation: ConnectionOperation) => ['healthy', 'failed', 'expired', 'canceled', 'superseded', 'timed_out'].includes(operation.status) || ['failed', 'expired', 'canceled'].includes(operation.job_status ?? '');
44
+ export interface SafeHistory { versions: { version_id: string; current: boolean; published_at: string | null }[]; truncated: boolean }
45
+ const identity = (v: unknown): Identity => { const r = object(v); return { consumer_project_uuid: projectIdentity(r.consumer_project_uuid), consumer_label: label(r.consumer_label) }; };
46
+ export function parseObservation(v: unknown): Observation {
47
+ const r = object(v); const out: Observation = { state: choice(r.state, ['disabled', 'verified', 'unavailable']), profile_id: r.profile_id == null ? null : profileId(r.profile_id), observed_project_uuid: nullable(r.observed_project_uuid, projectIdentity), checked_at: r.checked_at == null ? null : timestamp(r.checked_at), error_code: r.error_code == null ? null : token(r.error_code), sharing_ready: flag(r.sharing_ready) };
48
+ if (out.state === 'verified' && (!out.observed_project_uuid || !out.checked_at || out.error_code) || out.state === 'disabled' && (out.observed_project_uuid || out.sharing_ready)) invalid();
49
+ return out;
50
+ }
51
+ export function parseReport(v: unknown): ConnectionReport {
52
+ const r = object(v); const nodes = bounded(r.nodes, 128).map((value): ConnectionNode => {
53
+ const n = object(value), hostname = label(n.hostname, 253); if (!/^[a-z0-9][a-z0-9_.-]*$/.test(hostname)) invalid();
54
+ return { hostname, revision: uuid(n.revision), published: flag(n.published), installed: flag(n.installed), restart_requested: flag(n.restart_requested), restarted: flag(n.restarted), healthy: flag(n.healthy), status: choice(n.status, ['unknown', 'draining', 'healthy', 'restarted', 'restart_requested', 'downloaded', 'pending', 'failed']), error_code: n.error_code == null ? null : token(n.error_code), shared_validation: n.shared_validation == null ? null : parseObservation(n.shared_validation) };
55
+ });
56
+ if (new Set(nodes.map((n) => n.hostname)).size !== nodes.length) invalid();
57
+ return { status: choice(r.status, ['unpublished', 'unconfigured', 'queued', 'dispatched', 'pending', 'healthy', 'unknown', 'failed', 'expired', 'canceled', 'superseded', 'timed_out']), nodes, healthy_everywhere: flag(r.healthy_everywhere), health_scope: r.health_scope == null ? undefined : token(r.health_scope), observed_at: r.observed_at == null ? undefined : timestamp(r.observed_at), error_code: r.error_code == null ? undefined : token(r.error_code) };
58
+ }
59
+ export function parseConnectionState(v: unknown): ConnectionState {
60
+ const r = object(v); if (r.schema_version !== 1) throw new OperatorError('unsupported');
61
+ const c = object(r.credential), destination = object(r.setup_destination);
62
+ if (destination.route !== 'setup' || destination.section !== 'aws' || destination.capacity_route !== 'capacity' || destination.actor !== 'deployment_operator' || destination.adoption_item !== 8903) invalid();
63
+ const profiles = bounded(r.profiles, 64).map((value) => { const p = object(value); return { id: profileId(p.id), label: label(p.label), origin: origin(p.origin), ...identity(p) }; });
64
+ if (new Set(profiles.map((p) => p.id)).size !== profiles.length) invalid();
65
+ const out: ConnectionState = { schema_version: 1, available: flag(r.available), unavailable_reasons: bounded(r.unavailable_reasons, 20).map(token), catalog_revision: nullable(r.catalog_revision, catalog), profiles, selected_profile_id: nullable(r.selected_profile_id, profileId), expected_identity: nullable(r.expected_identity, identity), loaded_identity: nullable(r.loaded_identity, parseObservation), credential: { configured: flag(c.configured), source: choice(c.source, ['published', 'static', 'none', 'unknown']), overridden: flag(c.overridden) }, revision: nullable(r.revision, uuid), loaded_revision: nullable(r.loaded_revision, uuid), pending_restart: flag(r.pending_restart), fleet: parseReport(r.fleet) };
66
+ const selected = profiles.find((p) => p.id === out.selected_profile_id);
67
+ if (out.selected_profile_id && (!selected || selected.consumer_project_uuid !== out.expected_identity?.consumer_project_uuid) || out.available && (!out.catalog_revision || !profiles.length || out.unavailable_reasons.length)) invalid();
68
+ return out;
69
+ }
70
+ export const connectionAuthority = (s: ConnectionState) => JSON.stringify([s.available, s.unavailable_reasons, s.revision, s.catalog_revision, s.selected_profile_id, s.expected_identity, s.profiles, s.credential]);
71
+ export function validateInput(v: ConnectionInput): void {
72
+ nullable(v.expected_revision, uuid); catalog(v.expected_catalog_revision); profileId(v.profile_id); choice(v.key_action, ['keep', 'replace', 'clear']);
73
+ if (Object.keys(v).some((k) => !['expected_revision', 'expected_catalog_revision', 'profile_id', 'key_action', 'replacement_key'].includes(k)) || v.replacement_key !== undefined && (v.key_action !== 'replace' || typeof v.replacement_key !== 'string' || v.replacement_key.length > 4096 || /\s/.test(v.replacement_key))) invalid();
74
+ }
75
+ export function parsePreview(v: unknown, input: ConnectionInput, expected: Identity | null): Preview {
76
+ const r = object(v); const out: Preview = { can_publish: flag(r.can_publish), revision: nullable(r.revision, uuid), catalog_revision: catalog(r.catalog_revision), profile_id: nullable(r.profile_id, profileId), expected_identity: nullable(r.expected_identity, identity), identity: parseObservation(r.identity), credential_changed: flag(r.credential_changed), consequences: bounded(r.consequences, 8).map((c) => choice(c, ['connection_disabled', 'restart_required', 'apply_and_verify_every_expected_node', 'old_key_retirement_is_separate'])) };
77
+ if (out.revision !== input.expected_revision || out.catalog_revision !== input.expected_catalog_revision || (input.key_action === 'clear' ? out.profile_id !== null || out.identity.state !== 'disabled' : out.profile_id !== input.profile_id || out.expected_identity?.consumer_project_uuid !== expected?.consumer_project_uuid || out.identity.state !== 'verified' || out.identity.observed_project_uuid !== expected?.consumer_project_uuid)) invalid();
78
+ return out;
79
+ }
80
+ export function nodeVerified(n: ConnectionNode, report: ConnectionReport, revision: string | null, profile: HubProfile | undefined): boolean {
81
+ return report.status === 'healthy' && !report.error_code && n.status === 'healthy' && report.health_scope === FLEET_HEALTH_SCOPE && !!revision && n.revision === revision && n.published && n.installed && n.restarted && n.healthy && !n.error_code && !!profile && n.shared_validation?.state === 'verified' && n.shared_validation.profile_id === profile.id && n.shared_validation.observed_project_uuid === profile.consumer_project_uuid;
82
+ }
83
+ export const connectionVerified = (s: ConnectionState, r = s.fleet) => r.status === 'healthy' && !r.error_code && !['failed', 'expired', 'canceled'].includes((r as Partial<ConnectionOperation>).job_status ?? '') && r.healthy_everywhere && r.nodes.length > 0 && r.nodes.every((n) => nodeVerified(n, r, s.revision, s.profiles.find((p) => p.id === s.selected_profile_id)));
84
+ export function operatorOrigin(): void { if (!usingMockTransport() && new URL(apiOrigin(), window.location.origin).origin !== window.location.origin) throw new OperatorError('denied'); }
85
+ export async function privateCall(path: string, body: Record<string, unknown> | null, scope: PrivateScope = {}, params?: Params): Promise<unknown> {
86
+ try {
87
+ if (body) operatorOrigin();
88
+ return (await mojoCall(path, { ...(body ? { method: 'POST', body } : { params }), signal: scope.signal, beforeSend: () => { if (body) operatorOrigin(); scope.beforeSend?.(); } })).data;
89
+ } catch (e) {
90
+ if (e instanceof OperatorError) throw e;
91
+ if (scope.signal?.aborted) throw new OperatorError('denied');
92
+ if (e instanceof MojoError) {
93
+ if (e.status === 440) throw new OperatorError('fresh');
94
+ if ([401, 403].includes(e.status)) throw new OperatorError('denied');
95
+ if (e.status === 404 && e.errorCode === 'recipient_unavailable') throw new OperatorError('recipient');
96
+ if ([404, 405].includes(e.status)) throw new OperatorError('unsupported');
97
+ if (e.errorCode === 'configuration_busy') throw new OperatorError('busy', 'configuration_busy');
98
+ if (e.errorCode === 'publication_uncertain') throw new OperatorError('uncertain');
99
+ if (e.status === 409) throw new OperatorError('conflict');
100
+ if (e.status === 400) throw new OperatorError('invalid');
101
+ if (e.status === 503) throw new OperatorError('unavailable');
102
+ }
103
+ throw new OperatorError(body ? 'uncertain' : 'unavailable');
104
+ }
105
+ }
106
+ export const readConnection = async (scope?: PrivateScope) => parseConnectionState(await privateCall(CONNECTION_ENDPOINT, null, scope));
107
+ const HUB_ENDPOINT = `${CONNECTION_ENDPOINT}/hub`;
108
+ const hubReasons = ['hub_unconfigured', 'hub_unavailable', 'deployment_required', 'deployment_in_progress', 'deployment_not_converged', 'deployment_proof_unavailable', 'framework_upgrade_required'] as const;
109
+ export interface HubStatus { version: 1; enabled: boolean; configured: boolean; deployment_uuid: string | null; can_enable: boolean; reason: typeof hubReasons[number] | null }
110
+ const deploymentId = (v: unknown): string => typeof v === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(v) ? v : invalid();
111
+ export function parseHubStatus(value: unknown): HubStatus {
112
+ const r = object(value); if (r.version !== 1) throw new OperatorError('unsupported');
113
+ const result: HubStatus = { version: 1, enabled: flag(r.enabled), configured: flag(r.configured), deployment_uuid: nullable(r.deployment_uuid, deploymentId), can_enable: flag(r.can_enable), reason: nullable(r.reason, v => choice(v, hubReasons)) };
114
+ if (result.enabled ? result.can_enable || result.reason !== null : result.can_enable ? !result.configured || !result.deployment_uuid || result.reason !== null : result.reason === null) invalid();
115
+ return result;
116
+ }
117
+ export const readHubStatus = async (scope?: PrivateScope) => parseHubStatus(await privateCall(HUB_ENDPOINT, null, scope));
118
+ export async function enableHub(deployment: string, scope?: PrivateScope) {
119
+ const result = parseHubStatus(await privateCall(HUB_ENDPOINT, { deployment_uuid: deploymentId(deployment) }, scope));
120
+ if (!result.enabled || result.deployment_uuid !== deployment) invalid();
121
+ return result;
122
+ }
123
+ export const previewConnection = async (input: ConnectionInput, expected: Identity | null, scope?: PrivateScope) => { validateInput(input); return parsePreview(await privateCall(CONNECTION_ENDPOINT, { action: 'preview', ...input }, scope), input, expected); };
124
+ export async function publishConnection(input: ConnectionInput, scope?: PrivateScope): Promise<Publication> {
125
+ validateInput(input); const r = object(await privateCall(CONNECTION_ENDPOINT, { action: 'publish', ...input }, scope));
126
+ if (r.published !== true || r.applied !== false || r.pending_restart !== true) invalid();
127
+ return { published: true, revision: uuid(r.revision), version_id: nullable(r.version_id, (v) => label(v, 1024)), pending_restart: true, applied: false };
128
+ }
129
+ export function parseOperation(v: unknown): ConnectionOperation { const r = object(v); return { ...parseReport(r), operation_id: uuid(r.operation_id), revision: uuid(r.revision), job_status: r.job_status == null ? undefined : choice(r.job_status, ['pending', 'running', 'completed', 'failed', 'expired', 'canceled']) }; }
130
+ export const applyConnection = async (revision: string, scope?: PrivateScope) => { const result = parseOperation(await privateCall(FLEET_ENDPOINT, { action: 'apply', expected_revision: uuid(revision) }, scope)); if (result.revision !== revision) invalid(); return result; };
131
+ export const readConnectionOperation = async (id: string, scope?: PrivateScope, revision?: string) => { const result = parseOperation(await privateCall(`${FLEET_ENDPOINT}/operation/${uuid(id)}`, null, scope)); if (result.operation_id !== id || revision !== undefined && result.revision !== revision) invalid(); return result; };
132
+ export async function readConnectionHistory(scope?: PrivateScope): Promise<SafeHistory> { const r = object(await privateCall(`${FLEET_ENDPOINT}/history`, null, scope)); return { versions: bounded(r.versions, 100).map((v) => { const row = object(v); return { version_id: label(row.version_id, 1024), current: flag(row.current), published_at: nullable(row.published_at, timestamp) }; }), truncated: flag(r.truncated) }; }
133
+
134
+ /** Local owner fence shared only by the two private operator forms. */
135
+ export function usePrivateOperator() {
136
+ const auth = useAuthSnapshot(), { data: me } = useMe(); const revision = getAuthSessionRevision();
137
+ const allowed = auth.authenticated && me?.is_superuser === true;
138
+ const key = `${auth.uid}:${revision}:${allowed}`; const latest = useRef(key); latest.current = key;
139
+ const owner = useRef(new AbortController());
140
+ useEffect(() => { const current = new AbortController(); owner.current = current; return () => current.abort(); }, [key]);
141
+ const fence = (): PrivateScope & { beforeSend: () => void } => { const current = owner.current; return { signal: current.signal, beforeSend: () => { if (!allowed || current.signal.aborted || latest.current !== key || getAuthSnapshot().uid !== auth.uid || getAuthSessionRevision() !== revision) throw new OperatorError('denied'); } }; };
142
+ return { allowed, key, fence };
143
+ }
@@ -0,0 +1,171 @@
1
+ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';
2
+ import { Link, useLocation, useNavigate } from 'react-router-dom';
3
+ import { requestFreshAuth, useMe } from '../../client/runtime';
4
+ import { modal } from '../../ui';
5
+ import { Popover } from '../../ui/Popover';
6
+ import { OperatorError, usePrivateOperator } from '../fleet-configuration/shared-validation-data';
7
+ import { GeoLocationError, geoDraft, geoFleetRoute, geoProviderLabel, geoProviderOptions, geoTestBlock, geoWriteBlock, readGeoLocation, saveGeoLocation, testGeoLocation, type GeoDraft, type GeoKeyEdit, type GeoLocationState, type GeoVerdict } from './data';
8
+
9
+ // The hosted MojoVerify service has one fixed origin; only its key is editable.
10
+ const mojoverifyDraft = (value: GeoDraft): GeoDraft => ({ ...geoDraft(value), GEOIP_MOJO_PROVIDER_URL: 'https://api.mojoverify.com' });
11
+
12
+ function GeoHelp({ label, children }: { label: string; children: ReactNode }) {
13
+ const anchor = useRef<HTMLButtonElement>(null), id = useId();
14
+ const [open, setOpen] = useState(false);
15
+ 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); }}><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><circle cx="10" cy="10" r="8" /><path d="M10 9v5" /><circle cx="10" cy="6" r=".8" fill="currentColor" stroke="none" /></svg></button><Popover anchorRef={anchor} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="ops-help-content">{children}</Popover></>;
16
+ }
17
+
18
+ export function GeoLocationPage({ fleetConfigurationPath }: { fleetConfigurationPath?: string } = {}) {
19
+ const owner = usePrivateOperator(), { data: me } = useMe();
20
+ if (me?.is_superuser !== true || !owner.allowed) return <div className="panel panel-pad"><h1>GeoLocation</h1><p>GeoLocation requires a superuser account with an interactive session.</p></div>;
21
+ return <GeoLocationContent key={owner.key} fleetConfigurationPath={fleetConfigurationPath} />;
22
+ }
23
+ function GeoLocationContent({ fleetConfigurationPath }: { fleetConfigurationPath?: string }) {
24
+ const owner = usePrivateOperator(), location = useLocation(), navigate = useNavigate();
25
+ const [state, setState] = useState<GeoLocationState | null>(null), [draft, setDraft] = useState<GeoDraft | null>(null);
26
+ const [keyMode, setKeyMode] = useState<GeoKeyEdit['mode']>('untouched'), [keyChanged, setKeyChanged] = useState(false);
27
+ const [verdict, setVerdict] = useState<GeoVerdict | null>(null), [error, setError] = useState(''), [notice, setNotice] = useState('');
28
+ const [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [blocked, setBlocked] = useState(true);
29
+ const input = useRef<HTMLInputElement>(null), flight = useRef(false), generation = useRef(0), draftEpoch = useRef(0);
30
+ const keyRef = useCallback((node: HTMLInputElement | null) => {
31
+ if (input.current && input.current !== node) input.current.value = '';
32
+ input.current = node;
33
+ }, []);
34
+ const clearKey = () => { if (input.current) input.current.value = ''; setKeyMode('untouched'); setKeyChanged(false); };
35
+ const invalidate = () => { draftEpoch.current++; setVerdict(null); setNotice(''); };
36
+ useEffect(() => () => { generation.current++; draftEpoch.current++; if (input.current) input.current.value = ''; }, [owner.key]);
37
+ const failure = async (reason: unknown, forceBlock = false) => {
38
+ const safe = reason instanceof GeoLocationError ? reason : new GeoLocationError(reason instanceof OperatorError && reason.kind === 'denied' ? 'denied' : 'unavailable');
39
+ setError(safe.message);
40
+ if (forceBlock || ['invalid', 'unsupported', 'denied', 'fresh', 'conflict', 'uncertain', 'unavailable'].includes(safe.kind)) setBlocked(true);
41
+ if (safe.kind === 'fresh' || safe.kind === 'denied') { clearKey(); invalidate(); }
42
+ if (safe.kind === 'fresh') await requestFreshAuth();
43
+ };
44
+ const refresh = async () => {
45
+ if (flight.current || !owner.allowed) return;
46
+ const fence = owner.fence(), run = ++generation.current;
47
+ invalidate(); clearKey(); setLoading(true); setBlocked(true); setError('');
48
+ try {
49
+ const next = await readGeoLocation(fence); fence.beforeSend();
50
+ if (run !== generation.current) return;
51
+ setState(next); setDraft(mojoverifyDraft(next.geoip)); setBlocked(false);
52
+ } catch (reason) { if (!fence.signal?.aborted && run === generation.current) await failure(reason, true); }
53
+ finally { if (!fence.signal?.aborted && run === generation.current) setLoading(false); }
54
+ };
55
+ useEffect(() => { void refresh(); }, [owner.key]);
56
+ const dirty = !!state && !!draft && (JSON.stringify(draft) !== JSON.stringify(mojoverifyDraft(state.geoip)) || keyMode === 'clear' || keyChanged);
57
+ const change = <K extends keyof GeoDraft>(key: K, value: GeoDraft[K]) => { invalidate(); setDraft(old => old ? { ...old, [key]: value } : old); };
58
+ const discard = () => { invalidate(); clearKey(); if (state) setDraft(mojoverifyDraft(state.geoip)); setError(''); };
59
+ const confirmDiscard = async (destination: 'refresh' | 'fleet') => {
60
+ flight.current = true; setBusy(true); const fence = owner.fence();
61
+ try {
62
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Discard unsaved changes?</h2><p className="modal-message">{destination === 'refresh' ? 'Refreshing replaces this draft and clears any entered API key.' : 'Opening Fleet Configuration discards this draft and clears any entered API key.'}</p><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Keep editing</button><button className="btn btn-danger" onClick={() => close(true)}>{destination === 'refresh' ? 'Discard and refresh' : 'Discard and continue'}</button></div></div>, { size: 'md', signal: fence.signal });
63
+ fence.beforeSend();
64
+ return confirmed === true;
65
+ } catch (reason) { if (!fence.signal?.aborted) await failure(reason); return false; }
66
+ finally { flight.current = false; if (!fence.signal?.aborted) setBusy(false); }
67
+ };
68
+ const requestRefresh = async () => {
69
+ if (flight.current || loading) return;
70
+ if (dirty && !await confirmDiscard('refresh')) return;
71
+ await refresh();
72
+ };
73
+ const openFleet = async () => {
74
+ if (flight.current || loading || !owner.allowed) return;
75
+ if (dirty && !await confirmDiscard('fleet')) return;
76
+ discard();
77
+ navigate(fleetConfigurationPath ?? geoFleetRoute(location.pathname));
78
+ };
79
+ const chooseKeyMode = async (mode: GeoKeyEdit['mode']) => {
80
+ if (flight.current || loading || blocked || !state) return;
81
+ if (mode === 'clear') {
82
+ flight.current = true; setBusy(true); const fence = owner.fence();
83
+ try {
84
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Remove the stored MojoVerify key?</h2><p className="modal-message">Saving will remove the key. MojoVerify lookups and threat-signal sharing may stop. This does not revoke the key at the service.</p><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Cancel</button><button className="btn btn-danger" onClick={() => close(true)}>Confirm key removal</button></div></div>, { size: 'md', signal: fence.signal });
85
+ fence.beforeSend(); if (confirmed) { invalidate(); clearKey(); setKeyMode('clear'); }
86
+ } catch (reason) { if (!fence.signal?.aborted) await failure(reason); }
87
+ finally { flight.current = false; if (!fence.signal?.aborted) setBusy(false); }
88
+ } else { invalidate(); clearKey(); setKeyMode(mode); }
89
+ };
90
+ const run = async (action: 'test' | 'save') => {
91
+ if (flight.current || !owner.allowed || blocked || loading || !state || !draft || (action === 'save' ? !dirty || geoWriteBlock(state) : geoTestBlock(state) || keyMode === 'clear')) return;
92
+ flight.current = true; setBusy(true); setError(''); setNotice('');
93
+ const fence = owner.fence(), epoch = draftEpoch.current, runGeneration = generation.current, base = state;
94
+ const saving = action === 'save';
95
+ try {
96
+ fence.beforeSend();
97
+ const edit: GeoKeyEdit = keyMode === 'replace' ? { mode: 'replace', value: input.current?.value ?? '' } : keyMode === 'clear' ? { mode: 'clear', confirmed: true } : { mode: 'untouched' };
98
+ if (action === 'test') {
99
+ const next = await testGeoLocation(base.configuration_revision, draft, edit, fence); fence.beforeSend();
100
+ if (epoch !== draftEpoch.current || runGeneration !== generation.current) return;
101
+ setVerdict(next);
102
+ try {
103
+ const observed = await readGeoLocation(fence); fence.beforeSend();
104
+ if (epoch !== draftEpoch.current || runGeneration !== generation.current) return;
105
+ // Refresh observations/prerequisites without rebasing a changed revision or the draft.
106
+ if (observed.configuration_revision !== base.configuration_revision || geoTestBlock(observed)) {
107
+ setBlocked(true); setError('Authoritative configuration changed or became unavailable. Refresh and review before continuing.');
108
+ } else setState(observed);
109
+ } catch (reason) { if (!fence.signal?.aborted) await failure(reason, true); }
110
+ } else {
111
+ clearKey();
112
+ const result = await saveGeoLocation(base.configuration_revision, draft, edit, fence); fence.beforeSend();
113
+ if (epoch !== draftEpoch.current || runGeneration !== generation.current) return;
114
+ invalidate(); setBlocked(true);
115
+ setNotice(result.published ? 'Saved and published. Apply the pending configuration in Fleet Configuration.' : 'Saved. Published provider values were unchanged; the stored credential may have changed.');
116
+ const authoritative = await readGeoLocation(fence); fence.beforeSend();
117
+ if (runGeneration !== generation.current) return;
118
+ setState(authoritative); setDraft(mojoverifyDraft(authoritative.geoip)); setBlocked(false);
119
+ }
120
+ } catch (reason) {
121
+ if (!fence.signal?.aborted && runGeneration === generation.current) {
122
+ if (saving) clearKey();
123
+ await failure(reason, saving && !(reason instanceof GeoLocationError && reason.kind === 'validation'));
124
+ }
125
+ } finally { flight.current = false; if (!fence.signal?.aborted && runGeneration === generation.current) setBusy(false); }
126
+ };
127
+ const block = state ? geoWriteBlock(state) : null, diagnosticBlock = state ? geoTestBlock(state) : null;
128
+ const disabled = busy || loading || blocked, options = state && draft ? geoProviderOptions(state, draft) : [];
129
+ const additionalOptions = draft ? options.filter(p => p !== draft.GEOIP_PRIMARY_PROVIDER && p !== draft.GEOIP_FALLBACK_PROVIDER && !draft.GEOIP_ADDITIONAL_PROVIDERS.includes(p)) : [];
130
+ const meta = (key: keyof GeoDraft) => state?.fleet_fields.find(f => f.key === key);
131
+ const help = (label: string, content: ReactNode) => <GeoHelp label={label}>{content}</GeoHelp>;
132
+ const providerSelect = (key: 'GEOIP_PRIMARY_PROVIDER' | 'GEOIP_FALLBACK_PROVIDER', label: string) => <label className="field"><span className="field-label">{label} {help(label, <>{meta(key)?.description} Each provider is queried only once at runtime, even if it appears in more than one position. Other provider credentials are managed in deployment settings.</>)}</span><select className="input" aria-label={label} value={draft?.[key] ?? ''} onChange={e => change(key, e.target.value)}>{options.map(p => <option key={p} value={p}>{geoProviderLabel(p)}{state?.geoip_providers.includes(p) ? '' : ' (configured; unavailable)'}</option>)}</select></label>;
133
+ const move = (index: number, offset: number) => { if (!draft) return; const next = [...draft.GEOIP_ADDITIONAL_PROVIDERS], target = index + offset; [next[index], next[target]] = [next[target]!, next[index]!]; change('GEOIP_ADDITIONAL_PROVIDERS', next); };
134
+ const stored = state?.verify_state.geoip, storedDate = stored?.at && Number.isFinite(Date.parse(stored.at)) ? new Date(stored.at) : null;
135
+ const details = () => {
136
+ if (!state) return;
137
+ void modal.open(close => <div className="modal-pad geo-admin-dialog"><h2 className="modal-title">Configuration information</h2><dl>{[['Published revision', state.published_revision ?? 'Not published'], ['This request node loaded', state.loaded_revision ?? 'Not reported'], ['Configuration revision', state.configuration_revision ?? 'Not published'], ['Published version', state.published_version ?? 'Not reported'], ['Remote configuration', state.remote_error ?? 'No read error reported']].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl><p className="dim">A key check does not prove that the provider chain or every Fleet node is healthy. Stored checks are historical observations, separate from this draft.</p>{stored && <p>{stored.message}{stored.code && <> · <code>{stored.code}</code></>}</p>}<div className="modal-actions"><button className="btn" onClick={() => close(undefined)}>Close</button></div></div>, { size: 'md', signal: owner.fence().signal });
138
+ };
139
+ return <div className="page geo-admin"><header className="page-header"><div><div className="eyebrow">Operations</div><h1>GeoLocation</h1><p className="dim">Set up your IP location providers.</p></div><button className="btn" disabled={busy || loading} onClick={() => void requestRefresh()}>Refresh</button></header>
140
+ {loading && <p role="status">Loading GeoLocation configuration…</p>}
141
+ {error && <p className="form-alert" role="alert">{error}</p>}{notice && <p className="geo-admin-notice" role="status">{notice}</p>}
142
+ {block && <div className="geo-admin-notice" role="alert"><strong>Publishing unavailable</strong><p>{block}</p><span>You can review a local draft{!diagnosticBlock && ' and test the MojoVerify key'}.</span></div>}
143
+ {state && draft && <><section className="panel geo-admin-setup" aria-label="GeoLocation provider setup">
144
+ <div className="geo-admin-form"><fieldset disabled={disabled} className="geo-admin-fields">
145
+ <div className="geo-admin-provider-fields">
146
+ {providerSelect('GEOIP_PRIMARY_PROVIDER', 'Primary provider')}
147
+ {providerSelect('GEOIP_FALLBACK_PROVIDER', 'Fallback provider')}
148
+ </div>
149
+ <div className="field"><span className="field-label">Additional providers {help('additional providers', <>{meta('GEOIP_ADDITIONAL_PROVIDERS')?.description} Tried in the order shown after the primary and fallback providers. Other provider credentials are managed in deployment settings.</>)}</span>
150
+ <ol className="geo-admin-provider-list">{draft.GEOIP_ADDITIONAL_PROVIDERS.map((provider, index) => <li key={provider}><span><span className="dim">{index + 1}.</span> {geoProviderLabel(provider)}{!state.geoip_providers.includes(provider) && <small className="dim"> Configured; unavailable</small>}</span><div className="geo-admin-actions"><button className="btn" aria-label={`Move ${geoProviderLabel(provider)} up`} disabled={disabled || index === 0} onClick={() => move(index, -1)}>↑</button><button className="btn" aria-label={`Move ${geoProviderLabel(provider)} down`} disabled={disabled || index === draft.GEOIP_ADDITIONAL_PROVIDERS.length - 1} onClick={() => move(index, 1)}>↓</button><button className="btn" aria-label={`Remove ${geoProviderLabel(provider)}`} onClick={() => change('GEOIP_ADDITIONAL_PROVIDERS', draft.GEOIP_ADDITIONAL_PROVIDERS.filter(p => p !== provider))}>Remove</button></div></li>)}</ol>
151
+ {!draft.GEOIP_ADDITIONAL_PROVIDERS.length && <p className="field-help">No additional providers.</p>}
152
+ <select className="input" aria-label="Add provider" value="" disabled={disabled || !additionalOptions.length || draft.GEOIP_ADDITIONAL_PROVIDERS.length >= 16} onChange={e => { if (e.target.value) change('GEOIP_ADDITIONAL_PROVIDERS', [...draft.GEOIP_ADDITIONAL_PROVIDERS, e.target.value]); }}><option value="">{additionalOptions.length ? 'Add provider…' : 'All providers added'}</option>{additionalOptions.map(p => <option value={p} key={p}>{geoProviderLabel(p)}</option>)}</select>
153
+ </div>
154
+ <div className="geo-admin-key"><div className="geo-admin-key-heading"><span className="field-label">MojoVerify API key</span>{help('MojoVerify API key', 'Write only. Tests check only MojoVerify. Saving also checks MojoVerify, even when another provider is primary, unless you explicitly remove its key. Other provider credentials are managed in deployment settings.')}</div>
155
+ <p className="dim">{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED ? <>Key set{state.geoip.GEOIP_API_KEY_MOJO_HINT && <> · <span aria-label="Stored key suffix">••••{state.geoip.GEOIP_API_KEY_MOJO_HINT}</span></>}</> : 'No stored key'}</p>
156
+ <div className="geo-admin-actions"><button className="btn" aria-pressed={keyMode === 'replace'} onClick={() => void chooseKeyMode('replace')}>{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED ? 'Replace key' : 'Add key'}</button>{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED && <button className="btn" onClick={() => void chooseKeyMode('clear')}>Remove key</button>}<button className="btn" disabled={disabled || !!diagnosticBlock || keyMode === 'clear'} onClick={() => void run('test')}>Test key</button>{keyMode !== 'untouched' && <button className="btn" onClick={() => void chooseKeyMode('untouched')}>Cancel key change</button>}</div>
157
+ {/* Retained while editing; the raw value lives only in this uncontrolled DOM input. */}
158
+ <label className="field" hidden={keyMode !== 'replace'}><span className="field-label">{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED ? 'Replacement key' : 'New key'}</span><input className="input" ref={keyRef} type="password" aria-label="MojoVerify API key" autoComplete="new-password" spellCheck={false} maxLength={4096} onChange={event => { invalidate(); setKeyChanged(event.currentTarget.value !== ''); }} /><span className="field-help">Leave blank to keep the stored key.</span></label>
159
+ {keyMode === 'clear' && <p className="text-warn">Save changes to remove the stored key. Key removal is not a connection test.</p>}
160
+ {draft.GEOIP_MOJO_PROVIDER_URL.replace(/\/$/, '') !== state.geoip.GEOIP_MOJO_PROVIDER_URL.replace(/\/$/, '') && <p className="text-warn">Switching to MojoVerify requires a replacement key. The stored key will not be sent to a different service.</p>}
161
+ </div>
162
+ <div className="geo-admin-sharing"><label><input type="checkbox" checked={draft.GEOIP_MOJO_SYNC_ENABLED} onChange={e => change('GEOIP_MOJO_SYNC_ENABLED', e.target.checked)} /> Share threat signals with MojoVerify</label>{help('threat-signal sharing', <>{meta('GEOIP_MOJO_SYNC_ENABLED')?.description} The remote key needs the geoip_sync permission. Sharing uses MojoVerify regardless of the primary provider.</>)}</div>
163
+ </fieldset></div>
164
+ {options.some(p => !state.geoip_providers.includes(p)) && <p className="geo-admin-context dim">Unavailable provider IDs are retained from your configuration. They cannot be verified against the current registry; review them before saving.</p>}
165
+ {verdict && <div className={`geo-admin-result ${verdict.success && verdict.code !== 'clear_requested' ? 'text-ok' : 'text-warn'}`} role="status" aria-label="Draft MojoVerify key test"><strong>{verdict.success && verdict.code !== 'clear_requested' ? 'Draft MojoVerify key verified' : 'Draft MojoVerify key not verified'}</strong><p>{verdict.message}</p>{verdict.code && <code>{verdict.code}</code>}{!verdict.success && <p>Supply a replacement key, or apply pending configuration in Fleet Configuration and refresh here.</p>}</div>}
166
+ {keyMode !== 'clear' && <p className={`geo-admin-context ${draft.GEOIP_PRIMARY_PROVIDER !== 'mojo' && !state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED && !keyChanged ? 'text-warn' : 'dim'}`}>{draft.GEOIP_PRIMARY_PROVIDER !== 'mojo' && !state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED && !keyChanged ? 'Saving also checks MojoVerify. Add its key before saving, or the backend may reject this change.' : 'Saving also checks the MojoVerify key.'}</p>}
167
+ <footer className="geo-admin-footer"><span className="dim" role="status">{dirty ? 'Unsaved changes' : 'No unsaved changes'}</span><div className="geo-admin-actions"><button className="btn" disabled={busy || loading || !dirty} onClick={discard}>Discard changes</button><button className="btn btn-primary" disabled={disabled || !!block || !dirty} onClick={() => void run('save')}>{busy ? 'Working…' : 'Save changes'}</button></div></footer>
168
+ </section>
169
+ <section className="panel geo-admin-status" aria-label="Stored configuration status"><div className="geo-admin-status-heading"><div className="geo-admin-key-heading"><h2>Last stored check</h2>{help('last stored check', 'This historical MojoVerify result is separate from your current draft and Fleet activation. A successful key check does not prove the provider chain or every node is healthy.')}</div><button className="btn" onClick={details}>Configuration information</button></div><p><strong>MojoVerify:</strong> {stored ? stored.ok && stored.code !== 'clear_requested' ? 'Verified' : stored.code === 'clear_requested' ? 'Key removal requested; not tested' : 'Failed' : 'Not checked'}{storedDate && <> · <time dateTime={storedDate.toISOString()}>{storedDate.toLocaleString()}</time></>}</p>{(blocked || loading) && <p className="text-warn">Earlier observations are not current confirmation. Refresh to check the configuration.</p>}<div className="geo-admin-activation">{state.pending_restart ? <span className="text-warn">Activation pending on this request node.</span> : <span className="dim">Review application and node health in Fleet.</span>}<Link to={fleetConfigurationPath ?? geoFleetRoute(location.pathname)} aria-disabled={busy || loading} onClick={event => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); void openFleet(); }}>Open Fleet Configuration</Link></div></section></>}
170
+ </div>;
171
+ }