portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { TagInput, toast } from '../../ui';
|
|
4
4
|
import { requestCertificate, useAcmeDelegations, useDnsCapabilities } from './api';
|
|
5
5
|
import { deriveCertificateReadiness, validateCertificateNames } from './certificate-data';
|
|
6
|
-
import {
|
|
6
|
+
import { getAuthSessionRevision, getAuthSnapshot, hasPermission, requestFreshAuth, useMe } from '../../client/runtime';
|
|
7
|
+
import { confirmShared } from './SharedValidationDialog';
|
|
8
|
+
import { invalidateValidation, requestWildcard, useValidationPlan } from './shared-validation-api';
|
|
9
|
+
import { SharedValidationError } from './shared-validation-data';
|
|
10
|
+
import { CertificateModel, DNS_MANAGE_PERMISSIONS, DnsCredentialModel, DomainModel, type CertificateRow, type DomainRow } from './models';
|
|
7
11
|
|
|
8
12
|
function relationId(value: DomainRow['credential']): number | null {
|
|
9
13
|
if (typeof value === 'number') return value;
|
|
@@ -15,6 +19,7 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
|
|
|
15
19
|
domain?: DomainRow;
|
|
16
20
|
}) {
|
|
17
21
|
const queryClient = useQueryClient();
|
|
22
|
+
const { data: me } = useMe();
|
|
18
23
|
const domains = DomainModel.useList({ status: 'active', size: 200, sort: 'name' }, { enabled: fixedDomain == null });
|
|
19
24
|
const [domainId, setDomainId] = useState<number | null>(fixedDomain?.id ?? null);
|
|
20
25
|
const domainQuery = DomainModel.useOne(domainId);
|
|
@@ -24,6 +29,15 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
|
|
|
24
29
|
const delegations = useAcmeDelegations(domain?.id ?? null, domain != null);
|
|
25
30
|
const credentialId = domain ? relationId(domain.credential) : null;
|
|
26
31
|
const credential = DnsCredentialModel.useOne(credentialId);
|
|
32
|
+
const sharedPlan = useValidationPlan(domain ? { domain: domain.id } : null);
|
|
33
|
+
const explicitShared = sharedPlan.data?.connection?.mode === 'shared';
|
|
34
|
+
const sharedFlight = useRef(false);
|
|
35
|
+
const sharedOwner = useRef<AbortController | null>(null);
|
|
36
|
+
const [sharedRefreshRequired, setSharedRefreshRequired] = useState(false);
|
|
37
|
+
const sharedRevision = getAuthSessionRevision();
|
|
38
|
+
const sharedIdentity = JSON.stringify([domainId, sharedRevision, me?.id, hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)]);
|
|
39
|
+
const currentIdentity = useRef(sharedIdentity); currentIdentity.current = sharedIdentity;
|
|
40
|
+
useEffect(() => { const abort = new AbortController(); sharedOwner.current = abort; return () => abort.abort(); }, [sharedIdentity]);
|
|
27
41
|
const [namesCsv, setNamesCsv] = useState('');
|
|
28
42
|
const [names, setNames] = useState<string[]>([]);
|
|
29
43
|
const [submitting, setSubmitting] = useState(false);
|
|
@@ -45,7 +59,30 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
|
|
|
45
59
|
|
|
46
60
|
const submit = async (event: React.FormEvent) => {
|
|
47
61
|
event.preventDefault();
|
|
48
|
-
if (
|
|
62
|
+
if (explicitShared) {
|
|
63
|
+
const r = sharedPlan.data;
|
|
64
|
+
if (!domain || !r?.capabilities.can_request || !r.connection || !r.alias.target || sharedPlan.error || sharedPlan.loading || sharedFlight.current || sharedRefreshRequired || !hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)) return;
|
|
65
|
+
sharedFlight.current = true; setSubmitting(true); setError(null);
|
|
66
|
+
const identity = sharedIdentity, uid = getAuthSnapshot().uid, revision = getAuthSessionRevision();
|
|
67
|
+
const signal = sharedOwner.current?.signal;
|
|
68
|
+
const beforeSend = () => { if (signal?.aborted || currentIdentity.current !== identity || getAuthSnapshot().uid !== uid || getAuthSessionRevision() !== revision) throw new SharedValidationError('access'); };
|
|
69
|
+
let submitted = false;
|
|
70
|
+
try {
|
|
71
|
+
const confirmed = await confirmShared({ title: 'Request wildcard certificate', message: <>Request a local certificate for <code>{r.profile.names.join(', ')}</code> through the verified shared validation target <code>{r.alias.target}</code>? This deployment keeps its own private key.</>, confirmText: 'Request wildcard', signal });
|
|
72
|
+
if (!confirmed) return;
|
|
73
|
+
beforeSend(); submitted = true;
|
|
74
|
+
const result = await requestWildcard(r.connection.id, { domain: domain.id }, r.alias.target, { signal, beforeSend });
|
|
75
|
+
beforeSend();
|
|
76
|
+
if (result.certificate) { const row = await CertificateModel.fetchOne(queryClient, result.certificate.id); beforeSend(); close(row); }
|
|
77
|
+
} catch (reason) {
|
|
78
|
+
if (!signal?.aborted) { setError(reason instanceof SharedValidationError ? reason.message : 'Shared wildcard request could not be completed. Refresh before retrying.'); if (reason instanceof SharedValidationError && reason.kind === 'reauth') { setSharedRefreshRequired(true); await requestFreshAuth(); } }
|
|
79
|
+
} finally {
|
|
80
|
+
if (submitted) { await invalidateValidation(queryClient); if (!signal?.aborted) setSharedRefreshRequired(!(await sharedPlan.refresh())); }
|
|
81
|
+
sharedFlight.current = false; setSubmitting(false);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!domain || !readiness?.ready || validation.errors.length || submitting || (sharedPlan.error && sharedPlan.error.kind !== 'unsupported')) return;
|
|
49
86
|
setSubmitting(true);
|
|
50
87
|
setError(null);
|
|
51
88
|
try {
|
|
@@ -69,7 +106,7 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
|
|
|
69
106
|
{(domains.data?.rows ?? []).map((row) => <option key={row.id} value={row.id}>{row.name}</option>)}
|
|
70
107
|
</select>
|
|
71
108
|
</label>}
|
|
72
|
-
<label className="field">
|
|
109
|
+
{explicitShared ? <div className="dns-cert-readiness"><strong>Fixed shared wildcard profile</strong><code>{sharedPlan.data!.profile.names.join(', ')}</code><span>Only the backend-authorized apex and wildcard can be requested through this connection.</span><span>{sharedPlan.data!.next_action.code.replaceAll('_', ' ')}</span><button className="btn" type="button" disabled={submitting || sharedPlan.loading} onClick={() => void sharedPlan.refresh().then((r) => { setSharedRefreshRequired(!r); if (r) setError(null); })}>Refresh shared readiness</button></div> : <label className="field">
|
|
73
110
|
<span className="field-label">Names</span>
|
|
74
111
|
<TagInput
|
|
75
112
|
name="certificate_names"
|
|
@@ -81,21 +118,22 @@ export function CertificateRequestDialog({ close, domain: fixedDomain }: {
|
|
|
81
118
|
disabled={!domain || submitting}
|
|
82
119
|
/>
|
|
83
120
|
<span className="field-help">Leave empty for the domain apex plus its wildcard. Every SAN must stay inside the selected zone.</span>
|
|
84
|
-
</label>
|
|
121
|
+
</label>}
|
|
85
122
|
{!!validation.errors.length && <div className="dns-errors" role="alert">{validation.errors.map((message) => <div key={message}>{message}</div>)}</div>}
|
|
86
123
|
{loadingReadiness && <div className="dns-cert-readiness dim"><i className="bi bi-arrow-repeat spin" /> Checking issuance readiness…</div>}
|
|
87
|
-
{readiness && <div className={`dns-cert-readiness${readiness.ready ? ' is-ready' : ' is-blocked'}`}>
|
|
124
|
+
{!explicitShared && readiness && <div className={`dns-cert-readiness${readiness.ready ? ' is-ready' : ' is-blocked'}`}>
|
|
88
125
|
<strong>{readiness.label}</strong>
|
|
89
126
|
{readiness.reason && <span>{readiness.reason}</span>}
|
|
90
127
|
</div>}
|
|
91
128
|
{capabilities.data?.acme.staging && <div className="dns-provider-note">
|
|
92
129
|
<strong>Current ACME configuration: staging.</strong> New certificates are not publicly trusted. This flag is current configuration, not historical provenance for existing certificates.
|
|
93
130
|
</div>}
|
|
131
|
+
{sharedPlan.error && sharedPlan.error.kind !== 'unsupported' && <div className="dns-errors" role="alert">{sharedPlan.error.message}</div>}
|
|
94
132
|
{capabilities.error && <div className="dns-errors">{capabilities.error.message}</div>}
|
|
95
133
|
{error && <div className="dns-errors" role="alert">{error}</div>}
|
|
96
134
|
<div className="modal-actions">
|
|
97
135
|
<button className="btn" type="button" disabled={submitting} onClick={() => close(null)}>Cancel</button>
|
|
98
|
-
<button className="btn btn-primary" type="submit" disabled={!domain || !readiness?.ready || validation.errors.length > 0 ||
|
|
136
|
+
<button className="btn btn-primary" type="submit" disabled={!domain || submitting || sharedPlan.loading || (explicitShared ? !sharedPlan.data?.capabilities.can_request || !!sharedPlan.error || sharedRefreshRequired : !readiness?.ready || validation.errors.length > 0 || (!!sharedPlan.error && sharedPlan.error.kind !== 'unsupported'))}>
|
|
99
137
|
{submitting ? 'Requesting…' : 'Request certificate'}
|
|
100
138
|
</button>
|
|
101
139
|
</div>
|
|
@@ -6,7 +6,12 @@ import { providerLabel } from './data';
|
|
|
6
6
|
import { useDnsDomainLinks } from './domain-links';
|
|
7
7
|
import { DnsRecordsPanel } from './DnsRecordsPanel';
|
|
8
8
|
import { DomainCertificatesSection } from './DomainCertificatesSection';
|
|
9
|
-
import {
|
|
9
|
+
import { SharedValidationPanel } from './SharedValidationPanel';
|
|
10
|
+
import { openSharedValidation } from './SharedValidationDialog';
|
|
11
|
+
import { useValidationPlan } from './shared-validation-api';
|
|
12
|
+
import { domainRecordsHref, domainScopeLabel, dnsAdminMount, supportsDomainRecords } from './domain-navigation';
|
|
13
|
+
import { redactRegistrarError } from './purchase-data';
|
|
14
|
+
import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS, DomainModel } from './models';
|
|
10
15
|
|
|
11
16
|
export function DomainDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
12
17
|
const query = DomainModel.useOne(id);
|
|
@@ -15,6 +20,7 @@ export function DomainDetail({ id, onClose }: { id: number; onClose: () => void
|
|
|
15
20
|
const navigate = useNavigate();
|
|
16
21
|
const location = useLocation();
|
|
17
22
|
const domain = query.data;
|
|
23
|
+
const shared = useValidationPlan(domain ? { domain: domain.id } : null);
|
|
18
24
|
const group = typeof domain?.group === 'number' ? domain.group : domain?.group?.id ?? null;
|
|
19
25
|
const capabilities = useDnsCapabilities(group, { enabled: !!domain });
|
|
20
26
|
if (query.isPending) return <div className="modal-pad dim">Loading domain…</div>;
|
|
@@ -26,16 +32,20 @@ export function DomainDetail({ id, onClose }: { id: number; onClose: () => void
|
|
|
26
32
|
onClose();
|
|
27
33
|
navigate(`${mount}/dns/${route.replace(/^\/?dns\//, '').replace(/^\//, '')}`);
|
|
28
34
|
};
|
|
29
|
-
const groupLabel =
|
|
35
|
+
const groupLabel = domainScopeLabel(domain.group);
|
|
36
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
37
|
+
const openDestination = (href: string) => { onClose(); navigate(href); };
|
|
30
38
|
const credential = typeof domain.credential === 'object' ? domain.credential : null;
|
|
31
39
|
return <DetailView
|
|
32
|
-
icon="bi-globe2" title={domain.name} subtitle={
|
|
40
|
+
icon="bi-globe2" title={domain.name} subtitle={providerLabel(domain.provider)}
|
|
33
41
|
chips={[{ text: domain.status, tone: fmt.inferTone(domain.status) }, ...(domain.verified ? [{ text: 'Verified', tone: 'success' as const }] : [])]}
|
|
34
42
|
sections={[
|
|
35
|
-
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-domain-overview"><
|
|
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} /> },
|
|
36
45
|
{ key: 'records', label: 'DNS Records', icon: 'bi-list-columns', render: () => <DnsRecordsPanel domain={domain} /> },
|
|
37
46
|
{ key: 'certificates', label: 'Certificates', icon: 'bi-patch-check', render: () => <DomainCertificatesSection domain={domain} /> },
|
|
38
47
|
]}
|
|
39
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) }] : []}
|
|
40
50
|
/>;
|
|
41
51
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { hasPermission, useMe } from '../../client/runtime';
|
|
4
|
+
import { toast } from '../../ui';
|
|
5
|
+
import { adoptHouseDomain } from './api';
|
|
6
|
+
import { DNS_MANAGE_PERMISSIONS, DomainModel } from './models';
|
|
7
|
+
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
|
+
import { DomainPurchaseWizard } from './DomainPurchaseWizard';
|
|
9
|
+
import { DomainRegisterExisting } from './DomainRegisterExisting';
|
|
10
|
+
|
|
11
|
+
export type OnboardingMode = 'menu' | 'register' | 'purchase' | 'adopt';
|
|
12
|
+
|
|
13
|
+
export function DomainOnboardingDialog({ close, setBusyGate, initialMode = 'menu' }: { close: () => void; setBusyGate: (busy: boolean) => void; initialMode?: OnboardingMode }) {
|
|
14
|
+
const { data: me } = useMe(); const queryClient = useQueryClient();
|
|
15
|
+
const [mode, setMode] = useState<OnboardingMode>(initialMode);
|
|
16
|
+
const [name, setName] = useState(''); const [review, setReview] = useState<string | null>(null);
|
|
17
|
+
const [busy, setBusy] = useState(false); const [attempted, setAttempted] = useState(false); const [error, setError] = useState('');
|
|
18
|
+
const inFlight = useRef(false);
|
|
19
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
20
|
+
const prepareAdoption = () => {
|
|
21
|
+
if (inFlight.current || attempted || me?.is_superuser !== true) return;
|
|
22
|
+
try { setReview(normalizePurchaseDomain(name)); setError(''); setBusyGate(true); }
|
|
23
|
+
catch (reason) { setError(redactRegistrarError(reason)); }
|
|
24
|
+
};
|
|
25
|
+
const cancelReview = () => { if (inFlight.current) return; setReview(null); setBusyGate(false); };
|
|
26
|
+
const adopt = async () => {
|
|
27
|
+
if (inFlight.current || attempted || !review || me?.is_superuser !== true || !canManage) return;
|
|
28
|
+
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(); }
|
|
30
|
+
catch (reason) { await DomainModel.invalidate(queryClient); setError(`${redactRegistrarError(reason)} Review the domain inventory before another adoption.`); }
|
|
31
|
+
finally { inFlight.current = false; setBusy(false); setReview(null); setBusyGate(false); }
|
|
32
|
+
};
|
|
33
|
+
if (!me) return <div className="modal-pad dim">Checking DNS authority…</div>;
|
|
34
|
+
if (!canManage) return <div className="modal-pad domain-onboarding"><h2 className="modal-title">DNS management required</h2><p className="dim">A global DNS management permission is required.</p><button className="btn" onClick={close}>Close</button></div>;
|
|
35
|
+
if (mode === 'purchase') return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Buy domain</h2><DomainPurchaseWizard onDone={close} onBusyChange={setBusyGate} /></div>;
|
|
36
|
+
if (mode === 'register') return <DomainRegisterExisting close={close} back={() => setMode('menu')} setBusyGate={setBusyGate} />;
|
|
37
|
+
if (mode === 'adopt') {
|
|
38
|
+
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>}
|
|
40
|
+
{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
|
+
</div>;
|
|
42
|
+
}
|
|
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>;
|
|
44
|
+
}
|
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { useMe } from '../../client/runtime';
|
|
4
|
-
import { modal, toast } from '../../ui';
|
|
5
|
-
import { adoptHouseDomain } from './api';
|
|
6
|
-
import { DomainModel } from './models';
|
|
7
|
-
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
|
-
import { DomainPurchaseWizard } from './DomainPurchaseWizard';
|
|
1
|
+
import { modal } from '../../ui';
|
|
2
|
+
import { DomainOnboardingDialog, type OnboardingMode } from './DomainOnboardingContent';
|
|
9
3
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const [mode, setMode] = useState<'menu' | 'purchase' | 'adopt'>('menu'); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('');
|
|
13
|
-
const adopt = async () => {
|
|
14
|
-
if (busy || me?.is_superuser !== true) return; setBusy(true); setBusyGate(true); setError('');
|
|
15
|
-
try { const domain = await adoptHouseDomain({ domain: normalizePurchaseDomain(name) }); await DomainModel.invalidate(queryClient); toast.success(`${domain.name} adopted at House scope`); close(); }
|
|
16
|
-
catch (reason) { setError(redactRegistrarError(reason)); }
|
|
17
|
-
finally { setBusy(false); setBusyGate(false); }
|
|
18
|
-
};
|
|
19
|
-
if (mode === 'purchase') return <div className="modal-pad"><h2 className="modal-title">Purchase a domain</h2><DomainPurchaseWizard onDone={close} onBusyChange={setBusyGate} /></div>;
|
|
20
|
-
if (mode === 'adopt') return <div className="modal-pad"><h2 className="modal-title">Adopt a House domain</h2><p className="dim">Manual, exact-name adoption only. This does not enumerate the House account and creates no tenant assignment.</p>{error && <div className="form-alert">{error}</div>}<label className="field"><span className="field-label">Existing House domain</span><input className="input" value={name} disabled={busy} onChange={(event) => setName(event.target.value)} /></label><div className="modal-actions"><button className="btn" disabled={busy} onClick={() => setMode('menu')}>Back</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void adopt()}>{busy ? 'Adopting…' : 'Adopt exact name'}</button></div></div>;
|
|
21
|
-
return <div className="modal-pad"><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('purchase')}><i className="bi bi-cart-check" /><b>Purchase a new domain</b><span>Search, quote, confirm once, and reconcile against the durable ledger.</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 name manually; no account discovery.</span></button>}</div><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button></div></div>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function showDomainOnboarding(): Promise<unknown> {
|
|
4
|
+
/** No-argument callers retain the original menu. */
|
|
5
|
+
export function showDomainOnboarding({ initialMode = 'menu' }: { initialMode?: OnboardingMode } = {}): Promise<unknown> {
|
|
25
6
|
let busy = false;
|
|
26
|
-
return modal.open((close) => <DomainOnboardingDialog close={() => close(null)} setBusyGate={(value) => { busy = value; }} />, { size: 'lg', canDismiss: () => !busy });
|
|
7
|
+
return modal.open((close) => <DomainOnboardingDialog initialMode={initialMode} close={() => close(null)} setBusyGate={(value) => { busy = value; }} />, { size: 'lg', canDismiss: () => !busy });
|
|
27
8
|
}
|
|
@@ -89,7 +89,7 @@ export function DomainPurchaseWizard({ onDone, onBusyChange }: { onDone?: () =>
|
|
|
89
89
|
inFlight.current = true; attempted.current = true; const accepted = ++generation.current;
|
|
90
90
|
const token = transient.token; quoteRef.current = null;
|
|
91
91
|
try {
|
|
92
|
-
const acknowledgement = await purchaseDomain({ group: transient.identity.group, purchase: transient.identity.purchase, confirm_token: token });
|
|
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
93
|
const settled = await pollPurchaseLedger({ purchase: acknowledgement.purchase, isCurrent: () => generation.current === accepted, fetch: fetchPurchaseDetail });
|
|
94
94
|
if (!settled) setTerminalMessage(`Purchase acknowledgement was received, but bounded polling did not settle. ${PURCHASE_MAY_HAVE_MOVED}`);
|
|
95
95
|
else { setReceipt(settled); setTerminalMessage(settled.status === 'completed' ? 'Registration completed.' : `${redactRegistrarError(settled.error)} ${PURCHASE_MAY_HAVE_MOVED}`); }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CollectionSelect, toast } from '../../ui';
|
|
4
|
+
import { OperationsHelp } from '../core/OperationsHelp';
|
|
5
|
+
import { DNS_GROUP_CHOICE_ENDPOINT, registerExistingDomain } from './api';
|
|
6
|
+
import { DnsCredentialModel, DomainModel, type DnsCredentialRow, type DnsGroupChoice } from './models';
|
|
7
|
+
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
|
+
|
|
9
|
+
export function usableRegistrationCredential(row: DnsCredentialRow | null, group: number): row is DnsCredentialRow {
|
|
10
|
+
return !!row && row.provider === 'godaddy' && row.is_active === true && row.verified === true
|
|
11
|
+
&& (typeof row.group === 'number' ? row.group : row.group?.id) === group;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DomainRegisterExisting({ close, back, setBusyGate }: { close: () => void; back: () => void; setBusyGate: (busy: boolean) => void }) {
|
|
15
|
+
const queryClient = useQueryClient();
|
|
16
|
+
const [group, setGroup] = useState<DnsGroupChoice | null>(null);
|
|
17
|
+
const [credential, setCredential] = useState<DnsCredentialRow | null>(null);
|
|
18
|
+
const [name, setName] = useState('');
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [attempted, setAttempted] = useState(false);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const inFlight = useRef(false);
|
|
23
|
+
// Both picker remounting and this ref reject responses/events from old scope.
|
|
24
|
+
const scope = useRef<number | null>(null);
|
|
25
|
+
const changeGroup = (selected: DnsGroupChoice | null) => {
|
|
26
|
+
if (inFlight.current || attempted) return;
|
|
27
|
+
scope.current = selected?.id ?? null; setGroup(selected); setCredential(null); setError('');
|
|
28
|
+
};
|
|
29
|
+
const submit = async (event: React.FormEvent) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
if (inFlight.current || attempted) return;
|
|
32
|
+
if (!group || !usableRegistrationCredential(credential, group.id) || scope.current !== group.id) { setError('Choose an active, verified GoDaddy credential in the selected group'); return; }
|
|
33
|
+
let domain: string;
|
|
34
|
+
try { domain = normalizePurchaseDomain(name); } catch (reason) { setError(redactRegistrarError(reason)); return; }
|
|
35
|
+
inFlight.current = true; setBusy(true); setBusyGate(true); setError('');
|
|
36
|
+
// An unknown outcome is terminal for this mounted form: inspect inventory.
|
|
37
|
+
setAttempted(true);
|
|
38
|
+
try {
|
|
39
|
+
const saved = await registerExistingDomain({ group: group.id, domain, credential: credential.id });
|
|
40
|
+
await DomainModel.invalidate(queryClient); toast.success(`${saved.name} registered`); close();
|
|
41
|
+
} catch (reason) {
|
|
42
|
+
await DomainModel.invalidate(queryClient);
|
|
43
|
+
setError(`${redactRegistrarError(reason)} Refresh the domain inventory and review the provider account before starting another registration.`);
|
|
44
|
+
} finally { inFlight.current = false; setBusy(false); setBusyGate(false); }
|
|
45
|
+
};
|
|
46
|
+
const groupId = group?.id;
|
|
47
|
+
return <form className="modal-pad domain-onboarding" onSubmit={(event) => void submit(event)} noValidate>
|
|
48
|
+
<h2 className="modal-title">Register existing domain</h2>
|
|
49
|
+
<p className="dim">Connect a GoDaddy domain you already own. <OperationsHelp label="domain registration">The provider checks ownership of this exact name without charging or listing the provider account. Choose an authorized group and an active, verified GoDaddy credential belonging to it. If no credentials appear, link and verify one in Provider Credentials.</OperationsHelp></p>
|
|
50
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
51
|
+
<div className="dns-registrar-grid">
|
|
52
|
+
<CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={group} onChange={(_id, row) => changeGroup(row ?? null)} label="Authorized group" help="Choose the group that owns this domain." required maxItems={25} defaultParams={{ start: 0 }} disabled={busy || attempted} placeholder="Search eligible groups…" />
|
|
53
|
+
<label className="field"><span className="field-label">Domain name <em>*</em></span><input className="input" value={name} disabled={busy || attempted} onChange={(event) => setName(event.target.value)} placeholder="example.com" /></label>
|
|
54
|
+
{groupId ? <CollectionSelect<DnsCredentialRow> key={groupId} model={DnsCredentialModel} value={credential ? { ...credential } : null} label="GoDaddy credential" required maxItems={25} defaultParams={{ group: groupId, provider: 'godaddy', is_active: true, verified: true, start: 0 }} disabled={busy || attempted} placeholder="Search verified GoDaddy credentials…" help="An active, verified credential in the selected group." onChange={(_id, row) => {
|
|
55
|
+
if (scope.current !== groupId || inFlight.current || attempted) return;
|
|
56
|
+
if (!row) { setCredential(null); return; }
|
|
57
|
+
if (!usableRegistrationCredential(row, groupId)) { setCredential(null); setError('That credential is not usable in the selected group'); return; }
|
|
58
|
+
setCredential(row); setError('');
|
|
59
|
+
}} /> : <p className="dim">Choose an authorized group before selecting its GoDaddy credential.</p>}
|
|
60
|
+
</div>
|
|
61
|
+
<div className="modal-actions"><button className="btn" type="button" disabled={busy} onClick={attempted ? close : back}>{attempted ? 'Close and review inventory' : 'Back'}</button><button className="btn btn-primary" type="submit" disabled={busy || attempted || !group || !credential || !name.trim()}>{busy ? 'Checking ownership…' : 'Register existing'}</button></div>
|
|
62
|
+
</form>;
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { hasPermission, useMe } from '../../client/runtime';
|
|
4
|
+
import { fetchDomainOverview, saveDomainRegistrarSettings } from './registrar-api';
|
|
5
|
+
import { DNS_MANAGE_PERMISSIONS, DomainModel, type DomainRow } from './models';
|
|
6
|
+
import { modal } from '../../ui';
|
|
7
|
+
import { redactRegistrarError } from './purchase-data';
|
|
8
|
+
|
|
9
|
+
export function DomainRegistrarSettings({ domain, onBusyChange, close }: { domain: DomainRow; onBusyChange?: (busy: boolean) => void; close?: () => void }) {
|
|
10
|
+
const { data: me } = useMe(); const queryClient = useQueryClient();
|
|
11
|
+
const [autoRenew, setAutoRenew] = useState(domain.auto_renew === true);
|
|
12
|
+
const [privacy, setPrivacy] = useState(domain.privacy === true);
|
|
13
|
+
const [live, setLive] = useState(domain);
|
|
14
|
+
const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [saved, setSaved] = useState(false);
|
|
15
|
+
const [uncertain, setUncertain] = useState(false); const inFlight = useRef(false);
|
|
16
|
+
if (domain.provider !== 'route53' || !hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null)) return null;
|
|
17
|
+
const changed = autoRenew !== (live.auto_renew === true) || privacy !== (live.privacy === true);
|
|
18
|
+
const submit = async (event: React.FormEvent) => {
|
|
19
|
+
event.preventDefault(); if (inFlight.current || uncertain || !changed) return;
|
|
20
|
+
const changes: Partial<Pick<DomainRow, 'auto_renew' | 'privacy'>> = {};
|
|
21
|
+
if (autoRenew !== (live.auto_renew === true)) changes.auto_renew = autoRenew;
|
|
22
|
+
if (privacy !== (live.privacy === true)) changes.privacy = privacy;
|
|
23
|
+
inFlight.current = true; onBusyChange?.(true); setBusy(true); setError(''); setSaved(false);
|
|
24
|
+
let failed = false;
|
|
25
|
+
try { await saveDomainRegistrarSettings(domain.id, changes); }
|
|
26
|
+
catch (reason) { failed = true; setUncertain(true); setError(`${redactRegistrarError(reason)} The registrar outcome is uncertain. Inspect the registrar before another change.`); }
|
|
27
|
+
finally {
|
|
28
|
+
try {
|
|
29
|
+
const fresh = await fetchDomainOverview(domain.id);
|
|
30
|
+
queryClient.setQueryData(DomainModel.keys.one(domain.id), fresh);
|
|
31
|
+
setLive(fresh); setAutoRenew(fresh.auto_renew === true); setPrivacy(fresh.privacy === true);
|
|
32
|
+
if (!failed) setSaved(true);
|
|
33
|
+
} catch { setUncertain(true); setError('The authoritative domain could not be refreshed. Inspect the registrar and reopen this domain before making another change.'); }
|
|
34
|
+
await DomainModel.invalidate(queryClient);
|
|
35
|
+
inFlight.current = false; onBusyChange?.(false); setBusy(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
return <form className="modal-pad domains-registrar-settings" onSubmit={(event) => void submit(event)}>
|
|
39
|
+
<h2 className="modal-title">Registrar settings</h2><p className="dim">Route 53 only. A database save does not prove that the registrar applied the change.</p>
|
|
40
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
41
|
+
{live.last_error && <div className="form-alert" role="status"><b>Registrar warning</b><p>{redactRegistrarError(live.last_error)}</p><p>This may be historical. Inspect the registrar before assuming the setting was applied.</p></div>}
|
|
42
|
+
{saved && <p role="status">Domain refreshed after save. Check the registrar for application of these settings.</p>}
|
|
43
|
+
<label className="field"><span><input type="checkbox" checked={autoRenew} disabled={busy || uncertain} onChange={(event) => setAutoRenew(event.target.checked)} /> Auto-renew</span></label>
|
|
44
|
+
<label className="field"><span><input type="checkbox" checked={privacy} disabled={busy || uncertain} onChange={(event) => setPrivacy(event.target.checked)} /> WHOIS privacy</span></label>
|
|
45
|
+
<div className="modal-actions">{close && <button className="btn" type="button" disabled={busy} onClick={close}>Close</button>}<button className="btn btn-primary" type="submit" disabled={busy || uncertain || !changed}>{busy ? 'Saving and refreshing…' : 'Save registrar settings'}</button></div>
|
|
46
|
+
</form>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function showDomainRegistrarSettings(domain: DomainRow): Promise<unknown> {
|
|
50
|
+
let busy = false;
|
|
51
|
+
return modal.open((close) => <DomainRegistrarSettings domain={domain} close={() => close(null)} onBusyChange={(value) => { busy = value; }} />, { size: 'md', canDismiss: () => !busy });
|
|
52
|
+
}
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
2
3
|
import { Badge, ModelTable, fmt, modal, type Column, type FilterDef } from '../../ui';
|
|
3
4
|
import { DomainDetail } from './DomainDetail';
|
|
4
|
-
import {
|
|
5
|
+
import { hasPermission, useMe } from '../../client/runtime';
|
|
6
|
+
import { DomainModel, DNS_MANAGE_PERMISSIONS, type DomainRow } from './models';
|
|
5
7
|
import { providerLabel } from './data';
|
|
8
|
+
import { domainRecordsHref, domainScopeLabel, supportsDomainRecords } from './domain-navigation';
|
|
6
9
|
|
|
7
|
-
const columns: Column<DomainRow>[] = [
|
|
8
|
-
{ key: 'name', label: 'Domain', sortable: true, hideable: false, render: (row) => <code>{row.name}</code> },
|
|
9
|
-
{ key: 'provider', label: 'Provider', sortable: true, render: (row) => providerLabel(row.provider) },
|
|
10
|
-
{ key: 'status', label: 'Status', sortable: true, render: (row) => <Badge tone={fmt.inferTone(row.status)}>{row.status}</Badge> },
|
|
11
|
-
{ key: 'group', label: 'Group', render: (row) => typeof row.group === 'object' ? row.group?.name ?? 'Platform' : row.group ?? 'Platform' },
|
|
12
|
-
{ key: 'expires', label: 'Expires', sortable: true, render: (row) => fmt.date(row.expires) },
|
|
13
|
-
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
|
|
14
|
-
];
|
|
15
10
|
const filters: FilterDef[] = [
|
|
16
11
|
{ key: 'provider', label: 'Provider', type: 'select', options: [{ value: 'route53', label: 'Route 53' }, { value: 'godaddy', label: 'GoDaddy' }, { value: 'mojo', label: 'Mojo certificate-only' }] },
|
|
17
12
|
{ key: 'status', label: 'Status', type: 'select', options: ['pending', 'registering', 'active'].map((value) => ({ value, label: value })) },
|
|
@@ -19,9 +14,27 @@ const filters: FilterDef[] = [
|
|
|
19
14
|
|
|
20
15
|
export function DomainsPage() {
|
|
21
16
|
const queryClient = useQueryClient();
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const { data: me } = useMe();
|
|
18
|
+
const location = useLocation(); const navigate = useNavigate();
|
|
19
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
20
|
+
const start = async (initialMode: 'register' | 'adopt' | 'purchase') => {
|
|
21
|
+
const { showDomainOnboarding } = await import('./DomainOnboardingDialog');
|
|
22
|
+
await showDomainOnboarding({ initialMode });
|
|
23
|
+
};
|
|
24
|
+
const open = (row: DomainRow) => {
|
|
25
|
+
void DomainModel.fetchOne(queryClient, row.id).catch(() => undefined);
|
|
26
|
+
void modal.detail((close) => <DomainDetail id={row.id} onClose={() => close(null)} />);
|
|
25
27
|
};
|
|
26
|
-
|
|
28
|
+
const columns: Column<DomainRow>[] = [
|
|
29
|
+
{ key: 'name', label: 'Domain', sortable: true, hideable: false, render: (row) => <span className="ops-cell"><strong>{row.name}</strong><small>{providerLabel(row.provider)}</small></span> },
|
|
30
|
+
{ key: 'status', label: 'Status', sortable: true, render: (row) => <Badge tone={fmt.inferTone(row.status)}>{row.status}</Badge> },
|
|
31
|
+
{ key: 'expires', label: 'Expires', sortable: true, render: (row) => fmt.date(row.expires) },
|
|
32
|
+
{ key: 'group', label: 'Scope', render: (row) => <span className="domains-scope">{domainScopeLabel(row.group)}</span> },
|
|
33
|
+
{ key: 'records', label: 'DNS Records', render: (row) => supportsDomainRecords(row.provider) ? <button className="btn btn-sm" type="button" onClick={(event) => { event.stopPropagation(); navigate(domainRecordsHref(location.pathname, row.id)); }}>DNS Records <i className="bi bi-arrow-right" /></button> : <span className="dim">{row.provider === 'mojo' ? 'Certificate-only' : 'Provider unavailable'}</span> },
|
|
34
|
+
];
|
|
35
|
+
return <div className="domains-page">
|
|
36
|
+
<header className="domains-header"><div><div className="eyebrow">Network & hosting</div><h1>Domains</h1><p className="dim">Manage the names this installation controls.</p></div>{canManage && <div className="domains-actions"><button className="btn" onClick={() => void start('register')}>Register existing</button>{me?.is_superuser === true && <button className="btn" onClick={() => void start('adopt')}>Adopt</button>}<button className="btn btn-primary" onClick={() => void start('purchase')}>Buy domain</button></div>}</header>
|
|
37
|
+
<ModelTable<DomainRow> model={DomainModel} title="Managed domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={open} />
|
|
38
|
+
{canManage && <details className="domains-advanced"><summary>Advanced</summary><p className="dim">Connect external domains to shared wildcard validation, or review controller allocations and trust.</p><button className="btn" onClick={() => void import('./SharedValidationDialog').then(({ openSharedValidation }) => openSharedValidation())}>Shared validation setup</button></details>}
|
|
39
|
+
</div>;
|
|
27
40
|
}
|