portal-mojo 0.2.21 → 0.2.22

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 (44) hide show
  1. package/docs/admin-assistant.md +75 -23
  2. package/docs/admin-dns-domains.md +96 -90
  3. package/docs/admin-dns-records.md +1 -1
  4. package/docs/admin-dns-registrar.md +29 -4
  5. package/docs/admin-dns.md +2 -3
  6. package/docs/admin-modals.md +21 -0
  7. package/docs/menus-and-access.md +24 -0
  8. package/package.json +1 -1
  9. package/src/admin/assistant/AssistantFeed.tsx +7 -4
  10. package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
  11. package/src/admin/assistant/api.ts +9 -7
  12. package/src/admin/assistant/pages.tsx +125 -109
  13. package/src/admin/assistant/setup.ts +1 -1
  14. package/src/admin/assistant/skill-lifecycle.ts +5 -4
  15. package/src/admin/core/index.ts +10 -2
  16. package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
  17. package/src/admin/dns/DomainAssignment.tsx +55 -0
  18. package/src/admin/dns/DomainDetail.tsx +55 -38
  19. package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
  20. package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
  21. package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
  22. package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
  23. package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
  24. package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
  25. package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
  26. package/src/admin/dns/DomainsPage.tsx +25 -24
  27. package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
  28. package/src/admin/dns/RegistrantContactPage.tsx +73 -27
  29. package/src/admin/dns/api.ts +26 -25
  30. package/src/admin/dns/domain-authority.ts +11 -0
  31. package/src/admin/dns/index.ts +2 -2
  32. package/src/admin/dns/purchase-data.ts +5 -3
  33. package/src/admin/dns/registrar-api.ts +57 -11
  34. package/src/admin/domains/infrastructure.ts +8 -8
  35. package/src/admin/domains/operations.ts +6 -6
  36. package/src/admin/edge/VhostsPage.tsx +6 -1
  37. package/src/admin/jobs/JobDashboardPage.tsx +14 -4
  38. package/src/client/mock.ts +14 -8
  39. package/src/styles/components/admin-assistant.css +83 -20
  40. package/src/styles/components/admin-domains.css +67 -11
  41. package/src/styles/components/admin-edge.css +1 -0
  42. package/src/styles/components/admin-jobs.css +1 -0
  43. package/src/ui/SidebarNav.tsx +10 -1
  44. package/src/ui/menu-registry.ts +2 -0
@@ -0,0 +1,55 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { hasPermission, useMe } from '../../client/runtime';
4
+ import { CollectionSelect, FlatRow, modal, toast } from '../../ui';
5
+ import { assignHouseDomain, DNS_GROUP_CHOICE_ENDPOINT, fetchDnsGroupChoice } from './api';
6
+ import { domainSession, requireDomainManager } from './domain-authority';
7
+ import { fetchDomainOverview } from './registrar-api';
8
+ import { DNS_MANAGE_PERMISSIONS, DomainModel, type DnsGroupChoice, type DomainRow } from './models';
9
+ import { redactRegistrarError } from './purchase-data';
10
+
11
+ export function DomainAssignment({ domain, close, onBusyChange }: { domain: DomainRow; close: () => void; onBusyChange: (busy: boolean) => void }) {
12
+ const { data: me } = useMe(); const qc = useQueryClient();
13
+ const [group, setGroup] = useState<DnsGroupChoice | null>(null);
14
+ const [review, setReview] = useState(false); const [confirm, setConfirm] = useState('');
15
+ const [busy, setBusy] = useState(false); const [attempted, setAttempted] = useState(false); const [error, setError] = useState('');
16
+ const [observed, setObserved] = useState<DomainRow | null>(null);
17
+ const flight = useRef(false); const mounted = useRef(true); const session = useRef(domainSession());
18
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
19
+ const allowed = me?.is_superuser === true && hasPermission(me, DNS_MANAGE_PERMISSIONS, null) && domain.group == null;
20
+ const refresh = async () => {
21
+ try { const row = await fetchDomainOverview(domain.id); if (!mounted.current || domainSession() !== session.current) return; setObserved(row); qc.setQueryData(DomainModel.keys.one(domain.id), row); }
22
+ catch { if (mounted.current) setError('Could not refresh the domain. Check its inventory before taking another action.'); }
23
+ finally { await DomainModel.invalidate(qc); }
24
+ };
25
+ const submit = async () => {
26
+ if (flight.current || attempted || !allowed || !group || !review || confirm !== group.name) return;
27
+ flight.current = true; setBusy(true); onBusyChange(true); setError('');
28
+ try {
29
+ await requireDomainManager(session.current, true);
30
+ const [current, target] = await Promise.all([fetchDomainOverview(domain.id), fetchDnsGroupChoice(group.id)]);
31
+ if (domainSession() !== session.current || !mounted.current) return;
32
+ if (current.group != null) { setObserved(current); setAttempted(true); throw new Error('This domain has already been assigned. Reassignment is unavailable.'); }
33
+ if (!target || target.name !== group.name) throw new Error('The destination changed. Close and choose it again.');
34
+ setAttempted(true);
35
+ const row = await assignHouseDomain({ domain: domain.id, group: group.id }, () => { if (!mounted.current || domainSession() !== session.current) throw new Error('Session changed. Reopen this task.'); });
36
+ if (domainSession() !== session.current || !mounted.current) return;
37
+ const owner = typeof row.group === 'number' ? row.group : row.group?.id;
38
+ if (row.id !== domain.id || owner !== group.id) throw new Error('The response did not confirm the selected destination.');
39
+ qc.setQueryData(DomainModel.keys.one(domain.id), row); toast.success(`${domain.name} assigned to ${group.name}`); close();
40
+ } catch (reason) {
41
+ if (mounted.current && domainSession() === session.current) setError(`${redactRegistrarError(reason)}${flight.current ? ' Refresh to inspect the current owner. This form will not repeat a submitted assignment.' : ''}`);
42
+ } finally { await refresh(); flight.current = false; if (mounted.current) setBusy(false); onBusyChange(false); }
43
+ };
44
+ if (!allowed) return <div className="modal-pad"><h2 className="modal-title">Assignment unavailable</h2><p>Only a platform administrator can assign an unassigned domain.</p><button className="btn" onClick={close}>Close</button></div>;
45
+ return <div className="modal-pad domains-assignment"><h2 className="modal-title">Assign {domain.name}</h2><p className="dim">Give this Platform domain to one group. This assignment cannot be changed here.</p>{error && <div className="form-alert" role="alert">{error}</div>}{observed?.group != null && <FlatRow label="Current owner">{typeof observed.group === 'number' ? `Group #${observed.group}` : observed.group.name}</FlatRow>}
46
+ {!attempted && !review && <CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={group} onChange={(_id, row) => setGroup(row ?? null)} label="Destination group" required maxItems={25} defaultParams={{ start: 0 }} disabled={busy} />}
47
+ {review && !attempted && <><FlatRow label="Domain">{domain.name}</FlatRow><FlatRow label="From">Platform</FlatRow><FlatRow label="To">{group?.name}</FlatRow><label className="field"><span className="field-label">Type {group?.name} to confirm</span><input className="input" value={confirm} disabled={busy} onChange={event => setConfirm(event.target.value)} /></label></>}
48
+ <div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{attempted ? 'Close' : 'Cancel'}</button>{attempted ? <button className="btn" disabled={busy} onClick={() => void refresh()}>Refresh owner</button> : review ? <><button className="btn" disabled={busy} onClick={() => { setReview(false); setConfirm(''); }}>Back</button><button className="btn btn-primary" disabled={busy || confirm !== group?.name} onClick={() => void submit()}>{busy ? 'Assigning…' : 'Assign to group'}</button></> : <button className="btn btn-primary" disabled={!group || busy} onClick={() => setReview(true)}>Review assignment</button>}</div>
49
+ </div>;
50
+ }
51
+
52
+ export function showDomainAssignment(domain: DomainRow) {
53
+ let busy = false;
54
+ return modal.open(close => <DomainAssignment domain={domain} close={() => close(null)} onBusyChange={value => { busy = value; }} />, { size: 'md', canDismiss: () => !busy });
55
+ }
@@ -1,51 +1,68 @@
1
+ import { useCallback, useState, useSyncExternalStore } from 'react';
1
2
  import { useLocation, useNavigate } from 'react-router-dom';
