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,298 @@
1
+ import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { getAuthSessionRevision, getAuthSnapshot, hasPermission, requestFreshAuth, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
4
+ import { CollectionSelect, FlatRow, modal, toast } from '../../ui';
5
+ import { DNS_MANAGE_PERMISSIONS, DomainModel, sanitizeDomainRow, type DnsGroupChoice, type DomainRow } from './models';
6
+ import { checkValidation, connectValidation, getAllocations, getHubCapabilities, getParticipants, getRecipient, grantValidation, invalidateValidation, requestWildcard, revokeValidation, sanitizeSetupGroup, SETUP_GROUP_ENDPOINT, useValidationPlan } from './shared-validation-api';
7
+ import { CONNECTION_REQUEST_MAX_BYTES, normalizeHostname, parseConnectionRequestText, serializeConnectionRequest, SharedValidationError, type Allocation, type ConnectionRequest, type HubCapabilities, type Page, type Participant, type Recipient, type SetupTarget } from './shared-validation-data';
8
+ import { ValidationFacts } from './SharedValidationPanel';
9
+
10
+ export function confirmShared(options: { title: string; message: ReactNode; confirmText: string; danger?: boolean; signal?: AbortSignal }) {
11
+ return modal.open<boolean>((close) => <div className="modal-pad dns-shared-confirm"><h2 className="modal-title">{options.title}</h2><div className="modal-message">{options.message}</div><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Cancel</button><button className={options.danger ? 'btn btn-danger' : 'btn btn-primary'} onClick={() => close(true)}>{options.confirmText}</button></div></div>, { size: 'md', signal: options.signal }).then((value) => value === true);
12
+ }
13
+
14
+ export function openSharedValidation(domain?: DomainRow) {
15
+ let pending = false;
16
+ return modal.open((close) => <SharedValidationDialog domain={domain} close={() => close(null)} onBusy={(value) => { pending = value; }} />, { size: 'lg', canDismiss: () => !pending });
17
+ }
18
+
19
+ export function SharedValidationDialog({ domain: fixedDomain, close, onBusy }: { domain?: DomainRow; close: () => void; onBusy?: (value: boolean) => void }) {
20
+ const qc = useQueryClient(); const { data: me } = useMe(); const auth = useAuthSnapshot();
21
+ const canManage = auth.authenticated && hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
22
+ const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
23
+ const [mode, setMode] = useState<'project' | 'controller'>('project');
24
+ const [targetKind, setTargetKind] = useState<'existing' | 'new'>('existing');
25
+ const [domain, setDomain] = useState<DomainRow | null>(fixedDomain ?? null);
26
+ const [group, setGroup] = useState<DnsGroupChoice | null>(null);
27
+ const nameRef = useRef<HTMLInputElement>(null), allocationRef = useRef<HTMLInputElement>(null);
28
+ const [inspected, setInspected] = useState<{ target: SetupTarget; allocation?: string } | null>(fixedDomain ? { target: { domain: fixedDomain.id } } : null);
29
+ const plan = useValidationPlan(mode === 'project' ? inspected?.target ?? null : null, inspected?.allocation);
30
+ const controllerIdentity = JSON.stringify([auth.uid, revision, canManage]);
31
+ const [controllerAuthority, setControllerAuthority] = useState<{ identity: string; caps: HubCapabilities } | null>(null);
32
+ // A replacement session never borrows the previous session's controller proof,
33
+ // including the render before the cleanup effect clears its selected facts.
34
+ const caps = controllerAuthority?.identity === controllerIdentity ? controllerAuthority.caps : null;
35
+ const setCaps = (value: HubCapabilities | null) => setControllerAuthority(value ? { identity: controllerIdentity, caps: value } : null);
36
+ const [controllerDomain, setControllerDomain] = useState('');
37
+ const [participantCursor, setParticipantCursor] = useState<string | undefined>();
38
+ const [participantLoading, setParticipantLoading] = useState(false);
39
+ const [participantError, setParticipantError] = useState('');
40
+ const [recipientLoading, setRecipientLoading] = useState(false);
41
+ const [recipientError, setRecipientError] = useState('');
42
+ const fileGeneration = useRef(0);
43
+ const downloadUrls = useRef(new Set<string>());
44
+ useEffect(() => () => { fileGeneration.current++; downloadUrls.current.forEach((url) => URL.revokeObjectURL(url)); downloadUrls.current.clear(); }, []);
45
+ const [descriptor, setDescriptor] = useState<ConnectionRequest | null>(null);
46
+ const [allocations, setAllocations] = useState<Page<Allocation> | null>(null);
47
+ const [allocation, setAllocation] = useState<Allocation | null>(null);
48
+ const [recipient, setRecipient] = useState<Recipient | null>(null);
49
+ const [participants, setParticipants] = useState<Page<Participant> | null>(null);
50
+ const [error, setError] = useState(''); const [notice, setNotice] = useState('');
51
+ const [busy, setBusy] = useState(false); const flight = useRef(false);
52
+ const [refreshRequired, setRefreshRequired] = useState(false);
53
+ const selection = JSON.stringify([fixedDomain?.id, fixedDomain?.name, fixedDomain?.group, mode, targetKind, domain?.id, group?.id, inspected, controllerDomain, descriptor, allocation?.allocation_ref, participantCursor, auth.uid, revision, canManage]);
54
+ const latest = useRef(selection); latest.current = selection;
55
+ const owner = useRef<AbortController | null>(null);
56
+ const livePermission = useRef(canManage); livePermission.current = canManage;
57
+ const liveCaps = useRef(caps); liveCaps.current = caps;
58
+ const setPending = (value: boolean) => { flight.current = value; setBusy(value); onBusy?.(value); };
59
+ useEffect(() => {
60
+ const abort = new AbortController(); owner.current = abort;
61
+ return () => { abort.abort(); };
62
+ }, [selection]);
63
+ useEffect(() => () => { onBusy?.(false); }, [onBusy]);
64
+ useEffect(() => {
65
+ setControllerAuthority(null); setDescriptor(null); setAllocations(null); setAllocation(null); setRecipient(null); setParticipants(null);
66
+ fileGeneration.current++; setControllerDomain(''); setParticipantCursor(undefined); setParticipantError(''); setRecipientError('');
67
+ }, [auth.authenticated, canManage, auth.uid, revision]);
68
+ const scope = () => {
69
+ const selected = selection, uid = auth.uid, rev = revision;
70
+ const signal = owner.current?.signal;
71
+ const beforeSend = () => {
72
+ if (signal?.aborted || latest.current !== selected || !livePermission.current || getAuthSnapshot().uid !== uid || getAuthSessionRevision() !== rev) throw new SharedValidationError('access');
73
+ };
74
+ return { signal, beforeSend };
75
+ };
76
+ useEffect(() => { if (fixedDomain) { setDomain(fixedDomain); setInspected({ target: { domain: fixedDomain.id } }); setError(''); setNotice(''); setRefreshRequired(false); } }, [fixedDomain?.id]);
77
+ const resetSelection = () => { fileGeneration.current++; setControllerDomain(''); setParticipantCursor(undefined); setParticipantError(''); setRecipientError(''); setInspected(null); setDescriptor(null); setAllocations(null); setAllocation(null); setRecipient(null); setParticipants(null); setError(''); setNotice(''); setRefreshRequired(false); };
78
+ const inspect = () => {
79
+ try {
80
+ const target: SetupTarget = targetKind === 'existing' && domain ? { domain: domain.id } : group ? { group: group.id, name: normalizeHostname(nameRef.current?.value ?? '') } : (() => { throw new SharedValidationError('invalid'); })();
81
+ const value = allocationRef.current?.value.trim() ?? '';
82
+ setInspected({ target, ...(value ? { allocation: normalizeHostname(value) } : {}) }); setError(''); setNotice(''); setRefreshRequired(false);
83
+ } catch { setError('Choose an authorized domain, or an eligible group and exact domain name, and use only a public validation target hostname.'); }
84
+ };
85
+ const explain = async (reason: unknown) => {
86
+ const safe = reason instanceof SharedValidationError ? reason : new SharedValidationError('unavailable');
87
+ setError(safe.message);
88
+ if (safe.kind === 'reauth') { setRefreshRequired(true); await requestFreshAuth(); }
89
+ };
90
+ const localAction = async (action: 'connect' | 'restart' | 'check' | 'request') => {
91
+ const r = plan.data;
92
+ if (flight.current || !canManage || !r || plan.error || plan.loading || refreshRequired || !inspected) return;
93
+ if (!(action === 'restart' ? r.capabilities.can_restart : action === 'check' ? r.capabilities.can_check : action === 'request' ? r.capabilities.can_request : r.capabilities.can_connect)) return;
94
+ const target = inspected.target; const targetAllocation = r.alias.target;
95
+ if (!targetAllocation) return;
96
+ const fence = scope(), trigger = document.activeElement as HTMLElement | null; setPending(true); setError(''); setNotice('');
97
+ let submitted = false;
98
+ try {
99
+ const confirmed = await confirmShared({
100
+ title: action === 'request' ? 'Request wildcard certificate' : action === 'check' ? 'Check validation' : action === 'restart' ? 'Prepare a fresh connection request' : r.connection ? 'Connect approved validation' : 'Prepare connection',
101
+ message: <div className="dns-shared-confirm"><p><code>{r.domain.name}</code> and <code>*.{r.domain.name}</code></p><p>Existing target: <code>{targetAllocation}</code></p>{r.connection && <p>Connection reference: <code>{r.connection.client_ref}</code></p>}<p>{action === 'request' ? 'Request an independent local certificate for exactly this apex and wildcard. This deployment keeps its own private key.' : action === 'check' ? 'Check current trust and the exact one-hop CNAME, and save the local validation result.' : action === 'restart' ? 'Retire the old reference and prepare a fresh request requiring controller approval again. Keep the same validation target and any valid local certificate.' : 'Preparing shared validation pauses direct local validation and renewal until controller authorization and checking finish. An existing valid certificate remains available. This does not grant trust or replace DNS.'}</p></div>,
102
+ confirmText: action === 'request' ? 'Request wildcard' : action === 'check' ? 'Check validation' : action === 'restart' ? 'Prepare fresh request' : r.connection ? 'Connect validation' : 'Prepare connection',
103
+ signal: fence.signal,
104
+ });
105
+ if (!confirmed) return;
106
+ fence.beforeSend(); submitted = true;
107
+ const result = action === 'check' ? await checkValidation(r.connection!.id, target, targetAllocation, fence) : action === 'request' ? await requestWildcard(r.connection!.id, target, targetAllocation, fence) : await connectValidation(target, targetAllocation, action === 'restart', fence);
108
+ fence.beforeSend(); setNotice(`Backend result: ${result.operation.outcome.replaceAll('_', ' ')}.`);
109
+ } catch (reason) { if (!fence.signal?.aborted) await explain(reason); }
110
+ finally {
111
+ if (submitted) {
112
+ await invalidateValidation(qc);
113
+ if (!fence.signal?.aborted) { const fresh = await plan.refresh(); setRefreshRequired(!fresh); if (!fresh) setError('The operation may have persisted. Refresh successfully before another action.'); }
114
+ }
115
+ setPending(false); restoreFocus(trigger, fence);
116
+ }
117
+ };
118
+ const discardControllerFacts = () => {
119
+ setCaps(null); setAllocations(null); setAllocation(null); setParticipants(null); setRecipient(null); setDescriptor(null);
120
+ setParticipantLoading(false); setRecipientLoading(false);
121
+ };
122
+ const refreshController = async (selected: Allocation, fence = scope()) => {
123
+ try {
124
+ const nextCaps = await getHubCapabilities(fence); fence.beforeSend();
125
+ if (!nextCaps.shared_validation || !nextCaps.can_manage_trust) throw new SharedValidationError('access');
126
+ const rows = await getParticipants(selected, participantCursor, fence); fence.beforeSend();
127
+ setCaps(nextCaps); setParticipants(rows); setParticipantError(''); return rows;
128
+ } catch (reason) {
129
+ if (!fence.signal?.aborted) discardControllerFacts();
130
+ throw reason;
131
+ }
132
+ };
133
+ const loadController = async () => {
134
+ if (flight.current || !canManage) return;
135
+ const fence = scope(); setPending(true); setError('');
136
+ try { const nextCaps = await getHubCapabilities(fence); fence.beforeSend(); setCaps(nextCaps); if (!nextCaps.shared_validation || !nextCaps.can_manage_trust) { discardControllerFacts(); setError('This session cannot manage shared validation trust. Ask the allocation controller.'); } }
137
+ catch (reason) { if (!fence.signal?.aborted) { discardControllerFacts(); await explain(reason); } }
138
+ finally { setPending(false); }
139
+ };
140
+ const clearControllerSelection = () => {
141
+ setAllocations(null); setAllocation(null); setParticipants(null); setRecipient(null); setParticipantCursor(undefined);
142
+ setParticipantError(''); setRecipientError(''); setRefreshRequired(false); setError(''); setNotice('');
143
+ };
144
+ const importRequest = async (file?: File) => {
145
+ if (!file || flight.current || !canManage || !caps?.shared_validation || !caps.can_manage_trust) return;
146
+ const fence = scope(), generation = ++fileGeneration.current;
147
+ setPending(true); setError('');
148
+ try {
149
+ if (!file.size || file.size > CONNECTION_REQUEST_MAX_BYTES) throw new SharedValidationError('invalid');
150
+ const request = parseConnectionRequestText(await file.text());
151
+ fence.beforeSend(); if (generation !== fileGeneration.current) return;
152
+ clearControllerSelection(); setDescriptor(request); setControllerDomain(request.domain);
153
+ } catch { if (!fence.signal?.aborted && generation === fileGeneration.current) setError('Choose a public five-field v1 request file, between 1 and 2048 UTF-8 bytes.'); }
154
+ finally { setPending(false); }
155
+ };
156
+ const findAllocations = async (cursor?: string) => {
157
+ if (flight.current || !canManage || !caps?.shared_validation || !caps.can_manage_trust) return;
158
+ const fence = scope(); setPending(true); setError('');
159
+ try {
160
+ const exact = normalizeHostname(controllerDomain);
161
+ const page = await getAllocations(exact, cursor, fence); fence.beforeSend();
162
+ setAllocations(page); setAllocation(null); setParticipants(null); setRecipient(null); setParticipantCursor(undefined); setRefreshRequired(false);
163
+ } catch (reason) { if (!fence.signal?.aborted) { discardControllerFacts(); await explain(reason); } }
164
+ finally { setPending(false); }
165
+ };
166
+ // Participant maintenance is authorized by the selected allocation, independently
167
+ // of an imported request or active recipient. Each read has its own error state.
168
+ useEffect(() => {
169
+ if (!allocation || mode !== 'controller' || !canManage || !caps?.shared_validation || !caps.can_manage_trust) return;
170
+ const fence = scope(); let active = true; setParticipantLoading(true); setParticipantError(''); setParticipants(null);
171
+ void getParticipants(allocation, participantCursor, fence).then((rows) => { fence.beforeSend(); if (active) setParticipants(rows); }).catch((reason) => {
172
+ if (active && !fence.signal?.aborted) { discardControllerFacts(); setRefreshRequired(true); void explain(reason); }
173
+ }).finally(() => { if (active) setParticipantLoading(false); });
174
+ return () => { active = false; };
175
+ // selection owns all auth, allocation, request and page fences.
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, [selection, caps?.shared_validation, caps?.can_manage_trust]);
178
+ const requestMatches = !!descriptor && !!allocation && descriptor.domain === allocation.domain && descriptor.allocation_ref === allocation.allocation_ref;
179
+ useEffect(() => {
180
+ setRecipient(null); setRecipientError(''); setRecipientLoading(false);
181
+ if (!descriptor || !allocation || !requestMatches || !canManage || !caps?.shared_validation || !caps.can_manage_trust) return;
182
+ const fence = scope(); let active = true; setRecipientLoading(true);
183
+ void getRecipient(allocation, descriptor.consumer_project_uuid, fence).then((person) => { fence.beforeSend(); if (active) setRecipient(person); }).catch((reason) => {
184
+ if (active && !fence.signal?.aborted) setRecipientError(reason instanceof SharedValidationError ? reason.message : new SharedValidationError('unavailable').message);
185
+ }).finally(() => { if (active) setRecipientLoading(false); });
186
+ return () => { active = false; };
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, [selection, caps?.shared_validation, caps?.can_manage_trust]);
189
+ const exportRequest = async (action: 'download' | 'copy' | 'share') => {
190
+ const request = plan.data?.connection_request;
191
+ if (!request || flight.current || !canManage || plan.error || plan.loading) return;
192
+ const fence = scope(); setPending(true); setError('');
193
+ try {
194
+ fence.beforeSend(); const contents = serializeConnectionRequest(request);
195
+ if (action === 'copy') { await navigator.clipboard.writeText(contents); fence.beforeSend(); toast.success('Public connection request copied'); }
196
+ else {
197
+ const file = new File([contents], 'shared-validation-request.json', { type: 'application/json' });
198
+ if (action === 'share') {
199
+ if (!navigator.canShare?.({ files: [file] }) || !navigator.share) throw new Error();
200
+ await navigator.share({ files: [file] }); fence.beforeSend();
201
+ } else {
202
+ const url = URL.createObjectURL(file); downloadUrls.current.add(url);
203
+ const link = document.createElement('a'); link.href = url; link.download = file.name;
204
+ document.body.append(link); link.click(); link.remove();
205
+ setTimeout(() => { URL.revokeObjectURL(url); downloadUrls.current.delete(url); }, 0);
206
+ }
207
+ }
208
+ } catch (reason) {
209
+ if (!fence.signal?.aborted && !(reason instanceof DOMException && reason.name === 'AbortError')) setError('This handoff was unavailable. Use Download request to save the same public request file.');
210
+ } finally { setPending(false); }
211
+ };
212
+ const controllerAction = async (participant?: Participant) => {
213
+ if (flight.current || refreshRequired || !canManage || !caps?.shared_validation || !caps.can_manage_trust || !allocation?.can_manage_trust || !participants || participantLoading) return;
214
+ const selected = allocation, request = descriptor, person = recipient;
215
+ if (!participant && (!requestMatches || !request || !person?.active || person.consumer_project_uuid !== request.consumer_project_uuid || recipientLoading)) return;
216
+ const existing = participants.items.find((p) => p.client_ref === request?.client_ref && p.consumer_project_uuid === request?.consumer_project_uuid);
217
+ if (!participant && existing?.trust_state === 'revoked') { setError('This reference was revoked. Ask the project to prepare a fresh request.'); return; }
218
+ if (participant && (participant.role !== 'trusted' || participant.allocation_ref !== selected.allocation_ref || !participants.items.includes(participant))) return;
219
+ const fence = scope(), trigger = document.activeElement as HTMLElement | null; setPending(true); setError(''); setNotice(''); let submitted = false;
220
+ try {
221
+ const confirmed = await confirmShared({
222
+ title: participant ? participant.trust_state === 'revoked' ? 'Retry revoked project cleanup' : 'Revoke project trust' : 'Grant certificate issuance authority', danger: !!participant,
223
+ message: <div className="dns-shared-confirm"><p><strong>{participant?.consumer_label ?? person!.label}</strong></p><p>Project: <code>{participant?.consumer_project_uuid ?? person!.consumer_project_uuid}</code></p>{participant && <p>Participant: <code>{participant.participant_ref}</code></p>}<p>Domain: <code>{selected.domain}</code><br />Target: <code>{selected.target}</code><br />Reference: <code>{participant?.client_ref ?? request!.client_ref}</code></p><p>{participant?.trust_state === 'revoked' ? 'Retry DNS cleanup for this already-revoked reference. It remains revoked; this never restores issuance authority. Other trusted projects keep working.' : participant ? 'Stop future DNS-01 validation for this project. Certificates already issued remain valid until expiry or separate revocation. Other trusted projects keep working.' : `Allow this project to issue certificates for ${selected.domain} and *.${selected.domain} using this validation target. Each deployment keeps its own certificate and private key.`}</p></div>,
224
+ confirmText: participant ? participant.trust_state === 'revoked' ? 'Retry cleanup' : 'Revoke trust' : 'Allow issuance', signal: fence.signal,
225
+ });
226
+ if (!confirmed) return;
227
+ fence.beforeSend(); if (!liveCaps.current?.can_manage_trust) throw new SharedValidationError('access');
228
+ submitted = true;
229
+ if (participant) await revokeValidation(selected, participant.participant_ref, fence); else await grantValidation(selected, request!, fence);
230
+ fence.beforeSend(); setNotice(participant ? 'Trust revoked. Other consumers remain unaffected.' : 'Exact connection authorized. The project can reconnect using its original request.');
231
+ } catch (reason) { if (!fence.signal?.aborted) { await explain(reason); if (participant && submitted) setNotice('Revocation may have persisted; cleanup is unconfirmed. Review refreshed trust before continuing.'); } }
232
+ finally {
233
+ if (submitted) {
234
+ await invalidateValidation(qc);
235
+ if (!fence.signal?.aborted) { try { await refreshController(selected, fence); setRefreshRequired(false); } catch { setRefreshRequired(true); setError('The operation may have persisted. Refresh successfully before another action.'); } }
236
+ }
237
+ setPending(false); restoreFocus(trigger, fence);
238
+ }
239
+ };
240
+ const refresh = async () => {
241
+ if (flight.current) return;
242
+ if (mode === 'project') { const fresh = await plan.refresh(); setRefreshRequired(!fresh); if (fresh) { setError(''); setNotice(''); } }
243
+ else if (allocation) { setPending(true); try { await refreshController(allocation); setRefreshRequired(false); setError(''); } catch (reason) { setRefreshRequired(true); await explain(reason); } finally { setPending(false); } }
244
+ else await loadController();
245
+ };
246
+ const r = plan.data;
247
+ return <div className="modal-pad dns-shared-dialog">
248
+ <h2 className="modal-title">Shared validation setup</h2>
249
+ <p className="dim">Share an existing DNS-01 validation target. Each project issues its own certificate and keeps its own private key.</p>
250
+ <div className="seg" aria-label="Shared validation role"><button className={`seg-btn${mode === 'project' ? ' seg-active' : ''}`} disabled={busy} onClick={() => { resetSelection(); setMode('project'); if (fixedDomain) setInspected({ target: { domain: fixedDomain.id } }); }}>Project setup</button><button className={`seg-btn${mode === 'controller' ? ' seg-active' : ''}`} disabled={busy || !canManage} onClick={() => { resetSelection(); setMode('controller'); }}>Controller review</button></div>
251
+ {error && <div className="dns-errors" role="alert">{error}</div>}{notice && <div className="dns-provider-note" role="status">{notice}</div>}
252
+ {!canManage && <p className="dns-blocked">The current session can inspect available facts but cannot perform shared setup.</p>}
253
+ {mode === 'project' ? <>
254
+ {fixedDomain ? <FlatRow label="Domain"><code>{fixedDomain.name}</code></FlatRow> : <>
255
+ <label className="field"><span className="field-label">Target type</span><select className="input" disabled={busy} value={targetKind} onChange={(e) => { resetSelection(); setTargetKind(e.target.value as 'existing' | 'new'); }}><option value="existing">Existing local domain</option><option value="new">Eligible group and exact name</option></select></label>
256
+ {targetKind === 'existing' ? <CollectionSelect<DomainRow> model={DomainModel} sanitizeRow={sanitizeDomainRow} value={domain ? { ...domain } : null} label="Local domain" disabled={busy} onChange={(_id, row) => { resetSelection(); setDomain(row ? sanitizeDomainRow(row) : null); }} maxItems={25} /> : <><CollectionSelect<DnsGroupChoice> endpoint={SETUP_GROUP_ENDPOINT} sanitizeRow={sanitizeSetupGroup} value={group} label="Eligible group" maxItems={25} disabled={busy || !canManage} onChange={(_id, row) => { resetSelection(); setGroup(row ? sanitizeSetupGroup(row) : null); }} /><label className="field"><span className="field-label">Exact domain name</span><input ref={nameRef} className="input" maxLength={253} disabled={busy} placeholder="customer.example" onChange={() => setInspected(null)} /></label></>}
257
+ </>}
258
+ <label className="field"><span className="field-label">Existing validation target (optional for inspection)</span><input ref={allocationRef} className="input" maxLength={253} disabled={busy} placeholder="existing-target.acme-hub.example" onChange={() => setInspected(null)} /><span className="field-help">Only the public target hostname supplied by its controller. Knowing it does not grant issuance authority.</span></label>
259
+ <div className="dns-shared-actions"><button className="btn" disabled={busy} onClick={inspect}>Inspect plan</button><button className="btn" disabled={busy || plan.loading || !inspected} onClick={() => void refresh()}>Refresh</button></div>
260
+ {plan.loading && inspected && <p className="dim">Checking validation…</p>}{plan.error && <div className="dns-errors" role="alert">{plan.error.message}{r && ' Displaying the last successful check.'}</div>}
261
+ {r && <><ValidationFacts readiness={r} />{r.connection_request && canManage && <div className="dns-shared-request"><strong>Public connection request</strong><RequestFacts request={r.connection_request} /><p>Send this file to the allocation controller. They open their deployment’s Domains → Advanced → Shared validation setup → Controller review and import it.</p><div className="dns-shared-actions"><button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('download')}>Download request</button>{typeof navigator.canShare === 'function' && typeof navigator.share === 'function' && <button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('share')}>Share request</button>}<button className="btn" disabled={busy || !!plan.error || plan.loading} onClick={() => void exportRequest('copy')}>Copy request</button></div></div>}
262
+ {canManage && <div className="dns-shared-actions">
263
+ {r.capabilities.can_connect && r.next_action.code !== 'grant_trust' && <button className="btn btn-primary" disabled={busy || !!plan.error || plan.loading || refreshRequired} onClick={() => void localAction('connect')}>{r.connection ? 'Connect approved validation' : 'Prepare connection'}</button>}
264
+ {r.capabilities.can_restart && r.trust.state === 'revoked' && <button className="btn btn-primary" disabled={busy || !!plan.error || plan.loading || refreshRequired} onClick={() => void localAction('restart')}>Prepare fresh request</button>}
265
+ {r.capabilities.can_check && <button className="btn" disabled={busy || !!plan.error || plan.loading || refreshRequired} onClick={() => void localAction('check')}>Check validation</button>}
266
+ {r.capabilities.can_request && <button className="btn btn-primary" disabled={busy || !!plan.error || plan.loading || refreshRequired} onClick={() => void localAction('request')}>Request wildcard certificate</button>}
267
+ </div>}
268
+ </>}{plan.timedOut && <p className="dns-warning">Automatic checks reached their limit. Refresh for current status.</p>}
269
+ </> : <>
270
+ <p className="dim">Only the existing allocation’s authorized controller can grant issuance authority. Ordinary DNS management is insufficient.</p>
271
+ <button className="btn" disabled={busy || !canManage} onClick={() => void loadController()}>Check controller access</button>
272
+ {caps?.shared_validation && caps.can_manage_trust && <>
273
+ <label className="field"><span className="field-label">Exact domain</span><input className="input" aria-label="Exact domain" value={controllerDomain} maxLength={253} disabled={busy} placeholder="acme.example" onChange={(e) => { clearControllerSelection(); setDescriptor(null); setControllerDomain(e.target.value); }} /><span className="field-help">Find an existing allocation to maintain its projects. A request file is needed only for a new grant.</span></label>
274
+ <div className="dns-shared-actions"><button className="btn" disabled={busy || !controllerDomain.trim()} onClick={() => void findAllocations()}>Find allocations</button><label className="field"><span className="field-label">Import request</span><input type="file" accept=".json,application/json" aria-label="Import request" disabled={busy} onChange={(e) => { const file = e.currentTarget.files?.[0]; e.currentTarget.value = ''; void importRequest(file); }} /></label></div>
275
+ {descriptor && <div className="dns-shared-request"><strong>Imported public request</strong><RequestFacts request={descriptor} /><button className="btn" disabled={busy} onClick={() => setDescriptor(null)}>Clear imported request</button><p className="dim">Importing grants no authority. Find and select this exact existing target before reviewing a grant.</p></div>}
276
+ {allocations && <fieldset className="dns-shared-allocations"><legend>Existing allocations</legend>{allocations.items.length === 0 && <p>No accessible allocation for this exact domain.</p>}{allocations.items.map((row) => <label className="dns-shared-allocation" key={row.allocation_ref}><input type="radio" name="shared-allocation" disabled={busy || !row.can_manage_trust} checked={allocation?.allocation_ref === row.allocation_ref} onChange={() => { if (descriptor && (descriptor.domain !== row.domain || descriptor.allocation_ref !== row.allocation_ref)) setDescriptor(null); setAllocation(row); setParticipants(null); setRecipient(null); setParticipantCursor(undefined); setRefreshRequired(false); }} /><span><strong>{row.target}</strong><small>Owner project: {row.owner_project_uuid}</small>{!row.can_manage_trust && <small>Trust management unavailable</small>}</span></label>)}<div className="dns-shared-actions">{allocations.next_cursor && <button className="btn" disabled={busy} onClick={() => void findAllocations(allocations.next_cursor!)}>Next allocation page</button>}<button className="btn" disabled={busy} onClick={() => void findAllocations()}>First allocation page</button></div></fieldset>}
277
+ {allocation && <div className="dns-shared-request"><FlatRow label="Selected domain"><code>{allocation.domain}</code></FlatRow><FlatRow label="Validation source"><code>{allocation.source}</code></FlatRow><FlatRow label="Exact target"><code>{allocation.target}</code></FlatRow><FlatRow label="Issuance scope"><code>{allocation.domain}, *.{allocation.domain}</code></FlatRow></div>}
278
+ {descriptor && allocation && !requestMatches && <p className="dns-warning">The imported request does not match this selected domain and allocation. A grant is unavailable.</p>}
279
+ {recipientLoading && <p className="dim">Checking exact grant recipient…</p>}{recipientError && <div className="dns-errors" role="alert">Grant recipient: {recipientError} Existing project maintenance remains available.</div>}
280
+ {requestMatches && recipient && <div className="dns-shared-request"><FlatRow label="Active recipient">{recipient.label}</FlatRow><FlatRow label="Project UUID"><code>{recipient.consumer_project_uuid}</code></FlatRow><p>Allow this project to issue certificates for {allocation!.domain} and *.{allocation!.domain} using this validation target. Each deployment keeps its own certificate and private key.</p><button className="btn btn-primary" disabled={busy || refreshRequired || !participants || participantLoading || recipientLoading} onClick={() => void controllerAction()}>Review grant</button></div>}
281
+ {participantLoading && allocation && <p className="dim">Loading selected allocation’s projects…</p>}{participantError && <div className="dns-errors" role="alert">{participantError}</div>}
282
+ {allocation && participants && <><h3>Selected allocation’s projects</h3>{participants.items.length === 0 && <p>No projects on this page.</p>}{participants.items.map((p) => <div className="dns-shared-request" key={p.participant_ref}><strong>{p.consumer_label}</strong><code>{p.consumer_project_uuid}</code><span>{p.role} · {p.trust_state}</span><small>Reference: {p.client_ref}</small><small>Participant: {p.participant_ref}</small><small>Scope: {p.scope.join(', ')}</small>{p.revoked_at && <small>Revoked: {p.revoked_at}</small>}{p.role === 'trusted' && <button className="btn" disabled={busy || refreshRequired || participantLoading} onClick={() => void controllerAction(p)}>{p.trust_state === 'revoked' ? 'Review cleanup retry' : 'Review revoke'}</button>}</div>)}<div className="dns-shared-actions">{participants.next_cursor && <button className="btn" disabled={busy || participantLoading} onClick={() => setParticipantCursor(participants.next_cursor!)}>Next project page</button>}{participantCursor && <button className="btn" disabled={busy || participantLoading} onClick={() => setParticipantCursor(undefined)}>First project page</button>}</div></>}
283
+ {allocation && <button className="btn" disabled={busy || participantLoading} onClick={() => void refresh()}>Refresh trust</button>}
284
+ </>}
285
+ </>}
286
+ <div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{busy ? 'Operation pending…' : 'Close'}</button></div>
287
+ </div>;
288
+ }
289
+
290
+ function restoreFocus(trigger: HTMLElement | null, fence: { beforeSend: () => void }) {
291
+ // Native dialog restoration happens while this single-flight trigger is still
292
+ // disabled. Restore after React enables it, only if the same scope survives.
293
+ setTimeout(() => { try { fence.beforeSend(); if (trigger?.isConnected) trigger.focus(); } catch { /* Scope no longer owns this focus. */ } }, 0);
294
+ }
295
+
296
+ function RequestFacts({ request }: { request: ConnectionRequest }) {
297
+ return <dl>{[['Request version', request.version], ['Project UUID', request.consumer_project_uuid], ['Client reference', request.client_ref], ['Domain', request.domain], ['Validation target', request.allocation_ref], ['Issuance scope', `${request.domain}, *.${request.domain}`]].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl>;
298
+ }
@@ -0,0 +1,76 @@
1
+ import { Badge, Eyebrow, FlatRow, fmt } from '../../ui';
2
+ import { Link, useInRouterContext, useLocation } from 'react-router-dom';
3
+ import { useMe } from '../../client/runtime';
4
+ import { fleetConnectionRoute } from '../fleet-configuration/operator-destinations';
5
+ import { useValidationPlan } from './shared-validation-api';
6
+ import type { Readiness } from './shared-validation-data';
7
+ import type { DomainRow } from './models';
8
+
9
+ const nextCopy: Record<string, string> = {
10
+ configure_hub: 'A deployment operator must configure the server, restart application and worker processes, then Refresh Portal.',
11
+ upgrade_hub: 'A deployment operator must enable a compatible shared-validation backend, then Refresh.',
12
+ repair_hub_access: 'Ask the deployment operator to repair the configured project access; do not change the validation target.',
13
+ retry_hub: 'The configured hub is unavailable. Refresh after the deployment operator restores access.',
14
+ choose_allocation: 'Enter the existing validation target supplied by its controller.',
15
+ prepare_connection: 'Prepare an immutable public request for the allocation controller.',
16
+ grant_trust: 'Ask the allocation controller to review and authorize this exact connection request.',
17
+ restart_connection: 'This reference was revoked. Prepare a fresh request explicitly; keep the existing validation target.',
18
+ connect: 'Connect the approved validation using the same prepared request.',
19
+ repair_alias: 'Ask the domain owner to restore the approved one-hop CNAME after checking other users of this target.',
20
+ check: 'Check the approved validation route before requesting a certificate.',
21
+ request: 'Validation is ready. Request this deployment’s independent apex and wildcard certificate.',
22
+ wait_for_job: 'Issuance is queued or running. This view watches for a bounded time.',
23
+ repair_worker: 'Ask the deployment operator to inspect and restart the certificate worker.',
24
+ restore_custody: 'Ask the deployment operator to restore access to this local certificate’s stored material.',
25
+ inspect_failure: 'Review the separate hub, trust, DNS and renewal facts below with the project operator.',
26
+ unsupported_domain_scope: 'Shared setup requires a group-managed domain in this rollout. Existing direct-provider operation remains available.',
27
+ none: 'No setup action is currently required.',
28
+ };
29
+ function OperatorLink() {
30
+ const location = useLocation();
31
+ return <Link className="btn" to={fleetConnectionRoute(location.pathname)} target="_blank" rel="noopener">Open deployment connection setup</Link>;
32
+ }
33
+ function OperatorHandoff({ readiness: r }: { readiness: Readiness }) {
34
+ const { data: me } = useMe(); const routed = useInRouterContext();
35
+ return <div className="dns-blocked"><strong>Deployment operator repair</strong><p>{r.domain.name} · hub {r.hub.state} · {r.next_action.code.replaceAll('_', ' ')} · observed {fmt.datetime(r.checked_at)}.</p>{me?.is_superuser === true && routed ? <><OperatorLink /><p>The protected Fleet form opens in a separate tab. It provides approved profiles, write-only key entry and node verification. Existing release holds and hub activation remain separate.</p></> : <p>Ask this deployment’s operator to open Fleet Configuration → Shared validation connection, review these facts and use its permitted Setup/deployment destinations. This session cannot edit protected credentials. Refresh after repair to see authoritative status.</p>}</div>;
36
+ }
37
+ export function ValidationFacts({ readiness: r }: { readiness: Readiness }) {
38
+ const certificate = r.certificate;
39
+ return <div className="dns-shared-facts">
40
+ <Eyebrow>Shared validation</Eyebrow>
41
+ <p className="dns-provider-note">{nextCopy[r.next_action.code]}</p>
42
+ {r.next_action.actor === 'deployment_operator' && <OperatorHandoff readiness={r} />}
43
+ <FlatRow label="Hub"><Badge tone={r.hub.state === 'ready' ? 'success' : 'warning'}>{r.hub.state}</Badge></FlatRow>
44
+ <FlatRow label="Trust"><Badge tone={r.trust.state === 'active' ? 'success' : r.trust.state === 'revoked' ? 'danger' : 'warning'}>{r.trust.state.replaceAll('_', ' ')}</Badge></FlatRow>
45
+ <FlatRow label="Local connection">{r.connection ? `${r.connection.mode} · ${r.connection.state}` : 'Not prepared'}</FlatRow>
46
+ {r.connection?.mode === 'shared' && r.connection.state !== 'verified' && <p className="dns-warning">Direct local validation and renewal wait for shared authorization and checking. An existing valid certificate remains available.</p>}
47
+ {r.trust.state === 'revoked' && <p className="dns-warning">Future validation is blocked. Certificates already issued remain valid until expiry or separate revocation. Other trusted projects keep working.</p>}
48
+ <Eyebrow>ACME validation</Eyebrow>
49
+ <FlatRow label="CNAME source"><code>{r.alias.source}</code></FlatRow>
50
+ <FlatRow label="Existing target"><code>{r.alias.target ?? 'Not selected'}</code></FlatRow>
51
+ <FlatRow label="Proof">{r.alias.state.replaceAll('_', ' ')}{r.alias.one_hop === false ? ' · chained alias' : r.alias.one_hop ? ' · one hop' : ''}</FlatRow>
52
+ <FlatRow label="Observed target"><code>{r.alias.observed_target ?? 'Unavailable / not yet checked'}</code></FlatRow>
53
+ <FlatRow label="DNS checked">{r.alias.checked_at ? fmt.datetime(r.alias.checked_at) : 'Not yet checked'}</FlatRow>
54
+ <p className="dim">Keep this approved CNAME. Restore it only after checking its other consumers; do not replace or remove it as a default repair.</p>
55
+ <Eyebrow>Traffic routing</Eyebrow>
56
+ <p className="dim">The <code>_acme-challenge</code> record validates certificate issuance only. Apex, www, API and app A/AAAA/CNAME records follow your deployment’s separate traffic instructions.</p>
57
+ <Eyebrow>Local certificate</Eyebrow>
58
+ <FlatRow label="Certificate">{certificate ? `${certificate.state} · local ${certificate.id}` : 'Not requested'}</FlatRow>
59
+ <FlatRow label="Validity">{certificate?.serving_valid === true ? `Valid until ${fmt.date(certificate.not_after)}` : certificate?.serving_valid === false ? 'No currently valid local certificate' : 'Unknown'}</FlatRow>
60
+ <FlatRow label="Material custody">{certificate?.material_state ?? 'No certificate'}</FlatRow>
61
+ <p className="dim">Each deployment keeps its own certificate and private key. Local custody does not prove installation or serving on an edge host.</p>
62
+ <Eyebrow>Renewal</Eyebrow>
63
+ <FlatRow label="Renewal state">{r.renewal.state}</FlatRow>
64
+ <FlatRow label="Next attempt">{fmt.date(r.renewal.next_attempt_at)}</FlatRow>
65
+ {r.renewal.error_code && <FlatRow label="Renewal reason"><code>{r.renewal.error_code}</code></FlatRow>}
66
+ {certificate?.serving_valid === true && ['blocked', 'failed', 'retrying'].includes(r.renewal.state) && <p className="dns-warning">The local certificate is valid until {fmt.date(certificate.not_after)}; renewal needs attention.</p>}
67
+ {r.renewal.cleanup_pending && <p className="dns-warning">Validation cleanup remains unconfirmed.</p>}
68
+ <FlatRow label="Worker version">{r.renewal.worker_version ?? 'Unknown — confirm the worker was upgraded and restarted'}</FlatRow>
69
+ <FlatRow label="Last plan check">{fmt.datetime(r.checked_at)}</FlatRow>
70
+ <FlatRow label="Next action">{r.next_action.code.replaceAll('_', ' ')} · {r.next_action.actor.replaceAll('_', ' ')}</FlatRow>
71
+ </div>;
72
+ }
73
+ export function SharedValidationPanel({ domain }: { domain: DomainRow }) {
74
+ const plan = useValidationPlan({ domain: domain.id });
75
+ return <div className="dns-shared-panel">{plan.error && <div className="dns-errors" role="alert">{plan.error.message}{plan.data && ' The facts below are from the last successful check.'}</div>}{plan.loading && <p className="dim">Checking validation…</p>}{plan.data && <ValidationFacts readiness={plan.data} />}{plan.timedOut && <p className="dns-warning">Automatic checks reached their limit. Refresh to inspect current status.</p>}<button className="btn" type="button" disabled={plan.loading} onClick={() => void plan.refresh()}>Refresh validation</button></div>;
76
+ }
@@ -226,8 +226,10 @@ export function quoteDomain(input: { group: number; domain: string; years?: numb
226
226
  return postData('/api/dnsman/registrar/quote', input);
227
227
  }
228
228
 
229
- export async function purchaseDomain(input: { group: number; purchase: number; confirm_token: string }): Promise<RegistrarPurchaseResult> {
230
- return sanitizePurchaseResult(await postData('/api/dnsman/registrar/purchase', input));
229
+ export async function purchaseDomain(input: { group: number; purchase: number; confirm_token: string; confirm_domain: string; confirm_price: string }): Promise<RegistrarPurchaseResult> {
230
+ // Snapshot once: the safe 440 continuation must use the identical quote.
231
+ const body = Object.freeze({ ...input });
232
+ return sanitizePurchaseResult(await withFreshAuth(() => postData('/api/dnsman/registrar/purchase', body)));
231
233
  }
232
234
 
233
235
  /** Detail and its error remain modal-local; neither enters Query cache. */
@@ -0,0 +1,16 @@
1
+ import type { DomainRow } from './models';
2
+
3
+ /** The observed DNS route supplies the mount, including embedded prefixes. */
4
+ export function dnsAdminMount(pathname: string): string {
5
+ const marker = pathname.indexOf('/dns/');
6
+ return marker < 0 ? '' : pathname.slice(0, marker);
7
+ }
8
+ export function domainRecordsHref(pathname: string, id: number): string {
9
+ return `${dnsAdminMount(pathname)}/dns/records?domain=${encodeURIComponent(id)}`;
10
+ }
11
+ export function domainScopeLabel(group: DomainRow['group']): string {
12
+ return group == null ? 'Platform' : typeof group === 'number' ? `Group #${group}` : group.name;
13
+ }
14
+ export function supportsDomainRecords(provider: string): boolean {
15
+ return provider === 'route53' || provider === 'godaddy';
16
+ }
@@ -0,0 +1,13 @@
1
+ import { mojoCall } from '../../client/runtime';
2
+ import { sanitizeDomainRow, type DomainRow } from './models';
3
+
4
+ /** Registrar writes are deliberately narrower than the generic model surface. */
5
+ export async function saveDomainRegistrarSettings(id: number, changes: Partial<Pick<DomainRow, 'auto_renew' | 'privacy'>>): Promise<void> {
6
+ if (Object.keys(changes).length === 0 || Object.entries(changes).some(([key, value]) => !['auto_renew', 'privacy'].includes(key) || typeof value !== 'boolean')) throw new Error('Only changed boolean registrar settings may be saved');
7
+ await mojoCall(`/api/dnsman/domain/${id}`, { method: 'POST', body: { ...changes } });
8
+ }
9
+
10
+ /** Authoritative uncached read after every attempted registrar write. */
11
+ export async function fetchDomainOverview(id: number): Promise<DomainRow> {
12
+ return sanitizeDomainRow((await mojoCall(`/api/dnsman/domain/${id}`)).data);
13
+ }
@@ -0,0 +1,90 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { type QueryClient } from '@tanstack/react-query';
3
+ import { getAuthSessionRevision, getAuthSnapshot, hasPermission, mojoCall, MojoError, useAuthSnapshot, useMe, type Params } from '../../client/runtime';
4
+ import { CertificateModel, DNS_MANAGE_PERMISSIONS, DomainModel, type DnsGroupChoice } from './models';
5
+ import { dnsKeys } from './api';
6
+ import { CERTIFICATE_POLL_INTERVAL_MS, CERTIFICATE_POLL_MAX_TICKS } from './CertificateLifecyclePoller';
7
+ import { parseAllocation, parseConnectionRequest, parseHubCapabilities, parsePage, parseParticipant, parseReadiness, parseRecipient, pendingReadiness, positive, record, SharedValidationError, text, type Allocation, type ConnectionRequest, type SetupTarget } from './shared-validation-data';
8
+
9
+ export const SETUP_GROUP_ENDPOINT = '/api/dnsman/delegation/group-choice';
10
+ const expectedCodes = new Set(['trust_missing', 'trust_revoked', 'hub_identity_changed', 'allocation_conflict', 'alias_mismatch', 'alias_lookup_failed', 'connection_not_verified', 'certificate_busy', 'cleanup_pending', 'profile_conflict', 'stale_connection', 'unsupported_domain_scope']);
11
+ export interface CallScope { signal?: AbortSignal; beforeSend?: () => void }
12
+ async function call(path: string, body: Record<string, unknown> | null, scope: CallScope = {}, params?: Params): Promise<unknown> {
13
+ try { return (await mojoCall(`/api/dnsman/${path}`, { ...(body === null ? { params } : { method: 'POST', body }), ...scope })).data; }
14
+ catch (error) {
15
+ if (error instanceof SharedValidationError) throw error;
16
+ if (scope.signal?.aborted) throw new SharedValidationError('access');
17
+ if (error instanceof MojoError) {
18
+ if (error.status === 440) throw new SharedValidationError('reauth');
19
+ if (error.status === 401 || error.status === 403) throw new SharedValidationError('access');
20
+ if (error.status === 404 || error.status === 405) throw new SharedValidationError('unsupported');
21
+ if (typeof error.errorCode === 'string' && expectedCodes.has(error.errorCode)) {
22
+ let readiness = null; try { readiness = parseReadiness(error.data); } catch { /* Only valid public evidence survives. */ }
23
+ throw new SharedValidationError('prerequisite', readiness, error.errorCode);
24
+ }
25
+ }
26
+ throw new SharedValidationError('unavailable');
27
+ }
28
+ }
29
+ export const getValidationPlan = async (target: SetupTarget, allocation?: string, scope?: CallScope) => parseReadiness(await call('delegation/plan', null, scope, { ...target, ...(allocation ? { allocation_ref: allocation } : {}) }), target, allocation);
30
+ export const connectValidation = async (target: SetupTarget, allocation: string, restart: boolean, scope?: CallScope) => parseReadiness(await call('delegation/connect', { ...target, allocation_ref: allocation, ...(restart ? { restart: true } : {}) }, scope), target, allocation);
31
+ export const checkValidation = async (delegation: number, target: SetupTarget, allocation: string, scope?: CallScope) => parseReadiness(await call('delegation/check', { delegation: positive(delegation) }, scope), target, allocation);
32
+ export const requestWildcard = async (delegation: number, target: SetupTarget, allocation: string, scope?: CallScope) => parseReadiness(await call('delegation/request', { delegation: positive(delegation) }, scope), target, allocation);
33
+ export const getHubCapabilities = async (scope?: CallScope) => parseHubCapabilities(await call('acme/capabilities', {}, scope));
34
+ export const getAllocations = async (domain: string, cursor?: string, scope?: CallScope) => parsePage(await call('acme/allocation/list', { domain, ...(cursor ? { cursor } : {}) }, scope), (r) => parseAllocation(r, domain));
35
+ export const getParticipants = async (allocation: Allocation, cursor?: string, scope?: CallScope) => parsePage(await call('acme/trust/list', { allocation_ref: allocation.allocation_ref, ...(cursor ? { cursor } : {}) }, scope), (r) => parseParticipant(r, allocation));
36
+ export const getRecipient = async (allocation: Allocation, consumer: string, scope?: CallScope) => parseRecipient(await call('acme/trust/recipient', { allocation_ref: allocation.allocation_ref, consumer_project_uuid: consumer }, scope), consumer);
37
+ export const grantValidation = async (allocation: Allocation, descriptor: ConnectionRequest, scope?: CallScope) => {
38
+ const result = parseParticipant(await call('acme/trust/grant', { connection_request: parseConnectionRequest(descriptor) }, scope), allocation);
39
+ if (result.client_ref !== descriptor.client_ref || result.consumer_project_uuid !== descriptor.consumer_project_uuid || result.role !== 'trusted' || result.trust_state !== 'active') throw new SharedValidationError('invalid');
40
+ return result;
41
+ };
42
+ export const revokeValidation = async (allocation: Allocation, participant: string, scope?: CallScope) => {
43
+ const result = parseParticipant(await call('acme/trust/revoke', { allocation_ref: allocation.allocation_ref, participant_ref: participant }, scope), allocation);
44
+ if (result.participant_ref !== participant || result.trust_state !== 'revoked') throw new SharedValidationError('invalid'); return result;
45
+ };
46
+ /** CollectionSelect hydration must use ?id, never invent /<id>. */
47
+ export const sanitizeSetupGroup = (v: unknown): DnsGroupChoice => ({ id: positive(record(v).id), name: text(record(v).name) });
48
+ export async function getSetupGroup(id: number, scope?: CallScope) {
49
+ const value = await call('delegation/group-choice', null, scope, { id: positive(id) });
50
+ if (!Array.isArray(value) || value.length !== 1) throw new SharedValidationError('access');
51
+ const group = sanitizeSetupGroup(value[0]); if (group.id !== id) throw new SharedValidationError('invalid'); return group;
52
+ }
53
+ export async function invalidateValidation(qc: QueryClient) {
54
+ await Promise.all([DomainModel.invalidate(qc), CertificateModel.invalidate(qc), qc.invalidateQueries({ queryKey: dnsKeys.root })]);
55
+ }
56
+ export function useValidationPlan(target: SetupTarget | null, allocation?: string, enabled = true, intervalMs = CERTIFICATE_POLL_INTERVAL_MS, maxTicks = CERTIFICATE_POLL_MAX_TICKS) {
57
+ const auth = useAuthSnapshot(); const { data: me } = useMe();
58
+ const revision = getAuthSessionRevision();
59
+ const key = JSON.stringify([target, allocation ?? '', auth.uid, revision, hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)]);
60
+ const current = useRef(key); current.current = key;
61
+ const generation = useRef(0);
62
+ const [state, setState] = useState<{ key: string; data: Awaited<ReturnType<typeof getValidationPlan>> | null; error: SharedValidationError | null; loading: boolean }>({ key, data: null, error: null, loading: false });
63
+ const controller = useRef<AbortController | null>(null);
64
+ const refresh = useCallback(async () => {
65
+ if (!target || !enabled || !auth.authenticated) return null;
66
+ const run = ++generation.current; controller.current?.abort();
67
+ const abort = new AbortController(); controller.current = abort;
68
+ const valid = () => current.current === key && generation.current === run && !abort.signal.aborted && getAuthSnapshot().uid === auth.uid && getAuthSessionRevision() === revision;
69
+ setState((s) => ({ key, data: s.key === key ? s.data : null, error: null, loading: true }));
70
+ try {
71
+ const data = await getValidationPlan(target, allocation, { signal: abort.signal, beforeSend: () => { if (!valid()) throw new SharedValidationError('access'); } });
72
+ if (!valid()) return null;
73
+ setState({ key, data, error: null, loading: false }); return data;
74
+ } catch (error) {
75
+ if (valid()) setState((s) => ({ key, data: s.key === key ? s.data : null, error: error instanceof SharedValidationError ? error : new SharedValidationError('unavailable'), loading: false }));
76
+ return null;
77
+ }
78
+ // key owns target/allocation/permission/auth identity and is deliberately the dependency.
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [key, enabled, auth.authenticated]);
81
+ useEffect(() => { void refresh(); return () => { generation.current++; controller.current?.abort(); }; }, [refresh]);
82
+ const ticks = useRef(0);
83
+ useEffect(() => { ticks.current = 0; }, [key]);
84
+ useEffect(() => {
85
+ if (!enabled || !auth.authenticated || state.key !== key || state.loading || state.error || !state.data || !pendingReadiness(state.data) || ticks.current >= maxTicks) return;
86
+ const timer = setTimeout(() => { ticks.current++; void refresh(); }, intervalMs);
87
+ return () => clearTimeout(timer);
88
+ }, [enabled, auth.authenticated, key, state, intervalMs, maxTicks, refresh]);
89
+ return { data: state.key === key && auth.authenticated ? state.data : null, error: state.key === key ? state.error : null, loading: state.key !== key || state.loading, refresh, timedOut: ticks.current >= maxTicks };
90
+ }