portal-mojo 0.2.19 → 0.2.20

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 (35) hide show
  1. package/docs/admin-credentials.md +27 -0
  2. package/docs/admin-dns-certificates.md +160 -0
  3. package/docs/admin-dns.md +16 -0
  4. package/docs/admin-fleet-configuration.md +98 -2
  5. package/docs/admin-identity-directories.md +12 -0
  6. package/docs/admin-modals.md +28 -0
  7. package/docs/auth-pages.md +18 -6
  8. package/docs/auth.md +16 -2
  9. package/docs/collection-select.md +14 -0
  10. package/package.json +1 -1
  11. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  12. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  13. package/src/admin/credentials/group-api-keys.tsx +5 -3
  14. package/src/admin/dns/CertificateDetail.tsx +8 -3
  15. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  16. package/src/admin/dns/DomainDetail.tsx +7 -1
  17. package/src/admin/dns/DomainsPage.tsx +6 -2
  18. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  19. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  20. package/src/admin/dns/shared-validation-api.ts +90 -0
  21. package/src/admin/dns/shared-validation-data.ts +106 -0
  22. package/src/admin/domains/operations.ts +6 -4
  23. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  24. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  25. package/src/admin/fleet-configuration/data.ts +4 -0
  26. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  27. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  28. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  29. package/src/admin/identity/groups/models.ts +2 -0
  30. package/src/client/auth.ts +77 -25
  31. package/src/client/fleet-mock.ts +73 -1
  32. package/src/client/index.ts +2 -2
  33. package/src/client/mock.ts +233 -6
  34. package/src/styles/components/admin-dns-certificates.css +33 -0
  35. package/src/ui/CollectionSelect.tsx +30 -8