2
- import { hasPermission, useMe } from '../../client/runtime';
3
- import { DetailView, Eyebrow, FlatRow, fmt } from '../../ui';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+ import { getAuthSessionRevision, hasPermission, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
5
+ import { Badge, FlatRow, Tabs, fmt, modal } from '../../ui';
6
+ import { OperationsHelp } from '../core/OperationsHelp';
4
7
  import { useDnsCapabilities } from './api';
5
8
  import { providerLabel } from './data';
6
9
  import { useDnsDomainLinks } from './domain-links';
7
10
  import { DnsRecordsPanel } from './DnsRecordsPanel';
8
11
  import { DomainCertificatesSection } from './DomainCertificatesSection';
9
- import { SharedValidationPanel } from './SharedValidationPanel';
10
12
  import { openSharedValidation } from './SharedValidationDialog';
11
- import { useValidationPlan } from './shared-validation-api';
12
- import { domainRecordsHref, domainScopeLabel, dnsAdminMount, supportsDomainRecords } from './domain-navigation';
13
+ import { domainScopeLabel, dnsAdminMount, supportsDomainRecords } from './domain-navigation';
13
14
  import { redactRegistrarError } from './purchase-data';
14
- import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS, DomainModel } from './models';
15
+ import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS, DomainModel, type DomainRow } from './models';
16
+ import { DomainRegistrationPanel } from './DomainRegistrationPanel';
17
+ import { showDomainAssignment } from './DomainAssignment';
15
18
 
