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.
- package/docs/admin-assistant.md +75 -23
- package/docs/admin-dns-domains.md +96 -90
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-dns-registrar.md +29 -4
- package/docs/admin-dns.md +2 -3
- package/docs/admin-modals.md +21 -0
- package/docs/menus-and-access.md +24 -0
- package/package.json +1 -1
- package/src/admin/assistant/AssistantFeed.tsx +7 -4
- package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
- package/src/admin/assistant/api.ts +9 -7
- package/src/admin/assistant/pages.tsx +125 -109
- package/src/admin/assistant/setup.ts +1 -1
- package/src/admin/assistant/skill-lifecycle.ts +5 -4
- package/src/admin/core/index.ts +10 -2
- package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
- package/src/admin/dns/DomainAssignment.tsx +55 -0
- package/src/admin/dns/DomainDetail.tsx +55 -38
- package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
- package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
- package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
- package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
- package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
- package/src/admin/dns/DomainsPage.tsx +25 -24
- package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
- package/src/admin/dns/RegistrantContactPage.tsx +73 -27
- package/src/admin/dns/api.ts +26 -25
- package/src/admin/dns/domain-authority.ts +11 -0
- package/src/admin/dns/index.ts +2 -2
- package/src/admin/dns/purchase-data.ts +5 -3
- package/src/admin/dns/registrar-api.ts +57 -11
- package/src/admin/domains/infrastructure.ts +8 -8
- package/src/admin/domains/operations.ts +6 -6
- package/src/admin/edge/VhostsPage.tsx +6 -1
- package/src/admin/jobs/JobDashboardPage.tsx +14 -4
- package/src/client/mock.ts +14 -8
- package/src/styles/components/admin-assistant.css +83 -20
- package/src/styles/components/admin-domains.css +67 -11
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-jobs.css +1 -0
- package/src/ui/SidebarNav.tsx +10 -1
- 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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const canManage = hasPermission(me
|
|
37
|
-
const
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
{
|
|
45
|
-
{
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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><
|
|
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 {
|
|
5
|
-
import
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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(
|
|
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 [
|
|
14
|
-
const [tlds, setTlds] = useState('com, net, org');
|
|
15
|
-
const [results, setResults] = useState<RegistrarSearchRow[]>([]);
|
|
16
|
-
const [
|
|
17
|
-
const [
|
|
18
|
-
const [
|
|
19
|
-
const [
|
|
20
|
-
const [
|
|
21
|
-
const quoteRef = useRef<TransientQuote | null>(null);
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
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
|
-
|
|
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(() => {
|
|
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
|
|
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: [],
|
|
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={
|
|
43
|
-
<label className="field"><span className="field-label">TLDs <em>*</em></span><input className="input" value={tlds} disabled={attempted.current} onChange={
|
|
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('
|
|
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
|
-
|
|
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(
|
|
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: [],
|
|
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
|
|
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: [],
|
|
78
|
-
content: quoted
|
|
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 (
|
|
85
|
-
|
|
86
|
-
if (!transient
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
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: '
|
|
107
|
-
content: <div className="dns-purchase-receipt"><h3>{receipt?.status === 'completed' ? 'Purchase complete' : 'Check
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
}
|