@@ -1,5 +1,5 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
- import { useContext, useSyncExternalStore } from 'react';
2
+ import { lazy, Suspense, useContext, useSyncExternalStore } from 'react';
3
3
  import {
4
4
  GroupContext,
5
5
  hasPermission,
@@ -42,6 +42,7 @@ import {
42
42
  type GroupApiKeyRow,
43
43
  } from './models';
44
44
  import { showSecretDialog } from './secret-dialog';
45
+ const ProtectedKeyButton = lazy(() => import('./AcmeConsumerKeyDialog').then((m) => ({ default: m.AcmeConsumerKeyButton })));
45
46
 
46
47
  function groupLabel(group: GroupApiKeyRow['group']): string {
47
48
  if (group && typeof group === 'object') return group.name;
@@ -362,6 +363,7 @@ function GroupApiKeysContent({ group, permission }: { group: CredentialGroup; pe
362
363
  <button className="btn btn-primary btn-compact" onClick={() => void actions.createKey(group)}>
363
364
  <i className="bi bi-key" /> Create Key
364
365
  </button>
366
+ <Suspense fallback={null}><ProtectedKeyButton group={group} /></Suspense>
365
367
  </div>
366
368
  )}
367
369
  {!isPending && rows.length === 0 && <p className="dim-italic">No API keys yet.</p>}
@@ -489,7 +491,7 @@ export function GroupApiKeysPage() {
489
491
  void modal.detail((close) => <GroupApiKeyDetail id={row.id} onClose={() => close(null)} />);
490
492
  };
491
493
  return (
492
- <ModelTable<GroupApiKeyRow>
494
+ <><Suspense fallback={null}><ProtectedKeyButton /></Suspense><ModelTable<GroupApiKeyRow>
493
495
  model={GroupApiKeyModel}
494
496
  eyebrow="Account"
495
497
  title="Group API Keys"
@@ -507,6 +509,6 @@ export function GroupApiKeysPage() {
507
509
  onRowClick={openDetail}
508
510
  addLabel="New API Key"
509
511
  onAdd={actions.canManage ? () => void actions.createKey() : undefined}
510
- />
512
+ /></>
511
513
  );
512
514
  }
@@ -6,6 +6,9 @@ import { revokeCertificate, useAcmeDelegations, useDnsCapabilities } from './api
6
6
  import {
7
7
  canInspectHouseCertificate, certificateStatusTone, deriveCertificateRenewalHealth,
8
8
  } from './certificate-data';
9
+ import { SharedValidationPanel } from './SharedValidationPanel';
10
+ import { openSharedValidation } from './SharedValidationDialog';
11
+ import { useValidationPlan } from './shared-validation-api';
9
12
  import { CertificateModel, DNS_MANAGE_PERMISSIONS, type CertificateRow, type DomainRow } from './models';
10
13
 
11
14
  function healthLabel(row: CertificateRow): string {
@@ -23,6 +26,7 @@ function AllowedCertificateDetail({ id, domain, onClose }: { id: number; domain:
23
26
  const groupId = typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
24
27
  const capabilities = useDnsCapabilities(groupId);
25
28
  const delegations = useAcmeDelegations(domain.id);
29
+ const shared = useValidationPlan({ domain: domain.id });
26
30
  const [revoking, setRevoking] = useState(false);
27
31
  const certificate = query.data;
28
32
  if (query.isPending) return <div className="modal-pad dim">Loading certificate…</div>;
@@ -89,17 +93,18 @@ function AllowedCertificateDetail({ id, domain, onClose }: { id: number; domain:
89
93
  </div> },
90
94
  { key: 'delegation', label: 'Delegation', icon: 'bi-diagram-2', render: () => <div className="dns-cert-detail-section">
91
95
  <Eyebrow>DNS-01 routing</Eyebrow>
92
- {delegations.isPending ? <p className="dim">Loading delegation status…</p> : delegation ? <>
96
+ {delegations.isPending ? <p className="dim">Loading delegation status…</p> : delegations.error ? <p className="dns-errors">Delegation status could not be loaded. DNS routing is unknown.</p> : delegation ? <>
93
97
  <FlatRow label="State"><Badge tone={delegation.state === 'verified' ? 'success' : delegation.state === 'broken' ? 'danger' : 'warning'}>{delegation.state}</Badge></FlatRow>
94
98
  <FlatRow label="Source"><code>{delegation.source ?? '—'}</code></FlatRow>
95
99
  <FlatRow label="Target"><code>{delegation.target ?? '—'}</code></FlatRow>
96
100
  {delegation.last_error_code && <FlatRow label="Error code"><code>{delegation.last_error_code}</code></FlatRow>}
97
101
  {delegation.verified_at && delegation.state === 'broken' && <div className="dns-errors">This delegation is sticky after verification. Repair it; issuance will not fall back to direct provider DNS.</div>}
98
- </> : <p className="dim">No delegated ACME route is attached; provider DNS is used directly.</p>}
102
+ </> : <p className="dim">{shared.data?.connection?.mode === 'shared' ? 'An explicit shared connection controls validation; direct DNS fallback is disabled.' : shared.error || shared.loading ? 'Shared routing status is unavailable; no direct-routing claim can be made.' : 'No legacy delegated ACME route is attached.'}</p>}
103
+ <SharedValidationPanel domain={domain} />
99
104
  </div> },
100
105
  ]}
101
106
  initialSection="overview"
102
- contextMenu={canManage && certificate.status === 'active' ? [{ label: revoking ? 'Revoking…' : 'Revoke certificate', icon: 'bi-x-octagon', danger: true, onSelect: () => void revoke() }] : []}
107
+ contextMenu={[...(canManage && shared.data?.capabilities.group_choices ? [{ label: 'Set up shared validation', icon: 'bi-diagram-2', onSelect: () => void openSharedValidation(domain) }] : []), ...(canManage && certificate.status === 'active' ? [{ label: revoking ? 'Revoking…' : 'Revoke certificate', icon: 'bi-x-octagon', danger: true, onSelect: () => void revoke() }] : [])]}
103
108
  menuContext={certificate}
104
109
  onClose={onClose}
105
110
  />;
@@ -1,9 +1,13 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
3
  import { TagInput, toast } from '../../ui';
4
4
  import { requestCertificate, useAcmeDelegations, useDnsCapabilities } from './api';
5
5
  import { deriveCertificateReadiness, validateCertificateNames } from './certificate-data';
6
- import { DnsCredentialModel, DomainModel, type CertificateRow, type DomainRow } from './models';
6
+ import { getAuthSessionRevision, getAuthSnapshot, hasPermission, requestFreshAuth, useMe } from '../../client/runtime';
7
+ import { confirmShared } from './SharedValidationDialog';
8
+ import { invalidateValidation, requestWildcard, useValidationPlan } from './shared-validation-api';
9
+ import { SharedValidationError } from './shared-validation-data';
10
+ import { CertificateModel, DNS_MANAGE_PERMISSIONS, DnsCredentialModel, DomainModel, type CertificateRow, type DomainRow } from './models';
7
11
 
8
12
  function relationId(value: DomainRow['credential']): number | null {
9
13
  if (typeof value === 'number') return value;
@@ -15,6 +19,7 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
15
19
  domain?: DomainRow;
16
20
  }) {
17
21
  const queryClient = useQueryClient();
22
+ const { data: me } = useMe();
18
23
  const domains = DomainModel.useList({ status: 'active', size: 200, sort: 'name' }, { enabled: fixedDomain == null });
19
24
  const [domainId, setDomainId] = useState<number | null>(fixedDomain?.id ?? null);
20
25
  const domainQuery = DomainModel.useOne(domainId);
@@ -24,6 +29,15 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
24
29
  const delegations = useAcmeDelegations(domain?.id ?? null, domain != null);
25
30
  const credentialId = domain ? relationId(domain.credential) : null;
26
31
  const credential = DnsCredentialModel.useOne(credentialId);
32
+ const sharedPlan = useValidationPlan(domain ? { domain: domain.id } : null);
33
+ const explicitShared = sharedPlan.data?.connection?.mode === 'shared';
34
+ const sharedFlight = useRef(false);
35
+ const sharedOwner = useRef<AbortController | null>(null);
36
+ const [sharedRefreshRequired, setSharedRefreshRequired] = useState(false);
37
+ const sharedRevision = getAuthSessionRevision();
38
+ const sharedIdentity = JSON.stringify([domainId, sharedRevision, me?.id, hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)]);
39
+ const currentIdentity = useRef(sharedIdentity); currentIdentity.current = sharedIdentity;
40
+ useEffect(() => { const abort = new AbortController(); sharedOwner.current = abort; return () => abort.abort(); }, [sharedIdentity]);
27
41
  const [namesCsv, setNamesCsv] = useState('');
28
42
  const [names, setNames] = useState<string[]>([]);
29
43
  const [submitting, setSubmitting] = useState(false);
@@ -45,7 +59,30 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
45
59
 
46
60
  const submit = async (event: React.FormEvent) => {
47
61
  event.preventDefault();
48
- if (!domain || !readiness?.ready || validation.errors.length || submitting) return;
62
+ if (explicitShared) {
63
+ const r = sharedPlan.data;
64
+ if (!domain || !r?.capabilities.can_request || !r.connection || !r.alias.target || sharedPlan.error || sharedPlan.loading || sharedFlight.current || sharedRefreshRequired || !hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)) return;
65
+ sharedFlight.current = true; setSubmitting(true); setError(null);
66
+ const identity = sharedIdentity, uid = getAuthSnapshot().uid, revision = getAuthSessionRevision();
67
+ const signal = sharedOwner.current?.signal;
68
+ const beforeSend = () => { if (signal?.aborted || currentIdentity.current !== identity || getAuthSnapshot().uid !== uid || getAuthSessionRevision() !== revision) throw new SharedValidationError('access'); };
69
+ let submitted = false;
70
+ try {
71
+ const confirmed = await confirmShared({ title: 'Request wildcard certificate', message: <>Request a local certificate for <code>{r.profile.names.join(', ')}</code> through the verified shared validation target <code>{r.alias.target}</code>? This deployment keeps its own private key.</>, confirmText: 'Request wildcard', signal });
72
+ if (!confirmed) return;
73
+ beforeSend(); submitted = true;
74
+ const result = await requestWildcard(r.connection.id, { domain: domain.id }, r.alias.target, { signal, beforeSend });
75
+ beforeSend();
76
+ if (result.certificate) { const row = await CertificateModel.fetchOne(queryClient, result.certificate.id); beforeSend(); close(row); }
77
+ } catch (reason) {
78
+ if (!signal?.aborted) { setError(reason instanceof SharedValidationError ? reason.message : 'Shared wildcard request could not be completed. Refresh before retrying.'); if (reason instanceof SharedValidationError && reason.kind === 'reauth') { setSharedRefreshRequired(true); await requestFreshAuth(); } }
79
+ } finally {
80
+ if (submitted) { await invalidateValidation(queryClient); if (!signal?.aborted) setSharedRefreshRequired(!(await sharedPlan.refresh())); }
81
+ sharedFlight.current = false; setSubmitting(false);
82
+ }
83
+ return;
84
+ }
85
+ if (!domain || !readiness?.ready || validation.errors.length || submitting || (sharedPlan.error && sharedPlan.error.kind !== 'unsupported')) return;
49
86
  setSubmitting(true);
50
87
  setError(null);
51
88
  try {
@@ -69,7 +106,7 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
69
106
  {(domains.data?.rows ?? []).map((row) => <option key={row.id} value={row.id}>{row.name}</option>)}
70
107
  </select>
71
108
  </label>}
72
- <label className="field">
109
+ {explicitShared ? <div className="dns-cert-readiness"><strong>Fixed shared wildcard profile</strong><code>{sharedPlan.data!.profile.names.join(', ')}</code><span>Only the backend-authorized apex and wildcard can be requested through this connection.</span><span>{sharedPlan.data!.next_action.code.replaceAll('_', ' ')}</span><button className="btn" type="button" disabled={submitting || sharedPlan.loading} onClick={() => void sharedPlan.refresh().then((r) => { setSharedRefreshRequired(!r); if (r) setError(null); })}>Refresh shared readiness</button></div> : <label className="field">
73
110
  <span className="field-label">Names</span>
74
111
  <TagInput
75
112
  name="certificate_names"
@@ -81,21 +118,22 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
81
118
  disabled={!domain || submitting}
82
119
  />
83
120
  <span className="field-help">Leave empty for the domain apex plus its wildcard. Every SAN must stay inside the selected zone.</span>
84
- </label>
121
+ </label>}
85
122
  {!!validation.errors.length && <div className="dns-errors" role="alert">{validation.errors.map((message) => <div key={message}>{message}</div>)}</div>}
86
123
  {loadingReadiness && <div className="dns-cert-readiness dim"><i className="bi bi-arrow-repeat spin" /> Checking issuance readiness…</div>}
87
- {readiness && <div className={`dns-cert-readiness${readiness.ready ? ' is-ready' : ' is-blocked'}`}>
124
+ {!explicitShared && readiness && <div className={`dns-cert-readiness${readiness.ready ? ' is-ready' : ' is-blocked'}`}>
88
125
  <strong>{readiness.label}</strong>
89
126
  {readiness.reason && <span>{readiness.reason}</span>}
90
127
  </div>}
91
128
  {capabilities.data?.acme.staging && <div className="dns-provider-note">
92
129
  <strong>Current ACME configuration: staging.</strong> New certificates are not publicly trusted. This flag is current configuration, not historical provenance for existing certificates.
93
130
  </div>}
131
+ {sharedPlan.error && sharedPlan.error.kind !== 'unsupported' && <div className="dns-errors" role="alert">{sharedPlan.error.message}</div>}
94
132
  {capabilities.error && <div className="dns-errors">{capabilities.error.message}</div>}
95
133
  {error && <div className="dns-errors" role="alert">{error}</div>}
96
134
  <div className="modal-actions">
97
135
  <button className="btn" type="button" disabled={submitting} onClick={() => close(null)}>Cancel</button>
98
- <button className="btn btn-primary" type="submit" disabled={!domain || !readiness?.ready || validation.errors.length > 0 || submitting}>
136
+ <button className="btn btn-primary" type="submit" disabled={!domain || submitting || sharedPlan.loading || (explicitShared ? !sharedPlan.data?.capabilities.can_request || !!sharedPlan.error || sharedRefreshRequired : !readiness?.ready || validation.errors.length > 0 || (!!sharedPlan.error && sharedPlan.error.kind !== 'unsupported'))}>
99
137
  {submitting ? 'Requesting…' : 'Request certificate'}
100
138
  </button>
101
139
  </div>
@@ -6,7 +6,10 @@ import { providerLabel } from './data';
6
6
  import { useDnsDomainLinks } from './domain-links';
7
7
  import { DnsRecordsPanel } from './DnsRecordsPanel';
8
8
  import { DomainCertificatesSection } from './DomainCertificatesSection';
9
- import { DomainModel } from './models';
9
+ import { SharedValidationPanel } from './SharedValidationPanel';
10
+ import { openSharedValidation } from './SharedValidationDialog';
11
+ import { useValidationPlan } from './shared-validation-api';
12
+ import { DNS_MANAGE_PERMISSIONS, DomainModel } from './models';
10
13
 
11
14
  export function DomainDetail({ id, onClose }: { id: number; onClose: () => void }) {
12
15
  const query = DomainModel.useOne(id);
@@ -15,6 +18,7 @@ export function DomainDetail({ id, onClose }: { id: number; onClose: () => void
15
18
  const navigate = useNavigate();
16
19
  const location = useLocation();
17
20
  const domain = query.data;
21
+ const shared = useValidationPlan(domain ? { domain: domain.id } : null);
18
22
  const group = typeof domain?.group === 'number' ? domain.group : domain?.group?.id ?? null;
19
23
  const capabilities = useDnsCapabilities(group, { enabled: !!domain });
20
24
  if (query.isPending) return <div className="modal-pad dim">Loading domain…</div>;
@@ -33,9 +37,11 @@ export function DomainDetail({ id, onClose }: { id: number; onClose: () => void
33
37
  chips={[{ text: domain.status, tone: fmt.inferTone(domain.status) }, ...(domain.verified ? [{ text: 'Verified', tone: 'success' as const }] : [])]}
34
38
  sections={[
35
39
  { key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-domain-overview"><Eyebrow>Domain</Eyebrow><FlatRow label="Name"><code>{domain.name}</code></FlatRow><FlatRow label="Provider">{providerLabel(domain.provider)}</FlatRow><FlatRow label="Status">{domain.status}</FlatRow><FlatRow label="Group">{groupLabel}</FlatRow>{domain.hosted_zone_id && <FlatRow label="Hosted zone"><code>{domain.hosted_zone_id}</code></FlatRow>}<FlatRow label="Registered">{fmt.date(domain.registered_on)}</FlatRow><FlatRow label="Expires">{fmt.date(domain.expires)}</FlatRow><FlatRow label="Verified">{domain.verified ? 'Yes' : 'No'}</FlatRow><FlatRow label="Auto-renew">{domain.auto_renew ? 'Enabled' : 'Disabled'}</FlatRow><FlatRow label="WHOIS privacy">{domain.privacy ? 'Enabled' : 'Disabled'}</FlatRow><FlatRow label="Credential">{credential ? `${credential.name} · ${credential.is_active && credential.verified ? 'usable' : 'unavailable'}` : '—'}</FlatRow>{domain.last_error && <div className="dns-error">{domain.last_error}</div>}{domain.provider === 'godaddy' && <div className="dns-provider-note">DNS is managed here; purchase, renewal, privacy, and WHOIS remain in the GoDaddy account.</div>}{domain.provider === 'mojo' && <div className="dns-blocked">Mojo is certificate-only and cannot use generic DNS CRUD.</div>}{availableLinks.length > 0 && <><Eyebrow>Related</Eyebrow><div className="dns-domain-links">{availableLinks.map((link) => <button className="btn" type="button" key={link.key} onClick={() => navigateLink(typeof link.route === 'function' ? link.route(domain) : link.route)}><i className={`bi ${link.icon}`} /> {link.label}</button>)}</div></>}</div> },
40
+ { key: 'validation', label: 'Validation', icon: 'bi-diagram-2', render: () => <SharedValidationPanel domain={domain} /> },
36
41
  { key: 'records', label: 'DNS Records', icon: 'bi-list-columns', render: () => <DnsRecordsPanel domain={domain} /> },
37
42
  { key: 'certificates', label: 'Certificates', icon: 'bi-patch-check', render: () => <DomainCertificatesSection domain={domain} /> },
38
43
  ]}
39
44
  initialSection="overview" onClose={onClose}
45
+ contextMenu={hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null) && shared.data?.capabilities.group_choices ? [{ label: 'Set up shared validation', icon: 'bi-diagram-2', onSelect: () => void openSharedValidation(domain) }] : []}
40
46
  />;
41
47
  }
@@ -1,7 +1,9 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
2
  import { Badge, ModelTable, fmt, modal, type Column, type FilterDef } from '../../ui';
3
3
  import { DomainDetail } from './DomainDetail';
4
- import { DomainModel, type DomainRow } from './models';
4
+ import { hasPermission, useMe } from '../../client/runtime';
5
+ import { openSharedValidation } from './SharedValidationDialog';
6
+ import { DomainModel, DNS_MANAGE_PERMISSIONS, type DomainRow } from './models';
5
7
  import { providerLabel } from './data';
6
8
 
7
9
  const columns: Column<DomainRow>[] = [
@@ -19,9 +21,11 @@ const filters: FilterDef[] = [
19
21
 
20
22
  export function DomainsPage() {
21
23
  const queryClient = useQueryClient();
24
+ const { data: me } = useMe();
25
+ const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
22
26
  const open = async (row: DomainRow) => {
23
27
  await DomainModel.fetchOne(queryClient, row.id);
24
28
  await modal.detail((close) => <DomainDetail id={row.id} onClose={() => close(null)} />);
25
29
  };
26
- return <ModelTable<DomainRow> model={DomainModel} eyebrow="Infrastructure · DNS" title="Domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={(row) => void open(row)} />;
30
+ return <ModelTable<DomainRow> model={DomainModel} eyebrow="Infrastructure · DNS" title="Domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={(row) => void open(row)} {...(canManage ? { onAdd: () => void openSharedValidation(), addLabel: 'Shared validation setup' } : {})} />;
27
31
  }
@@ -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 → 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
+ }