16
- export function DomainDetail({ id, onClose }: { id: number; onClose: () => void }) {
17
- const query = DomainModel.useOne(id);
18
- const { data: me } = useMe();
19
- const links = useDnsDomainLinks();
20
- const navigate = useNavigate();
21
- const location = useLocation();
19
+ export function showDomainDetails(id: number, initialTab = 'overview') {
20
+ let busy = false;
21
+ const onBusyChange = (value: boolean) => { busy = value; };
22
+ return modal.open(close => <DomainDetail id={id} initialTab={initialTab} onClose={() => close(null)} onBusyChange={onBusyChange} />, { size: 'lg', canDismiss: () => !busy });
23
+ }
24
+
25
+ export function DomainDetail({ id, initialTab = 'overview', onClose, onBusyChange }: { id: number; initialTab?: string; onClose: () => void; onBusyChange?: (busy: boolean) => void }) {
26
+ const { data: me } = useMe(); const auth = useAuthSnapshot();
27
+ const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
28
+ const canView = auth.authenticated && hasPermission(me, DNS_VIEW_PERMISSIONS, null);
29
+ const query = DomainModel.useOne(canView ? id : null);
22
30
  const domain = query.data;
23
- const shared = useValidationPlan(domain ? { domain: domain.id } : null);
24
- const group = typeof domain?.group === 'number' ? domain.group : domain?.group?.id ?? null;
25
- const capabilities = useDnsCapabilities(group, { enabled: !!domain });
31
+ if (!canView) return <div className="modal-pad"><h2 className="modal-title">Domain unavailable</h2><button className="btn" onClick={onClose}>Close</button></div>;
26
32
  if (query.isPending) return <div className="modal-pad dim">Loading domain…</div>;
27
- if (!domain || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Domain not found'}</div>;
28
- const availableLinks = capabilities.data ? links.filter((link) => (!link.permissions || hasPermission(me, link.permissions)) && (!link.when || link.when(domain, capabilities.data!))) : [];
29
- const navigateLink = (route: string) => {
30
- const marker = location.pathname.indexOf('/dns/');
31
- const mount = marker >= 0 ? location.pathname.slice(0, marker) : '';
32
- onClose();
33
- navigate(`${mount}/dns/${route.replace(/^\/?dns\//, '').replace(/^\//, '')}`);
34
- };
35
- const groupLabel = domainScopeLabel(domain.group);
36
- const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
37
- const openDestination = (href: string) => { onClose(); navigate(href); };
33
+ if (!domain || query.error) return <div className="modal-pad"><h2 className="modal-title">Could not load domain</h2><p className="text-bad">{query.error?.message ?? 'Domain not found'}</p><div className="modal-actions"><button className="btn" onClick={onClose}>Close</button><button className="btn" onClick={() => void query.refetch()}>Retry</button></div></div>;
34
+ return <DomainContent key={`${auth.uid}:${revision}:${domain.id}`} domain={domain} initialTab={initialTab} onClose={onClose} onBusyChange={onBusyChange} />;
35
+ }
36
+
37
+ function DomainContent({ domain, initialTab, onClose, onBusyChange }: { domain: DomainRow; initialTab: string; onClose: () => void; onBusyChange?: (busy: boolean) => void }) {
38
+ const { data: me } = useMe(); const qc = useQueryClient();
39
+ const location = useLocation(); const navigate = useNavigate(); const links = useDnsDomainLinks();
40
+ const [tab, setTab] = useState(initialTab); const [recordType, setRecordType] = useState(''); const [busy, setBusy] = useState(false);
41
+ const setPending = useCallback((value: boolean) => { setBusy(value); onBusyChange?.(value); }, [onBusyChange]);
42
+ const canManage = hasPermission(me, DNS_MANAGE_PERMISSIONS, null);
43
+ const group = typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
44
+ // Catalog is read-only; manager prerequisites and legal contacts load only on deliberate actions.
45
+ const caps = useDnsCapabilities(group, { enabled: links.some(link => !['domains', 'records', 'certificates', 'purchases', 'registrant', 'credentials'].includes(link.key)) });
46
+ const customLinks = links.filter(link => !['domains', 'records', 'certificates', 'purchases', 'registrant', 'credentials'].includes(link.key) && (!link.permissions || hasPermission(me, link.permissions, null)) && (!link.when || caps.data && link.when(domain, caps.data)));
47
+ const go = (href: string) => { onClose(); navigate(href); };
48
+ const refresh = async () => { await DomainModel.invalidate(qc); };
38
49
  const credential = typeof domain.credential === 'object' ? domain.credential : null;
39
- return <DetailView
40
- icon="bi-globe2" title={domain.name} subtitle={providerLabel(domain.provider)}
41
- chips={[{ text: domain.status, tone: fmt.inferTone(domain.status) }, ...(domain.verified ? [{ text: 'Verified', tone: 'success' as const }] : [])]}
42
- sections={[
43
- { key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-domain-overview"><div className="domains-overview-actions">{supportsDomainRecords(domain.provider) && <button className="btn btn-primary" onClick={() => openDestination(domainRecordsHref(location.pathname, domain.id))}>DNS Records <i className="bi bi-arrow-right" /></button>}{canManage && domain.provider === 'route53' && <button className="btn" onClick={() => void import('./DomainRegistrarSettings').then(({ showDomainRegistrarSettings }) => showDomainRegistrarSettings(domain))}>Registrar settings</button>}{hasPermission(me ?? null, DNS_VIEW_PERMISSIONS, null) && <button className="btn" onClick={() => openDestination(`${dnsAdminMount(location.pathname)}/edge/vhosts`)}>Serving <i className="bi bi-arrow-right" /></button>}</div><Eyebrow>Domain</Eyebrow><FlatRow label="Scope">{groupLabel}</FlatRow><FlatRow label="Expires">{fmt.date(domain.expires)}</FlatRow><FlatRow label="Auto-renew">{domain.auto_renew ? 'Enabled' : 'Disabled'}</FlatRow><FlatRow label="WHOIS privacy">{domain.privacy ? 'Enabled' : 'Disabled'}</FlatRow><details className="ops-details"><summary>Registration details</summary>{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="Credential">{credential ? `${credential.name} · ${credential.is_active && credential.verified ? 'usable' : 'unavailable'}` : '—'}</FlatRow></details>{domain.last_error && <div className="dns-error">{redactRegistrarError(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.filter((link) => link.key !== 'records' || supportsDomainRecords(domain.provider)).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> },
44
- { key: 'validation', label: 'Advanced validation', icon: 'bi-diagram-2', render: () => <SharedValidationPanel domain={domain} /> },
45
- { key: 'records', label: 'DNS Records', icon: 'bi-list-columns', render: () => <DnsRecordsPanel domain={domain} /> },
46
- { key: 'certificates', label: 'Certificates', icon: 'bi-patch-check', render: () => <DomainCertificatesSection domain={domain} /> },
47
- ]}
48
- initialSection="overview" onClose={onClose}
49
- contextMenu={canManage && shared.data?.capabilities.group_choices ? [{ label: 'Set up shared validation', icon: 'bi-diagram-2', onSelect: () => void openSharedValidation(domain) }] : []}
50
- />;
50
+ const items = [
51
+ { key: 'overview', label: 'Overview', panel: <div className="domains-detail-panel">
52
+ {domain.last_error && <div className="form-alert" role="alert">{redactRegistrarError(domain.last_error)}</div>}
53
+ {domain.provider === 'godaddy' && <p className="dns-provider-note">Manage DNS here. Registration and renewals stay in your GoDaddy account.</p>}
54
+ {domain.provider === 'mojo' && <p className="dns-provider-note">Certificate-only domain. General DNS records are managed by its DNS provider.</p>}
55
+ {!supportsDomainRecords(domain.provider) && domain.provider !== 'mojo' && <p className="form-alert">This DNS provider is unavailable in this portal.</p>}
56
+ <FlatRow label="Scope"><span>{domainScopeLabel(domain.group)}</span>{canManage && me?.is_superuser === true && domain.group == null && <button className="btn btn-sm" onClick={() => void showDomainAssignment(domain)}>Assign to group</button>}</FlatRow>
57
+ <FlatRow label="Expires">{domain.expires ? fmt.date(domain.expires) : <span>Unknown <OperationsHelp label="domain expiry">A hosted zone does not prove that its registration never expires. Read live registration details or check its registrar.</OperationsHelp></span>}</FlatRow>
58
+ <FlatRow label="Ownership">{domain.verified ? 'Verified' : 'Not verified'}</FlatRow>
59
+ {domain.hosted_zone_id && <FlatRow label="Hosted zone"><code>{domain.hosted_zone_id}</code></FlatRow>}
60
+ {credential && <FlatRow label="Credential"><span>{credential.name} · {credential.is_active && credential.verified ? 'Verified' : 'Needs attention'}</span></FlatRow>}
61
+ <div className="domains-overview-actions"><button className="btn" onClick={() => go(`${dnsAdminMount(location.pathname)}/edge/vhosts`)}>Serving <i className="bi bi-arrow-right" /></button>{canManage && <span className="domains-task-help"><button className="btn" onClick={() => void openSharedValidation(domain)}>Shared validation</button><OperationsHelp label="shared validation">Connect shared wildcard validation or review controller trust for this domain.</OperationsHelp></span>}{customLinks.map(link => <button className="btn" key={link.key} onClick={() => go(`${dnsAdminMount(location.pathname)}/dns/${(typeof link.route === 'function' ? link.route(domain) : link.route).replace(/^\/?dns\//, '').replace(/^\//, '')}`)}>{link.label}</button>)}</div>
62
+ </div> },
63
+ ...(supportsDomainRecords(domain.provider) ? [{ key: 'records', label: 'DNS records', panel: <div className="domains-detail-panel"><DnsRecordsPanel domain={domain} recordType={recordType} onRecordTypeChange={setRecordType} onBusyChange={setPending} /></div> }] : []),
64
+ { key: 'certificates', label: 'Certificates', panel: <div className="domains-detail-panel"><DomainCertificatesSection domain={domain} /></div> },
65
+ ...(canManage && domain.provider !== 'mojo' ? [{ key: 'registration', label: 'Registration', panel: <div className="domains-detail-panel"><DomainRegistrationPanel domain={domain} onBusyChange={setPending} onChanged={refresh} /></div> }] : []),
66
+ ];
67
+ return <div className="domains-detail"><header className="domains-detail-header"><div><h2 className="modal-title">{domain.name}</h2><div className="domains-detail-subtitle"><span>{providerLabel(domain.provider)}</span><Badge tone={fmt.inferTone(domain.status)}>{domain.status}</Badge></div></div><button className="btn-icon" aria-label="Close domain" disabled={busy} onClick={onClose}><i className="bi bi-x-lg" /></button></header><Tabs ariaLabel="Domain details" activeKey={tab} onActiveKeyChange={setTab} items={items.map(item => ({ ...item, disabled: busy && item.key !== tab }))} /></div>;
51
68
  }
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
3
  import { hasPermission, useMe } from '../../client/runtime';
4
4
  import { toast } from '../../ui';
@@ -7,6 +7,8 @@ import { DNS_MANAGE_PERMISSIONS, DomainModel } from './models';
7
7
  import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
8
8
  import { DomainPurchaseWizard } from './DomainPurchaseWizard';
9
9
  import { DomainRegisterExisting } from './DomainRegisterExisting';
10
+ import { domainSession, requireDomainManager } from './domain-authority';
11
+ import { OperationsHelp } from '../core/OperationsHelp';
10
12
 
11
13
  export type OnboardingMode = 'menu' | 'register' | 'purchase' | 'adopt';
12
14
 
@@ -16,6 +18,9 @@ export function DomainOnboardingDialog({ close, setBusyGate, initialMode = 'menu
16
18
  const [name, setName] = useState(''); const [review, setReview] = useState<string | null>(null);
17
19
  const [busy, setBusy] = useState(false); const [attempted, setAttempted] = useState(false); const [error, setError] = useState('');
18
20
  const inFlight = useRef(false);
21
+ const session = useRef(domainSession());
22
+ const mounted = useRef(true);
23
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
19
24
  const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
20
25
  const prepareAdoption = () => {
21
26
  if (inFlight.current || attempted || me?.is_superuser !== true) return;
@@ -26,7 +31,7 @@ export function DomainOnboardingDialog({ close, setBusyGate, initialMode = 'menu
26
31
  const adopt = async () => {
27
32
  if (inFlight.current || attempted || !review || me?.is_superuser !== true || !canManage) return;
28
33
  inFlight.current = true; setBusy(true); setBusyGate(true); setAttempted(true); setError('');
29
- try { const domain = await adoptHouseDomain({ domain: review }); await DomainModel.invalidate(queryClient); toast.success(`${domain.name} adopted at Platform scope`); close(); }
34
+ try { await requireDomainManager(session.current, true); const domain = await adoptHouseDomain({ domain: review }, () => { if (!mounted.current || domainSession() !== session.current) throw new Error('Session changed. Reopen this task.'); }); await DomainModel.invalidate(queryClient); if (!mounted.current || domainSession() !== session.current) return; toast.success(`${domain.name} adopted at Platform scope`); close(); }
30
35
  catch (reason) { await DomainModel.invalidate(queryClient); setError(`${redactRegistrarError(reason)} Review the domain inventory before another adoption.`); }
31
36
  finally { inFlight.current = false; setBusy(false); setReview(null); setBusyGate(false); }
32
37
  };
@@ -36,9 +41,9 @@ export function DomainOnboardingDialog({ close, setBusyGate, initialMode = 'menu
36
41
  if (mode === 'register') return <DomainRegisterExisting close={close} back={() => setMode('menu')} setBusyGate={setBusyGate} />;
37
42
  if (mode === 'adopt') {
38
43
  if (me.is_superuser !== true) return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Platform superuser required</h2><button className="btn" onClick={close}>Close</button></div>;
39
- return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Adopt a House domain</h2><p className="dim">Adopt one existing public hosted zone at Platform scope. No account discovery, new zone, or tenant assignment.</p>{error && <div className="form-alert" role="alert">{error}</div>}
44
+ return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Adopt Route 53 zone</h2><p className="dim">Connect an existing hosted zone to Platform. <OperationsHelp label="zone adoption">Enter one exact domain from this installation’s AWS account. This does not create a zone, buy a registration, or assign it to a group.</OperationsHelp></p>{error && <div className="form-alert" role="alert">{error}</div>}
40
45
  {review ? <div className="dns-quote-card"><h3>Review exact adoption</h3><div><span>Domain</span><code>{review}</code></div><div><span>Scope</span><b>Platform</b></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={cancelReview}>Cancel review</button><button className="btn btn-primary" disabled={busy} onClick={() => void adopt()}>{busy ? 'Adopting…' : 'Confirm adoption'}</button></div></div> : <><label className="field"><span className="field-label">Existing House domain</span><input className="input" value={name} disabled={busy || attempted} onChange={(event) => setName(event.target.value)} /></label><div className="modal-actions"><button className="btn" disabled={busy} onClick={attempted ? close : () => setMode('menu')}>{attempted ? 'Close and review inventory' : 'Back'}</button><button className="btn btn-primary" disabled={busy || attempted || !name.trim()} onClick={prepareAdoption}>Review adoption</button></div></>}
41
46
  </div>;
42
47
  }
43
- return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Add a domain</h2><p className="dim">Choose one explicit workflow.</p><div className="dns-onboarding-options"><button className="panel panel-pad" onClick={() => setMode('register')}><i className="bi bi-globe2" /><b>Register existing</b><span>Prove ownership of one GoDaddy domain using a verified group credential.</span></button>{me.is_superuser === true && <button className="panel panel-pad" onClick={() => setMode('adopt')}><i className="bi bi-house-add" /><b>Adopt a House domain</b><span>Type an existing public zone name; keep it at Platform scope.</span></button>}<button className="panel panel-pad" onClick={() => setMode('purchase')}><i className="bi bi-cart-check" /><b>Buy domain</b><span>Search, quote, confirm once, and reconcile against the durable ledger.</span></button></div><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button></div></div>;
48
+ return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Add a domain</h2><div className="dns-onboarding-options"><button className="panel panel-pad" onClick={() => setMode('register')}><i className="bi bi-globe2" /><b>Connect GoDaddy domain</b><span>Manage DNS for a domain you already own.</span></button><button className="panel panel-pad" onClick={() => setMode('purchase')}><i className="bi bi-cart-check" /><b>Buy domain</b><span>Search available names and review the price.</span></button>{me.is_superuser === true && <button className="panel panel-pad" onClick={() => setMode('adopt')}><i className="bi bi-house-add" /><b>Adopt Route 53 zone</b><span>Connect an existing zone in this AWS account.</span></button>}</div><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button></div></div>;
44
49
  }
@@ -1,11 +1,46 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { useLocation, useNavigate } from 'react-router-dom';
3
+ import { getAuthSessionRevision, getAuthSnapshot, hasPermission, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
2
4
  import { Badge, FlatRow, fmt } from '../../ui';
5
+ import { OperationsHelp } from '../core/OperationsHelp';
3
6
  import { fetchPurchaseDetail } from './api';
4
- import { redactRegistrarError } from './purchase-data';
5
- import type { DomainPurchaseRow } from './models';
7
+ import { dnsAdminMount } from './domain-navigation';
8
+ import { PURCHASE_MAY_HAVE_MOVED, redactRegistrarError } from './purchase-data';
9
+ import { DNS_VIEW_PERMISSIONS, type DomainPurchaseRow } from './models';
6
10
 
7
- export function DomainPurchaseDetail({ id, close }: { id: number; close: () => void }) {
8
- const [row, setRow] = useState<DomainPurchaseRow | null>(null); const [error, setError] = useState('');
9
- useEffect(() => { let current = true; void fetchPurchaseDetail(id).then((value) => { if (current) setRow(value); }, (reason) => { if (current) setError(redactRegistrarError(reason)); }); return () => { current = false; setRow(null); setError(''); }; }, [id]);
10
- return <div className="modal-pad"><div className="eyebrow">Domain purchase ledger</div><h2 className="modal-title">{row?.domain_name ?? `Purchase #${id}`}</h2>{!row && !error && <p className="dim">Loading ledger detail…</p>}{error && <div className="form-alert">{error}</div>}{row && <><div className="chip-row"><Badge tone={row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'}>{row.status}</Badge></div><FlatRow label="Purchase"><code>#{row.id}</code></FlatRow><FlatRow label="Kind">{row.kind}</FlatRow><FlatRow label="Price">{row.price ?? '—'} {row.currency}</FlatRow><FlatRow label="Cost">{row.cost ?? '—'} {row.currency}</FlatRow><FlatRow label="Years">{row.years}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Quote expiry">{fmt.datetime(row.quote_expires)}</FlatRow><FlatRow label="Operation"><code>{row.operation_id ?? 'Not recorded'}</code></FlatRow>{row.error && <div className="form-alert">{redactRegistrarError(row.error)}</div>}</>}<div className="modal-actions"><button className="btn" onClick={close}>Close</button></div></div>;
11
+ export function DomainPurchaseDetail({ id, close, knownDomainId, privacyDowngraded = false, onNavigate }: { id: number; close: () => void; knownDomainId?: number; privacyDowngraded?: boolean; onNavigate?: () => void }) {
12
+ const { data: me } = useMe(); const auth = useAuthSnapshot();
13
+ const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
14
+ const canView = auth.authenticated && hasPermission(me ?? null, DNS_VIEW_PERMISSIONS, null);
15
+ const owner = JSON.stringify([id, auth.uid, revision, canView]); const latest = useRef(owner); latest.current = owner;
16
+ const [loaded, setLoaded] = useState<{ owner: string; row: DomainPurchaseRow } | null>(null);
17
+ const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const flight = useRef(false); const generation = useRef(0);
18
+ const location = useLocation(); const navigate = useNavigate(); const row = loaded?.owner === owner && canView ? loaded.row : null;
19
+ const refresh = useCallback(async () => {
20
+ if (!canView || flight.current || !Number.isInteger(id) || id <= 0) return;
21
+ const accepted = ++generation.current, uid = auth.uid; flight.current = true; setLoading(true); setError('');
22
+ const current = () => latest.current === owner && generation.current === accepted && getAuthSnapshot().uid === uid && getAuthSessionRevision() === revision;
23
+ try { const value = await fetchPurchaseDetail(id); if (!current()) return; if (value.id !== id) throw new Error('The ledger response did not match this purchase'); setLoaded({ owner, row: value }); }
24
+ catch (reason) { if (current()) setError(redactRegistrarError(reason)); }
25
+ finally { if (current()) { flight.current = false; setLoading(false); } }
26
+ }, [id, owner, canView, auth.uid, revision]);
27
+ useEffect(() => { flight.current = false; setLoaded(null); setError(''); setLoading(false); void refresh(); return () => { generation.current++; }; }, [refresh]);
28
+ const openDomain = () => {
29
+ if (!row || row.status !== 'completed') return;
30
+ if (knownDomainId && Number.isInteger(knownDomainId) && knownDomainId > 0) { const accepted = generation.current; void import('./DomainDetail').then(({ showDomainDetails }) => { if (generation.current === accepted && latest.current === owner && getAuthSnapshot().uid === auth.uid && getAuthSessionRevision() === revision) { close(); onNavigate?.(); return showDomainDetails(knownDomainId); } }); }
31
+ else { close(); onNavigate?.(); navigate(`${dnsAdminMount(location.pathname)}/dns/domains?search=${encodeURIComponent(row.domain_name)}`); }
32
+ };
33
+ return <div className="modal-pad dns-purchase-detail"><div className="eyebrow">Purchase #{id}</div><h2 className="modal-title">{row?.domain_name ?? 'Domain purchase'}</h2>
34
+ {!canView && <p className="dim" role="status">DNS viewing permission is required to inspect this purchase.</p>}
35
+ {loading && !row && <p className="dim" role="status">Loading purchase…</p>}{error && <div className="form-alert" role="alert">{row ? 'Refresh failed. Showing the previous result. ' : ''}{error}</div>}
36
+ {row && <><div className="chip-row"><Badge tone={row.status === 'completed' ? 'success' : row.status === 'failed' || row.status === 'expired' ? 'danger' : 'warning'}>{row.status}</Badge><OperationsHelp label="purchase ledger">This is the durable record of this exact purchase. Refresh only reads its status; it never submits another purchase. A submitted status can remain here while the registrar finishes registration.</OperationsHelp></div>
37
+ <FlatRow label="Price">{row.price ?? '—'} {row.currency} · {row.years} year{row.years === 1 ? '' : 's'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
38
+ {row.status === 'quoted' && <FlatRow label="Quote expires">{fmt.datetime(row.quote_expires)}</FlatRow>}
39
+ {row.operation_id && <FlatRow label="Registrar operation"><code>{row.operation_id}</code></FlatRow>}
40
+ {row.error && <div className="form-alert">{redactRegistrarError(row.error)}</div>}
41
+ {row.status === 'submitted' && <p className="form-alert">{PURCHASE_MAY_HAVE_MOVED}</p>}
42
+ {privacyDowngraded && <p className="form-alert" role="alert">Registration proceeded without WHOIS privacy. Your registrant contact may be publicly visible.</p>}
43
+ </>}
44
+ <div className="modal-actions">{row?.status === 'completed' && <button className="btn" onClick={openDomain}>{knownDomainId ? 'View domain' : 'Find domain'}</button>}<button className="btn" disabled={!canView || loading} onClick={() => void refresh()}>{loading ? 'Refreshing…' : 'Refresh'}</button><button className="btn btn-primary" onClick={close}>Close</button></div>
45
+ </div>;
11
46
  }
@@ -1,112 +1,142 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
- import { CollectionSelect, FormWizard, Badge, fmt, type FormWizardSection } from '../../ui';
3
+ import { getAuthSessionRevision, getAuthSnapshot, hasPermission, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
4
+ import { CollectionSelect, FormWizard, Badge, fmt, modal, type FormWizardSection } from '../../ui';
5
+ import { OperationsHelp } from '../core/OperationsHelp';
4
6
  import { DNS_GROUP_CHOICE_ENDPOINT, fetchDnsCapabilities, fetchPurchaseDetail, purchaseDomain, quoteDomain, searchRegistrarBatch } from './api';
5
- import { DomainModel, DomainPurchaseModel, type DnsGroupChoice, type DomainPurchaseRow, type RegistrarSearchRow } from './models';
7
+ import { DNS_MANAGE_PERMISSIONS, DomainModel, DomainPurchaseModel, type DnsGroupChoice, type DomainPurchaseRow, type RegistrarSearchRow } from './models';
6
8
  import { normalizePurchaseDomain, normalizeTypedTlds, pollPurchaseLedger, PURCHASE_MAY_HAVE_MOVED, quoteIdentity, quoteMatches, redactRegistrarError, searchAvailabilityLabel, type QuoteIdentity } from './purchase-data';
9
+ import { DomainPurchaseDetail } from './DomainPurchaseDetail';
10
+ import { RegistrantContactPage } from './RegistrantContactPage';
7
11
 
8
12
  interface TransientQuote { readonly token: string; readonly identity: Readonly<QuoteIdentity> }
9
-
10
- export function DomainPurchaseWizard({ onDone, onBusyChange }: { onDone?: () => void; onBusyChange?: (busy: boolean) => void }) {
13
+ interface WizardProps { onDone?: () => void; onBusyChange?: (busy: boolean) => void }
14
+ export function DomainPurchaseWizard(props: WizardProps) {
15
+ const { data: me } = useMe(); const auth = useAuthSnapshot();
16
+ const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
17
+ const canManage = auth.authenticated && hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
18
+ const [attempt, setAttempt] = useState<{ uid: string | null; revision: number; id: number } | null>(null);
19
+ useEffect(() => { setAttempt(value => value?.uid === auth.uid ? value : null); }, [auth.uid]);
20
+ const recordAttempt = useCallback((id: number) => setAttempt({ uid: auth.uid, revision, id }), [auth.uid, revision]);
21
+ if (!canManage) return <p className="dim" role="status">DNS management permission is required to buy a domain.</p>;
22
+ if (attempt?.uid === auth.uid && attempt.revision !== revision) return <PurchaseRecovery key={`${auth.uid}:${revision}`} id={attempt.id} onDone={props.onDone} />;
23
+ return <PurchaseWizard key={`${auth.uid}:${revision}`} {...props} uid={auth.uid} revision={revision} onAttempt={recordAttempt} />;
24
+ }
25
+ function PurchaseWizard({ onDone, onBusyChange, uid, revision, onAttempt }: WizardProps & { uid: string | null; revision: number; onAttempt: (id: number) => void }) {
11
26
  const queryClient = useQueryClient();
12
27
  const [group, setGroup] = useState<DnsGroupChoice | null>(null);
13
- const [base, setBase] = useState('');
14
- const [tlds, setTlds] = useState('com, net, org');
15
- const [results, setResults] = useState<RegistrarSearchRow[]>([]);
16
- const [selected, setSelected] = useState<RegistrarSearchRow | null>(null);
17
- const [years, setYears] = useState(1);
18
- const [typedDomain, setTypedDomain] = useState('');
19
- const [receipt, setReceipt] = useState<DomainPurchaseRow | null>(null);
20
- const [terminalMessage, setTerminalMessage] = useState('');
21
- const quoteRef = useRef<TransientQuote | null>(null);
22
- const [quoted, setQuoted] = useState<QuoteIdentity | null>(null);
23
- const attempted = useRef(false);
24
- const inFlight = useRef(false);
25
- const generation = useRef(0);
28
+ const [step, setStep] = useState('search');
29
+ const [base, setBase] = useState(''); const [tlds, setTlds] = useState('com, net, org');
30
+ const [results, setResults] = useState<RegistrarSearchRow[]>([]); const [selected, setSelected] = useState<RegistrarSearchRow | null>(null);
31
+ const [typedDomain, setTypedDomain] = useState(''); const [receipt, setReceipt] = useState<DomainPurchaseRow | null>(null);
32
+ const [terminalMessage, setTerminalMessage] = useState(''); const [purchaseId, setPurchaseId] = useState<number | null>(null);
33
+ const [knownDomainId, setKnownDomainId] = useState<number>(); const [privacyDowngraded, setPrivacyDowngraded] = useState(false);
34
+ const [contactRevision, setContactRevision] = useState(0); const [missingContact, setMissingContact] = useState(false); const [contactNotice, setContactNotice] = useState('');
35
+ const [busy, setBusy] = useState(false); const [refreshing, setRefreshing] = useState(false);
36
+ const quoteRef = useRef<TransientQuote | null>(null); const [quoted, setQuoted] = useState<QuoteIdentity | null>(null);
37
+ const attempted = useRef(false); const inFlight = useRef(false); const generation = useRef(0); const alive = useRef(false);
38
+ const onBusy = useRef(onBusyChange); onBusy.current = onBusyChange;
39
+ const modalOwner = useRef<AbortController | null>(null);
40
+ const handleBusy = useCallback((value: boolean) => { inFlight.current = value; setBusy(value); onBusy.current?.(value); }, []);
41
+ const isCurrent = (accepted: number) => alive.current && generation.current === accepted && getAuthSnapshot().uid === uid && getAuthSessionRevision() === revision;
26
42
  const clearQuote = () => { quoteRef.current = null; setQuoted(null); setTypedDomain(''); };
27
-
28
- useEffect(() => () => { generation.current += 1; quoteRef.current = null; }, []);
29
43
  useEffect(() => {
30
- if (!quoted) return;
44
+ alive.current = true; const owner = new AbortController(); modalOwner.current = owner;
45
+ return () => { alive.current = false; generation.current++; quoteRef.current = null; owner.abort(); onBusy.current?.(false); };
46
+ }, []);
47
+ useEffect(() => {
48
+ if (!quoted || attempted.current) return;
31
49
  const delay = Math.max(0, Math.min(2_147_483_647, quoted.expires * 1000 - Date.now()));
32
- const timer = window.setTimeout(() => { generation.current += 1; quoteRef.current = null; setQuoted(null); setTypedDomain(''); }, delay);
50
+ const timer = window.setTimeout(() => { if (!attempted.current) { generation.current++; clearQuote(); } }, delay);
33
51
  return () => window.clearTimeout(timer);
34
52
  }, [quoted]);
35
- const changeUpstream = (change: () => void) => { if (attempted.current) return; generation.current += 1; clearQuote(); setResults([]); setSelected(null); change(); };
36
-
53
+ const changeUpstream = (change: () => void) => { if (attempted.current || inFlight.current) return; generation.current++; clearQuote(); setResults([]); setSelected(null); setMissingContact(false); setContactNotice(''); change(); };
54
+ const setupContact = async () => {
55
+ if (!group || inFlight.current || attempted.current) return;
56
+ const accepted = generation.current, target = { ...group }; let pending = false;
57
+ await modal.open(close => <RegistrantContactPage initialGroup={target} lockScope onClose={() => close(null)} onBusyChange={value => { pending = value; }} onSaved={() => { if (isCurrent(accepted)) { setMissingContact(false); setContactNotice('Contact saved. Continue to search.'); setContactRevision(value => value + 1); } close(null); }} />, { size: 'lg', signal: modalOwner.current?.signal, canDismiss: () => !pending });
58
+ };
59
+ const refresh = async () => {
60
+ if (!purchaseId || refreshing) return; const accepted = generation.current; setRefreshing(true);
61
+ try { const value = await fetchPurchaseDetail(purchaseId); if (!isCurrent(accepted)) return; if (value.id !== purchaseId) throw new Error('The ledger response did not match this purchase'); setReceipt(value); setTerminalMessage(value.status === 'completed' ? 'Registration completed.' : PURCHASE_MAY_HAVE_MOVED); }
62
+ catch (reason) { if (isCurrent(accepted)) setTerminalMessage(`${redactRegistrarError(reason)} ${PURCHASE_MAY_HAVE_MOVED}`); }
63
+ finally { if (isCurrent(accepted)) setRefreshing(false); }
64
+ };
37
65
  const sections = useMemo<FormWizardSection[]>(() => [
38
66
  {
39
- key: 'search', label: 'Search', fields: [], description: 'Choose one authorized group, then enter a base label and your own TLD list. The backend—not this client—is the supported-TLD authority.',
67
+ key: 'search', label: 'Search', fields: [],
40
68
  content: <div className="dns-registrar-grid">
41
- <CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={group} onChange={(_id, row) => changeUpstream(() => setGroup(row ?? null))} label="Purchasing group" required placeholder="Search eligible groups…" maxItems={25} defaultParams={{ start: 0 }} />
42
- <label className="field"><span className="field-label">Base name <em>*</em></span><input className="input" value={base} disabled={attempted.current} onChange={(event) => changeUpstream(() => setBase(event.target.value))} placeholder="example" /></label>
43
- <label className="field"><span className="field-label">TLDs <em>*</em></span><input className="input" value={tlds} disabled={attempted.current} onChange={(event) => changeUpstream(() => setTlds(event.target.value))} placeholder="com, net" /><span className="field-help">Typed values only; no client-maintained supported list.</span></label>
69
+ <CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={group} onChange={(_id, row) => changeUpstream(() => setGroup(row ?? null))} label="Purchasing group" required placeholder="Search eligible groups…" maxItems={25} defaultParams={{ start: 0 }} disabled={busy || attempted.current} />
70
+ <label className="field"><span className="field-label">Base name <em>*</em></span><input className="input" value={base} disabled={busy || attempted.current} onChange={event => changeUpstream(() => setBase(event.target.value))} placeholder="example" /></label>
71
+ <label className="field"><span className="field-label">TLDs <em>*</em> <OperationsHelp label="domain search">Enter endings to search, separated by commas, for example com, net. Availability and pricing are checked with the registrar.</OperationsHelp></span><input className="input" aria-label="TLDs" value={tlds} disabled={busy || attempted.current} onChange={event => changeUpstream(() => setTlds(event.target.value))} placeholder="com, net" /></label>
72
+ {missingContact && <div className="form-alert">Set up a registrant contact for {group?.name} before purchasing. <button type="button" className="btn" disabled={busy} onClick={() => void setupContact()}>Set up contact</button></div>}{contactNotice && <p role="status">{contactNotice}</p>}
44
73
  </div>,
45
74
  onNext: async () => {
46
- if (!group) throw new Error('Choose a purchasing group');
47
- const caps = await fetchDnsCapabilities(group.id);
75
+ if (!group) throw new Error('Choose a purchasing group'); const accepted = generation.current;
76
+ const caps = await fetchDnsCapabilities(group.id); if (!isCurrent(accepted)) throw new Error('The purchasing scope changed. Search again.');
48
77
  if (!caps.purchase_enabled) throw new Error('Domain purchasing is disabled on this deployment');
49
- if (!caps.registrant_contact_configured) throw new Error('This group has no usable registrant contact');
78
+ if (!caps.registrant_contact_configured) { setMissingContact(true); throw new Error('A registrant contact is required'); }
50
79
  const label = base.trim().toLowerCase().replace(/^\.+|\.+$/g, '');
51
80
  if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) throw new Error('Enter one valid base label');
52
- const typed = normalizeTypedTlds(tlds, caps.search_batch_limit);
53
- const answer = await searchRegistrarBatch({ domain: label, tlds: typed });
81
+ const typed = normalizeTypedTlds(tlds, caps.search_batch_limit); const answer = await searchRegistrarBatch({ domain: label, tlds: typed });
82
+ if (!isCurrent(accepted)) throw new Error('The purchasing scope changed. Search again.');
54
83
  if (!answer.results.length) throw new Error('The registry returned no search results');
55
- setResults(answer.results); setSelected(answer.results.find((row) => row.available === true) ?? null);
84
+ setResults(answer.results); setSelected(answer.results.find(row => row.available === true) ?? null);
56
85
  },
57
86
  },
58
87
  {
59
- key: 'quote', label: 'Choose & quote', fields: [], description: 'Availability is tri-state. Only an explicit Available answer can be quoted.',
60
- content: <div className="dns-search-results">
61
- {results.map((row) => <label key={row.name} className={`dns-search-result${selected?.name === row.name ? ' is-selected' : ''}`}>
62
- <input type="radio" name="domain-result" checked={selected?.name === row.name} disabled={row.available !== true || attempted.current} onChange={() => { clearQuote(); setSelected(row); }} />
63
- <span><code>{row.name}</code><small>{row.reason ?? `${row.price ?? '—'} ${row.currency ?? ''}`}</small></span>
64
- <Badge tone={row.available === true ? 'success' : row.available === false ? 'danger' : 'warning'}>{searchAvailabilityLabel(row)}</Badge>
65
- </label>)}
66
- <label className="field"><span className="field-label">Registration years</span><select className="input" value={years} disabled={attempted.current} onChange={(event) => { clearQuote(); setYears(Number(event.target.value)); }}>{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => <option key={value} value={value}>{value}</option>)}</select></label>
67
- </div>,
88
+ key: 'quote', label: 'Choose & quote', fields: [],
89
+ content: <div className="dns-search-results"><p className="dim">One-year registration <OperationsHelp label="registration term">Purchases in this portal are for one year. The registrar provides the final quote before you confirm.</OperationsHelp></p>{results.map(row => <label key={row.name} className={`dns-search-result${selected?.name === row.name ? ' is-selected' : ''}`}><input type="radio" name="domain-result" checked={selected?.name === row.name} disabled={row.available !== true || busy || attempted.current} onChange={() => { clearQuote(); setSelected(row); }} /><span><code>{row.name}</code><small>{row.reason ?? `${row.price ?? '—'} ${row.currency ?? ''}`}</small></span><Badge tone={row.available === true ? 'success' : row.available === false ? 'danger' : 'warning'}>{searchAvailabilityLabel(row)}</Badge></label>)}</div>,
68
90
  onNext: async () => {
69
91
  if (!group || !selected || selected.available !== true || selected.price == null || !selected.currency) throw new Error('Choose an explicitly available domain');
70
- const quote = await quoteDomain({ group: group.id, domain: normalizePurchaseDomain(selected.name), years });
92
+ const accepted = generation.current; const quote = await quoteDomain({ group: group.id, domain: normalizePurchaseDomain(selected.name), years: 1 }, () => { if (!isCurrent(accepted)) throw new Error('The purchasing scope changed. Request a new quote.'); });
93
+ if (!isCurrent(accepted)) throw new Error('The purchasing scope changed. Request a new quote.');
71
94
  const identity = quoteIdentity(quote, group.id);
72
- if (!quoteMatches(identity, { group: group.id, domain: selected.name, years, price: selected.price, currency: selected.currency })) throw new Error('Quote did not match the selected search result');
95
+ if (!Number.isInteger(identity.purchase) || identity.purchase <= 0 || !quote.token || typeof quote.privacy_supported !== 'boolean' || !quoteMatches(identity, { group: group.id, domain: selected.name, years: 1, price: selected.price, currency: selected.currency })) throw new Error('Quote did not match the selected one-year registration');
73
96
  quoteRef.current = Object.freeze({ token: quote.token, identity: Object.freeze(identity) }); setQuoted(identity);
74
97
  },
75
98
  },
76
99
  {
77
- key: 'confirm', label: 'Confirm purchase', fields: [], description: 'This is a real-money operation. The exact quote tuple is immutable and the one-use token exists only in this mounted wizard.',
78
- content: quoted && <div className="dns-quote-card">
79
- <div><span>Domain</span><code>{quoted.domain}</code></div><div><span>Term</span><b>{quoted.years} year{quoted.years === 1 ? '' : 's'}</b></div>
80
- <div><span>Exact price</span><b>{quoted.price} {quoted.currency}</b></div><div><span>Expires</span><b>{fmt.datetime(quoted.expires)}</b></div>
81
- <label className="field"><span className="field-label">Type {quoted.domain} to confirm <em>*</em></span><input className="input" value={typedDomain} disabled={attempted.current} autoComplete="off" onChange={(event) => setTypedDomain(event.target.value)} /></label>
82
- </div>,
100
+ key: 'confirm', label: 'Confirm purchase', fields: [],
101
+ content: quoted ? <div className="dns-quote-card"><div><span>Domain</span><code>{quoted.domain}</code></div><div><span>Term</span><b>1 year</b></div><div><span>Exact price</span><b>{quoted.price} {quoted.currency}</b></div><div><span>Expires</span><b>{fmt.datetime(quoted.expires)}</b></div><div><span>WHOIS privacy</span><b>{quoted.privacy_supported ? 'Requested where supported' : 'Not available for this domain'}</b></div>{!quoted.privacy_supported && <p className="form-alert">Your registrant contact may be publicly visible. Confirm only if you accept registration without WHOIS privacy.</p>}<label className="field"><span className="field-label">Type {quoted.domain} to confirm <em>*</em> <OperationsHelp label="purchase confirmation">Confirming charges the exact quoted price for one year. The confirmation can be used only once. An uncertain response must be checked in the purchase ledger.</OperationsHelp></span><input className="input" aria-label={`Type ${quoted.domain} to confirm`} value={typedDomain} disabled={busy || attempted.current} autoComplete="off" onChange={event => setTypedDomain(event.target.value)} /></label></div> : <p className="form-alert" role="alert">Your quote expired. Go back to request a new quote.</p>,
83
102
  onNext: async () => {
84
- if (inFlight.current || attempted.current) throw new Error('This quote has already been attempted');
85
- const transient = quoteRef.current;
86
- if (!transient || !group || !selected || selected.price == null || !selected.currency) throw new Error('The quote is missing or expired. Start over.');
87
- if (!quoteMatches(transient.identity, { group: group.id, domain: selected.name, years, price: selected.price, currency: selected.currency })) { clearQuote(); throw new Error('The quote expired or its inputs changed. Start over.'); }
103
+ if (attempted.current) throw new Error('This quote has already been attempted'); const transient = quoteRef.current;
104
+ if (!transient || !group || !selected || selected.price == null || !selected.currency) throw new Error('The quote is missing or expired. Go back for a new quote.');
105
+ if (!quoteMatches(transient.identity, { group: group.id, domain: selected.name, years: 1, price: selected.price, currency: selected.currency })) { clearQuote(); throw new Error('The quote expired or its inputs changed. Go back for a new quote.'); }
88
106
  if (typedDomain.trim().toLowerCase() !== transient.identity.domain) throw new Error(`Type ${transient.identity.domain} exactly`);
89
- inFlight.current = true; attempted.current = true; const accepted = ++generation.current;
90
- const token = transient.token; quoteRef.current = null;
107
+ attempted.current = true; const accepted = ++generation.current; const token = transient.token; quoteRef.current = null; setPurchaseId(transient.identity.purchase); onAttempt(transient.identity.purchase);
91
108
  try {
92
- const acknowledgement = await purchaseDomain({ group: transient.identity.group, purchase: transient.identity.purchase, confirm_token: token, confirm_domain: transient.identity.domain, confirm_price: transient.identity.price });
93
- const settled = await pollPurchaseLedger({ purchase: acknowledgement.purchase, isCurrent: () => generation.current === accepted, fetch: fetchPurchaseDetail });
94
- if (!settled) setTerminalMessage(`Purchase acknowledgement was received, but bounded polling did not settle. ${PURCHASE_MAY_HAVE_MOVED}`);
95
- else { setReceipt(settled); setTerminalMessage(settled.status === 'completed' ? 'Registration completed.' : `${redactRegistrarError(settled.error)} ${PURCHASE_MAY_HAVE_MOVED}`); }
96
- } catch (error) {
97
- setTerminalMessage(`${redactRegistrarError(error)} ${PURCHASE_MAY_HAVE_MOVED}`);
98
- try { setReceipt(await fetchPurchaseDetail(transient.identity.purchase)); } catch { /* detail stays local and optional */ }
99
- } finally {
100
- await Promise.allSettled([DomainPurchaseModel.invalidate(queryClient), DomainModel.invalidate(queryClient)]);
101
- inFlight.current = false;
102
- }
109
+ const acknowledgement = await purchaseDomain({ group: transient.identity.group, purchase: transient.identity.purchase, confirm_token: token, confirm_domain: transient.identity.domain, confirm_price: transient.identity.price }, () => { if (!isCurrent(accepted)) throw new Error('The purchasing session changed. Review the exact purchase before continuing.'); });
110
+ if (!isCurrent(accepted)) return;
111
+ if (acknowledgement.purchase !== transient.identity.purchase || acknowledgement.name !== transient.identity.domain) throw new Error('The acknowledgement did not match this purchase');
112
+ setKnownDomainId(acknowledgement.domain); setPrivacyDowngraded(acknowledgement.privacy_downgraded);
113
+ const settled = await pollPurchaseLedger({ purchase: transient.identity.purchase, isCurrent: () => isCurrent(accepted), fetch: fetchPurchaseDetail });
114
+ if (!isCurrent(accepted)) return;
115
+ setReceipt(settled); setTerminalMessage(settled?.status === 'completed' ? 'Registration completed.' : PURCHASE_MAY_HAVE_MOVED);
116
+ } catch (reason) {
117
+ if (!isCurrent(accepted)) return; setTerminalMessage(`${redactRegistrarError(reason)} ${PURCHASE_MAY_HAVE_MOVED}`);
118
+ try { const value = await fetchPurchaseDetail(transient.identity.purchase); if (isCurrent(accepted) && value.id === transient.identity.purchase) setReceipt(value); } catch { /* Exact ledger remains available for deliberate Refresh. */ }
119
+ } finally { await Promise.allSettled([DomainPurchaseModel.invalidate(queryClient), DomainModel.invalidate(queryClient)]); }
103
120
  },
104
121
  },
105
122
  {
106
- key: 'receipt', label: 'Ledger', fields: [], terminal: true,
107
- content: <div className="dns-purchase-receipt"><h3>{receipt?.status === 'completed' ? 'Purchase complete' : 'Check the durable ledger'}</h3><p className={receipt?.status === 'completed' ? '' : 'form-alert'}>{terminalMessage}</p>{receipt && <><div><span>Purchase</span><code>#{receipt.id}</code></div><div><span>Domain</span><code>{receipt.domain_name}</code></div><div><span>Status</span><Badge tone={receipt.status === 'completed' ? 'success' : receipt.status === 'failed' ? 'danger' : 'warning'}>{receipt.status}</Badge></div></>}<div className="modal-actions"><button className="btn btn-primary" onClick={onDone}>Close</button></div></div>,
123
+ key: 'receipt', label: 'Purchase status', fields: [], terminal: true,
124
+ content: <div className="dns-purchase-receipt"><h3>{receipt?.status === 'completed' ? 'Purchase complete' : 'Check purchase status'}</h3><p className={receipt?.status === 'completed' ? '' : 'form-alert'}>{terminalMessage}</p>{privacyDowngraded && <p role="alert" className="form-alert">The registrar completed registration without WHOIS privacy. Your registrant contact may be publicly visible.</p>}<div><span>Purchase</span><code>#{purchaseId}</code></div><div><span>Domain</span><code>{receipt?.domain_name ?? quoted?.domain}</code></div>{receipt && <div><span>Status</span><Badge tone={receipt.status === 'completed' ? 'success' : receipt.status === 'failed' ? 'danger' : 'warning'}>{receipt.status}</Badge></div>}<div className="modal-actions"><button className="btn" disabled={refreshing} onClick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</button><button className="btn" disabled={!purchaseId} onClick={() => { if (purchaseId) void modal.open(close => <DomainPurchaseDetail id={purchaseId} onNavigate={onDone} knownDomainId={knownDomainId} privacyDowngraded={privacyDowngraded} close={() => close(null)} />, { size: 'md', signal: modalOwner.current?.signal }); }}>View purchase</button><button className="btn btn-primary" onClick={onDone}>Close</button></div></div>,
108
125
  },
109
- ], [base, group, onDone, queryClient, quoted, receipt, results, selected, terminalMessage, tlds, typedDomain, years]);
126
+ ], [base, busy, contactNotice, group, knownDomainId, missingContact, onDone, privacyDowngraded, purchaseId, queryClient, quoted, receipt, refreshing, results, selected, terminalMessage, tlds, typedDomain]);
127
+ return <FormWizard mode="wizard" resetKey={contactRevision} sections={sections} onBusyChange={handleBusy} onCancel={onDone} onFinish={() => undefined} onStepChange={setStep} nextText={step === 'confirm' ? 'Confirm purchase' : 'Continue'} />;
128
+ }
110
129
 
111
- return <FormWizard mode="wizard" sections={sections} onBusyChange={onBusyChange} onCancel={onDone} onFinish={() => undefined} nextText="Continue" />;
130
+ /** Only the attempted ID survives same-user session replacement; tokens and drafts do not. */
131
+ function PurchaseRecovery({ id, onDone }: { id: number; onDone?: () => void }) {
132
+ const [row, setRow] = useState<DomainPurchaseRow | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState('');
133
+ const flight = useRef(false); const owner = useRef<AbortController | null>(null);
134
+ useEffect(() => { const controller = new AbortController(); owner.current = controller; return () => controller.abort(); }, []);
135
+ const refresh = async () => {
136
+ if (flight.current) return; const signal = owner.current?.signal; flight.current = true; setBusy(true); setError('');
137
+ try { const value = await fetchPurchaseDetail(id); if (signal?.aborted) return; if (value.id !== id) throw new Error('The ledger response did not match this purchase'); setRow(value); }
138
+ catch (reason) { if (!signal?.aborted) setError(redactRegistrarError(reason)); }
139
+ finally { if (!signal?.aborted) { flight.current = false; setBusy(false); } }
140
+ };
141
+ return <div className="dns-purchase-receipt"><h3>Check purchase #{id}</h3><p className="form-alert" role="alert">Your session changed. Review this purchase before starting another.</p>{row && <p><code>{row.domain_name}</code> · {row.status}</p>}{error && <p className="form-alert" role="alert">{error}</p>}<div className="modal-actions"><button className="btn" disabled={busy} onClick={() => void refresh()}>{busy ? 'Refreshing…' : 'Refresh'}</button><button className="btn" onClick={() => void modal.open(close => <DomainPurchaseDetail id={id} onNavigate={onDone} close={() => close(null)} />, { size: 'md', signal: owner.current?.signal })}>View purchase</button><button className="btn btn-primary" onClick={onDone}>Close</button></div></div>;
112
142
  }
@@ -12,5 +12,5 @@ const columns: Column<DomainPurchaseRow>[] = [
12
12
  const filters: FilterDef[] = [{ key: 'status', label: 'Status', type: 'multiselect', options: ['quoted', 'submitted', 'completed', 'failed', 'expired'].map((value) => ({ value, label: value })) }];
13
13
  export function DomainPurchasesPage() {
14
14
  const { data: me } = useMe(); const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
15
- return <ModelTable model={DomainPurchaseModel} eyebrow="Infrastructure · DNS" title="Domain Purchases" searchable searchPlaceholder="Search domain or status" columns={columns} filters={filters} defaultParams={{ sort: '-created' }} persistState persistKey="admin:dns:purchases" onRowClick={(row) => void modal.detail((close) => <DomainPurchaseDetail id={row.id} close={() => close(null)} />)} {...(canManage ? { addLabel: 'Add domain', onAdd: () => void showDomainOnboarding() } : {})} />;
15
+ return <ModelTable model={DomainPurchaseModel} eyebrow="Infrastructure · DNS" title="Domain Purchases" searchable searchPlaceholder="Search domain or status" columns={columns} filters={filters} defaultParams={{ sort: '-created' }} persistState persistKey="admin:dns:purchases" onRowClick={(row) => void modal.open((close) => <DomainPurchaseDetail id={row.id} close={() => close(null)} />, { size: 'md' })} {...(canManage ? { addLabel: 'Add domain', onAdd: () => void showDomainOnboarding() } : {})} />;
16
16
  }