portal-mojo 0.1.0
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/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { modal, toast } from '../../ui';
|
|
3
|
+
import { useCan } from '../../client';
|
|
4
|
+
import { deleteDnsRecordSet, useDnsCapabilities, useDnsRecordCoordinator, useDnsRecords } from './api';
|
|
5
|
+
import { DNS_RECORD_TYPES, diffRecordSet, isSpentAcmeChallenge, recordKey, recordWarnings, relativeRecordName, snapshotRecordOwner } from './dns-data';
|
|
6
|
+
import { DnsRecordEditor } from './DnsRecordEditor';
|
|
7
|
+
import { DNS_MANAGE_PERMISSIONS, type DnsRecordRow, type DomainRow } from './models';
|
|
8
|
+
|
|
9
|
+
export interface DnsRecordsPanelProps { domain: DomainRow; recordType?: string; onRecordTypeChange?: (type: string) => void }
|
|
10
|
+
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
11
|
+
const groupId = (domain: DomainRow): number | null => typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
|
|
12
|
+
|
|
13
|
+
export function DnsRecordsPanel({ domain, recordType = '', onRecordTypeChange }: DnsRecordsPanelProps) {
|
|
14
|
+
const active = domain.status === 'active';
|
|
15
|
+
const generalProvider = ['route53', 'godaddy'].includes(domain.provider.toLowerCase());
|
|
16
|
+
const credential = typeof domain.credential === 'object' ? domain.credential : null;
|
|
17
|
+
const credentialUsable = domain.provider !== 'godaddy' || (!!credential && credential.is_active && credential.verified);
|
|
18
|
+
const eligible = active && generalProvider && credentialUsable;
|
|
19
|
+
const capabilities = useDnsCapabilities(groupId(domain), { enabled: eligible });
|
|
20
|
+
const records = useDnsRecords(domain.id, eligible && !capabilities.isError);
|
|
21
|
+
const { can: canManage } = useCan(DNS_MANAGE_PERMISSIONS);
|
|
22
|
+
const coordinate = useDnsRecordCoordinator(domain.id);
|
|
23
|
+
const providerKnown = capabilities.data?.providers.some((provider) => provider.name.toLowerCase() === domain.provider.toLowerCase()) ?? false;
|
|
24
|
+
const rows = records.data?.records ?? [];
|
|
25
|
+
const knownObserved = [...new Set(rows.map((record) => record.type.toUpperCase()))];
|
|
26
|
+
const choices = [...DNS_RECORD_TYPES.filter((type) => capabilities.data?.allowed_record_types.includes(type) || knownObserved.includes(type)), ...knownObserved.filter((type) => !DNS_RECORD_TYPES.includes(type as typeof DNS_RECORD_TYPES[number]))];
|
|
27
|
+
const visible = recordType ? rows.filter((record) => record.type.toUpperCase() === recordType.toUpperCase()) : rows;
|
|
28
|
+
|
|
29
|
+
if (!active) return <Blocked title="DNS changes are unavailable">This domain is {domain.status}. Live records are available only after activation completes.</Blocked>;
|
|
30
|
+
if (domain.provider === 'mojo') return <Blocked title="Certificate-only domain">Mojo domains mark delegated certificate coverage and do not support general DNS record management.</Blocked>;
|
|
31
|
+
if (!generalProvider) return <Blocked title="Unsupported DNS provider">The {domain.provider || 'unknown'} provider does not expose general DNS through this administration surface.</Blocked>;
|
|
32
|
+
if (!credentialUsable) return <Blocked title="Provider credential unavailable">This GoDaddy domain needs an active, verified provider credential. {credential?.is_active === false ? 'The linked credential is inactive.' : credential?.verified === false ? 'The linked credential is unverified.' : 'No usable credential is linked.'}</Blocked>;
|
|
33
|
+
if (capabilities.isLoading) return <div className="dns-state dim">Loading DNS capabilities…</div>;
|
|
34
|
+
if (capabilities.isError) return <Retry error={capabilities.error} retry={() => void capabilities.refetch()} />;
|
|
35
|
+
if (!providerKnown) return <Blocked title="Provider unavailable">This deployment did not advertise {domain.provider} in its DNS capabilities.</Blocked>;
|
|
36
|
+
if (records.isLoading) return <div className="dns-state dim">Reading the live provider zone…</div>;
|
|
37
|
+
if (records.isError) return <Retry error={records.error} retry={() => void records.refetch()} />;
|
|
38
|
+
|
|
39
|
+
const openEditor = (record?: DnsRecordRow) => {
|
|
40
|
+
void modal.open<boolean>((close) => <DnsRecordEditor domain={domain} capabilities={capabilities.data!} records={rows} record={record} onClose={(saved) => close(saved)} />, { size: 'lg' });
|
|
41
|
+
};
|
|
42
|
+
const remove = async (record: DnsRecordRow) => {
|
|
43
|
+
const opening = snapshotRecordOwner(rows, record.type, record.name);
|
|
44
|
+
const diff = diffRecordSet(record, null);
|
|
45
|
+
const warnings = recordWarnings({ provider: domain.provider, type: record.type, name: record.name, values: [], ttl: record.ttl, zone: domain.name, existingRecords: rows, before: record.record_values, deleting: true });
|
|
46
|
+
const ok = await modal.confirm({ title: 'Delete whole record set?', danger: true, confirmText: 'Delete set', message: <div className="dns-confirm"><p>Delete <code>{record.type} {record.name}</code>?</p><p>Every value will be removed:</p><ul>{diff.removed.map((value, index) => <li key={`${value}-${index}`}><code>{value}</code></li>)}</ul>{warnings.map((warning) => <p className="dns-warning" key={warning}>{warning}</p>)}</div> });
|
|
47
|
+
if (!ok) return;
|
|
48
|
+
try {
|
|
49
|
+
await coordinate({ opening, intended: null, write: () => deleteDnsRecordSet(domain.id, { type: record.type, name: record.name }) });
|
|
50
|
+
toast.success('DNS record set deleted');
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const ambiguous = error != null && typeof error === 'object' && 'dnsLiveStateApplied' in error;
|
|
53
|
+
toast.error(`${errorMessage(error)}${ambiguous ? ' The refreshed live zone shows that the set is gone.' : ''}`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return <div className="dns-records-panel">
|
|
58
|
+
<div className="dns-record-toolbar"><label className="field dns-record-filter"><span className="field-label">Record type</span><select className="input" value={recordType} onChange={(event) => onRecordTypeChange?.(event.target.value)}><option value="">All record types</option>{choices.map((type) => <option key={type} value={type}>{type}</option>)}</select></label><div className="dns-toolbar-actions"><button type="button" className="btn" onClick={() => void records.refetch()}><i className={`bi ${records.isFetching ? 'bi-arrow-repeat spin' : 'bi-arrow-clockwise'}`} /> Refresh</button>{canManage && <button type="button" className="btn btn-primary" onClick={() => openEditor()}><i className="bi bi-plus-lg" /> Add record set</button>}</div></div>
|
|
59
|
+
{domain.provider === 'godaddy' && <div className="dns-provider-note">GoDaddy DNS is managed here. Registrar purchase, renewal, privacy, and WHOIS remain in the provider account; refreshed TTLs have a 600-second floor and whole-set deletion is unavailable.</div>}
|
|
60
|
+
{visible.length === 0 ? <div className="dns-state dim">{rows.length ? 'No records match this type.' : 'The provider returned an empty zone.'}</div> : <div className="dns-table-wrap"><table className="tbl dns-record-table"><thead><tr><th>Type</th><th>Name</th><th>Values</th><th>TTL</th><th><span className="sr-only">Actions</span></th></tr></thead><tbody>{visible.map((record) => {
|
|
61
|
+
const type = record.type.toUpperCase();
|
|
62
|
+
const supported = !!capabilities.data?.allowed_record_types.map((entry) => entry.toUpperCase()).includes(type) && DNS_RECORD_TYPES.includes(type as typeof DNS_RECORD_TYPES[number]);
|
|
63
|
+
const spent = isSpentAcmeChallenge(domain.provider, record);
|
|
64
|
+
const mayDelete = canManage && supported && !spent && domain.provider === 'route53' && !(['NS', 'SOA'].includes(type) && relativeRecordName(record.name, domain.name) === '@');
|
|
65
|
+
return <tr key={recordKey(record)}><td><code>{type}</code>{spent && <span className="chip chip-muted dns-spent">spent</span>}</td><td><code>{relativeRecordName(record.name, domain.name)}</code></td><td><ul className="dns-record-values">{record.record_values.map((value, index) => <li key={`${value}-${index}`}><code>{value}</code></li>)}</ul></td><td>{record.ttl}s</td><td><div className="dns-row-actions">{canManage && supported && !spent && <button type="button" className="btn-icon" title="Edit complete set" onClick={() => openEditor(record)}><i className="bi bi-pencil" /></button>}{mayDelete && <button type="button" className="btn-icon text-bad" title="Delete complete set" onClick={() => void remove(record)}><i className="bi bi-trash" /></button>}</div></td></tr>;
|
|
66
|
+
})}</tbody></table></div>}
|
|
67
|
+
</div>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Blocked({ title, children }: { title: string; children: ReactNode }) { return <div className="dns-blocked"><i className="bi bi-shield-lock" /><div><strong>{title}</strong><p>{children}</p></div></div>; }
|
|
71
|
+
function Retry({ error, retry }: { error: unknown; retry: () => void }) { return <div className="dns-error" role="alert"><strong>Could not read the live zone.</strong><p>{errorMessage(error)}</p><button type="button" className="btn" onClick={retry}>Retry</button></div>; }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { hasPermission, useMe } from '../../client';
|
|
3
|
+
import { Badge, fmt, modal, toast } from '../../ui';
|
|
4
|
+
import { CertificateDetail } from './CertificateDetail';
|
|
5
|
+
import { CertificateLifecyclePoller } from './CertificateLifecyclePoller';
|
|
6
|
+
import { CertificateRequestDialog } from './CertificateRequestDialog';
|
|
7
|
+
import { canInspectHouseCertificate, certificateStatusTone } from './certificate-data';
|
|
8
|
+
import { CertificateModel, DNS_MANAGE_PERMISSIONS, type CertificateRow, type DomainRow } from './models';
|
|
9
|
+
|
|
10
|
+
export function DomainCertificatesSection({ domain }: { domain: DomainRow }) {
|
|
11
|
+
const queryClient = useQueryClient();
|
|
12
|
+
const { data: me } = useMe();
|
|
13
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
14
|
+
const query = CertificateModel.useList({ domain: domain.id, sort: '-created', start: 0, size: 25 });
|
|
15
|
+
const open = async (row: CertificateRow) => {
|
|
16
|
+
if (!canInspectHouseCertificate(domain, me?.is_superuser === true)) {
|
|
17
|
+
toast.error('This certificate cannot be inspected with the current session.');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
await CertificateModel.fetchOne(queryClient, row.id);
|
|
22
|
+
await modal.detail((close) => <CertificateDetail id={row.id} domain={domain} onClose={() => close(null)} />);
|
|
23
|
+
} catch (reason) {
|
|
24
|
+
toast.error(reason instanceof Error ? reason.message : 'Certificate detail is unavailable');
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const request = async () => {
|
|
28
|
+
await modal.open((close) => <CertificateRequestDialog close={close} domain={domain} />, { size: 'md' });
|
|
29
|
+
};
|
|
30
|
+
return <div className="dns-domain-certificates">
|
|
31
|
+
<div className="dns-cert-section-head"><div><div className="eyebrow">Certificates</div><p className="dim">Issued certificate custody and ACME renewal state for this domain.</p></div>{canManage && <button className="btn btn-primary" onClick={() => void request()}><i className="bi bi-patch-plus" /> Request</button>}</div>
|
|
32
|
+
{query.isPending ? <div className="dns-state dim">Loading certificates…</div> : query.error ? <div className="dns-errors">{query.error.message}</div> : !query.data?.rows.length ? <div className="dns-state dim">No certificates issued for this domain.</div> : <div className="dns-cert-list">
|
|
33
|
+
{query.data.rows.map((row) => <button type="button" className="dns-cert-card" key={row.id} onClick={() => void open(row)}>
|
|
34
|
+
<span><code>{row.common_name}</code><small>{row.sans.length} SAN{row.sans.length === 1 ? '' : 's'} · expires {fmt.date(row.not_after)}</small></span>
|
|
35
|
+
<Badge tone={certificateStatusTone(row.status)}>{row.status}</Badge>
|
|
36
|
+
</button>)}
|
|
37
|
+
</div>}
|
|
38
|
+
<CertificateLifecyclePoller domain={domain.id} />
|
|
39
|
+
</div>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
2
|
+
import { hasPermission, useMe } from '../../client';
|
|
3
|
+
import { DetailView, Eyebrow, FlatRow, fmt } from '../../ui';
|
|
4
|
+
import { useDnsCapabilities } from './api';
|
|
5
|
+
import { providerLabel } from './data';
|
|
6
|
+
import { useDnsDomainLinks } from './domain-links';
|
|
7
|
+
import { DnsRecordsPanel } from './DnsRecordsPanel';
|
|
8
|
+
import { DomainCertificatesSection } from './DomainCertificatesSection';
|
|
9
|
+
import { DomainModel } from './models';
|
|
10
|
+
|
|
11
|
+
export function DomainDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
12
|
+
const query = DomainModel.useOne(id);
|
|
13
|
+
const { data: me } = useMe();
|
|
14
|
+
const links = useDnsDomainLinks();
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const location = useLocation();
|
|
17
|
+
const domain = query.data;
|
|
18
|
+
const group = typeof domain?.group === 'number' ? domain.group : domain?.group?.id ?? null;
|
|
19
|
+
const capabilities = useDnsCapabilities(group, { enabled: !!domain });
|
|
20
|
+
if (query.isPending) return <div className="modal-pad dim">Loading domain…</div>;
|
|
21
|
+
if (!domain || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Domain not found'}</div>;
|
|
22
|
+
const availableLinks = capabilities.data ? links.filter((link) => (!link.permissions || hasPermission(me, link.permissions)) && (!link.when || link.when(domain, capabilities.data!))) : [];
|
|
23
|
+
const navigateLink = (route: string) => {
|
|
24
|
+
const marker = location.pathname.indexOf('/dns/');
|
|
25
|
+
const mount = marker >= 0 ? location.pathname.slice(0, marker) : '';
|
|
26
|
+
onClose();
|
|
27
|
+
navigate(`${mount}/dns/${route.replace(/^\/?dns\//, '').replace(/^\//, '')}`);
|
|
28
|
+
};
|
|
29
|
+
const groupLabel = typeof domain.group === 'object' ? domain.group?.name ?? 'Platform' : domain.group ?? 'Platform';
|
|
30
|
+
const credential = typeof domain.credential === 'object' ? domain.credential : null;
|
|
31
|
+
return <DetailView
|
|
32
|
+
icon="bi-globe2" title={domain.name} subtitle={`${providerLabel(domain.provider)}${domain.hosted_zone_id ? ` · ${domain.hosted_zone_id}` : ''}`}
|
|
33
|
+
chips={[{ text: domain.status, tone: fmt.inferTone(domain.status) }, ...(domain.verified ? [{ text: 'Verified', tone: 'success' as const }] : [])]}
|
|
34
|
+
sections={[
|
|
35
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-domain-overview"><Eyebrow>Domain</Eyebrow><FlatRow label="Name"><code>{domain.name}</code></FlatRow><FlatRow label="Provider">{providerLabel(domain.provider)}</FlatRow><FlatRow label="Status">{domain.status}</FlatRow><FlatRow label="Group">{groupLabel}</FlatRow>{domain.hosted_zone_id && <FlatRow label="Hosted zone"><code>{domain.hosted_zone_id}</code></FlatRow>}<FlatRow label="Registered">{fmt.date(domain.registered_on)}</FlatRow><FlatRow label="Expires">{fmt.date(domain.expires)}</FlatRow><FlatRow label="Verified">{domain.verified ? 'Yes' : 'No'}</FlatRow><FlatRow label="Auto-renew">{domain.auto_renew ? 'Enabled' : 'Disabled'}</FlatRow><FlatRow label="WHOIS privacy">{domain.privacy ? 'Enabled' : 'Disabled'}</FlatRow><FlatRow label="Credential">{credential ? `${credential.name} · ${credential.is_active && credential.verified ? 'usable' : 'unavailable'}` : '—'}</FlatRow>{domain.last_error && <div className="dns-error">{domain.last_error}</div>}{domain.provider === 'godaddy' && <div className="dns-provider-note">DNS is managed here; purchase, renewal, privacy, and WHOIS remain in the GoDaddy account.</div>}{domain.provider === 'mojo' && <div className="dns-blocked">Mojo is certificate-only and cannot use generic DNS CRUD.</div>}{availableLinks.length > 0 && <><Eyebrow>Related</Eyebrow><div className="dns-domain-links">{availableLinks.map((link) => <button className="btn" type="button" key={link.key} onClick={() => navigateLink(typeof link.route === 'function' ? link.route(domain) : link.route)}><i className={`bi ${link.icon}`} /> {link.label}</button>)}</div></>}</div> },
|
|
36
|
+
{ key: 'records', label: 'DNS Records', icon: 'bi-list-columns', render: () => <DnsRecordsPanel domain={domain} /> },
|
|
37
|
+
{ key: 'certificates', label: 'Certificates', icon: 'bi-patch-check', render: () => <DomainCertificatesSection domain={domain} /> },
|
|
38
|
+
]}
|
|
39
|
+
initialSection="overview" onClose={onClose}
|
|
40
|
+
/>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useMe } from '../../client';
|
|
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';
|
|
9
|
+
|
|
10
|
+
function DomainOnboardingDialog({ close, setBusyGate }: { close: () => void; setBusyGate: (busy: boolean) => void }) {
|
|
11
|
+
const { data: me } = useMe(); const queryClient = useQueryClient();
|
|
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> {
|
|
25
|
+
let busy = false;
|
|
26
|
+
return modal.open((close) => <DomainOnboardingDialog close={() => close(null)} setBusyGate={(value) => { busy = value; }} />, { size: 'lg', canDismiss: () => !busy });
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Badge, FlatRow, fmt } from '../../ui';
|
|
3
|
+
import { fetchPurchaseDetail } from './api';
|
|
4
|
+
import { redactRegistrarError } from './purchase-data';
|
|
5
|
+
import type { DomainPurchaseRow } from './models';
|
|
6
|
+
|
|
7
|
+
export function DomainPurchaseDetail({ id, close }: { id: number; close: () => void }) {
|
|
8
|
+
const [row, setRow] = useState<DomainPurchaseRow | null>(null); const [error, setError] = useState('');
|
|
9
|
+
useEffect(() => { let current = true; void fetchPurchaseDetail(id).then((value) => { if (current) setRow(value); }, (reason) => { if (current) setError(redactRegistrarError(reason)); }); return () => { current = false; setRow(null); setError(''); }; }, [id]);
|
|
10
|
+
return <div className="modal-pad"><div className="eyebrow">Domain purchase ledger</div><h2 className="modal-title">{row?.domain_name ?? `Purchase #${id}`}</h2>{!row && !error && <p className="dim">Loading ledger detail…</p>}{error && <div className="form-alert">{error}</div>}{row && <><div className="chip-row"><Badge tone={row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'}>{row.status}</Badge></div><FlatRow label="Purchase"><code>#{row.id}</code></FlatRow><FlatRow label="Kind">{row.kind}</FlatRow><FlatRow label="Price">{row.price ?? '—'} {row.currency}</FlatRow><FlatRow label="Cost">{row.cost ?? '—'} {row.currency}</FlatRow><FlatRow label="Years">{row.years}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Quote expiry">{fmt.datetime(row.quote_expires)}</FlatRow><FlatRow label="Operation"><code>{row.operation_id ?? 'Not recorded'}</code></FlatRow>{row.error && <div className="form-alert">{redactRegistrarError(row.error)}</div>}</>}<div className="modal-actions"><button className="btn" onClick={close}>Close</button></div></div>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CollectionSelect, FormWizard, Badge, fmt, type FormWizardSection } from '../../ui';
|
|
4
|
+
import { DNS_GROUP_CHOICE_ENDPOINT, fetchDnsCapabilities, fetchPurchaseDetail, purchaseDomain, quoteDomain, searchRegistrarBatch } from './api';
|
|
5
|
+
import { DomainModel, DomainPurchaseModel, type DnsGroupChoice, type DomainPurchaseRow, type RegistrarSearchRow } from './models';
|
|
6
|
+
import { normalizePurchaseDomain, normalizeTypedTlds, pollPurchaseLedger, PURCHASE_MAY_HAVE_MOVED, quoteIdentity, quoteMatches, redactRegistrarError, searchAvailabilityLabel, type QuoteIdentity } from './purchase-data';
|
|
7
|
+
|
|
8
|
+
interface TransientQuote { readonly token: string; readonly identity: Readonly<QuoteIdentity> }
|
|
9
|
+
|
|
10
|
+
export function DomainPurchaseWizard({ onDone, onBusyChange }: { onDone?: () => void; onBusyChange?: (busy: boolean) => void }) {
|
|
11
|
+
const queryClient = useQueryClient();
|
|
12
|
+
const [group, setGroup] = useState<DnsGroupChoice | null>(null);
|
|
13
|
+
const [base, setBase] = useState('');
|
|
14
|
+
const [tlds, setTlds] = useState('com, net, org');
|
|
15
|
+
const [results, setResults] = useState<RegistrarSearchRow[]>([]);
|
|
16
|
+
const [selected, setSelected] = useState<RegistrarSearchRow | null>(null);
|
|
17
|
+
const [years, setYears] = useState(1);
|
|
18
|
+
const [typedDomain, setTypedDomain] = useState('');
|
|
19
|
+
const [receipt, setReceipt] = useState<DomainPurchaseRow | null>(null);
|
|
20
|
+
const [terminalMessage, setTerminalMessage] = useState('');
|
|
21
|
+
const quoteRef = useRef<TransientQuote | null>(null);
|
|
22
|
+
const [quoted, setQuoted] = useState<QuoteIdentity | null>(null);
|
|
23
|
+
const attempted = useRef(false);
|
|
24
|
+
const inFlight = useRef(false);
|
|
25
|
+
const generation = useRef(0);
|
|
26
|
+
const clearQuote = () => { quoteRef.current = null; setQuoted(null); setTypedDomain(''); };
|
|
27
|
+
|
|
28
|
+
useEffect(() => () => { generation.current += 1; quoteRef.current = null; }, []);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!quoted) return;
|
|
31
|
+
const delay = Math.max(0, Math.min(2_147_483_647, quoted.expires * 1000 - Date.now()));
|
|
32
|
+
const timer = window.setTimeout(() => { generation.current += 1; quoteRef.current = null; setQuoted(null); setTypedDomain(''); }, delay);
|
|
33
|
+
return () => window.clearTimeout(timer);
|
|
34
|
+
}, [quoted]);
|
|
35
|
+
const changeUpstream = (change: () => void) => { if (attempted.current) return; generation.current += 1; clearQuote(); setResults([]); setSelected(null); change(); };
|
|
36
|
+
|
|
37
|
+
const sections = useMemo<FormWizardSection[]>(() => [
|
|
38
|
+
{
|
|
39
|
+
key: 'search', label: 'Search', fields: [], description: 'Choose one authorized group, then enter a base label and your own TLD list. The backend—not this client—is the supported-TLD authority.',
|
|
40
|
+
content: <div className="dns-registrar-grid">
|
|
41
|
+
<CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={group} onChange={(_id, row) => changeUpstream(() => setGroup(row ?? null))} label="Purchasing group" required placeholder="Search eligible groups…" maxItems={25} defaultParams={{ start: 0 }} />
|
|
42
|
+
<label className="field"><span className="field-label">Base name <em>*</em></span><input className="input" value={base} disabled={attempted.current} onChange={(event) => changeUpstream(() => setBase(event.target.value))} placeholder="example" /></label>
|
|
43
|
+
<label className="field"><span className="field-label">TLDs <em>*</em></span><input className="input" value={tlds} disabled={attempted.current} onChange={(event) => changeUpstream(() => setTlds(event.target.value))} placeholder="com, net" /><span className="field-help">Typed values only; no client-maintained supported list.</span></label>
|
|
44
|
+
</div>,
|
|
45
|
+
onNext: async () => {
|
|
46
|
+
if (!group) throw new Error('Choose a purchasing group');
|
|
47
|
+
const caps = await fetchDnsCapabilities(group.id);
|
|
48
|
+
if (!caps.purchase_enabled) throw new Error('Domain purchasing is disabled on this deployment');
|
|
49
|
+
if (!caps.registrant_contact_configured) throw new Error('This group has no usable registrant contact');
|
|
50
|
+
const label = base.trim().toLowerCase().replace(/^\.+|\.+$/g, '');
|
|
51
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) throw new Error('Enter one valid base label');
|
|
52
|
+
const typed = normalizeTypedTlds(tlds, caps.search_batch_limit);
|
|
53
|
+
const answer = await searchRegistrarBatch({ domain: label, tlds: typed });
|
|
54
|
+
if (!answer.results.length) throw new Error('The registry returned no search results');
|
|
55
|
+
setResults(answer.results); setSelected(answer.results.find((row) => row.available === true) ?? null);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'quote', label: 'Choose & quote', fields: [], description: 'Availability is tri-state. Only an explicit Available answer can be quoted.',
|
|
60
|
+
content: <div className="dns-search-results">
|
|
61
|
+
{results.map((row) => <label key={row.name} className={`dns-search-result${selected?.name === row.name ? ' is-selected' : ''}`}>
|
|
62
|
+
<input type="radio" name="domain-result" checked={selected?.name === row.name} disabled={row.available !== true || attempted.current} onChange={() => { clearQuote(); setSelected(row); }} />
|
|
63
|
+
<span><code>{row.name}</code><small>{row.reason ?? `${row.price ?? '—'} ${row.currency ?? ''}`}</small></span>
|
|
64
|
+
<Badge tone={row.available === true ? 'success' : row.available === false ? 'danger' : 'warning'}>{searchAvailabilityLabel(row)}</Badge>
|
|
65
|
+
</label>)}
|
|
66
|
+
<label className="field"><span className="field-label">Registration years</span><select className="input" value={years} disabled={attempted.current} onChange={(event) => { clearQuote(); setYears(Number(event.target.value)); }}>{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => <option key={value} value={value}>{value}</option>)}</select></label>
|
|
67
|
+
</div>,
|
|
68
|
+
onNext: async () => {
|
|
69
|
+
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 });
|
|
71
|
+
const identity = quoteIdentity(quote, group.id);
|
|
72
|
+
if (!quoteMatches(identity, { group: group.id, domain: selected.name, years, price: selected.price, currency: selected.currency })) throw new Error('Quote did not match the selected search result');
|
|
73
|
+
quoteRef.current = Object.freeze({ token: quote.token, identity: Object.freeze(identity) }); setQuoted(identity);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: 'confirm', label: 'Confirm purchase', fields: [], description: 'This is a real-money operation. The exact quote tuple is immutable and the one-use token exists only in this mounted wizard.',
|
|
78
|
+
content: quoted && <div className="dns-quote-card">
|
|
79
|
+
<div><span>Domain</span><code>{quoted.domain}</code></div><div><span>Term</span><b>{quoted.years} year{quoted.years === 1 ? '' : 's'}</b></div>
|
|
80
|
+
<div><span>Exact price</span><b>{quoted.price} {quoted.currency}</b></div><div><span>Expires</span><b>{fmt.datetime(quoted.expires)}</b></div>
|
|
81
|
+
<label className="field"><span className="field-label">Type {quoted.domain} to confirm <em>*</em></span><input className="input" value={typedDomain} disabled={attempted.current} autoComplete="off" onChange={(event) => setTypedDomain(event.target.value)} /></label>
|
|
82
|
+
</div>,
|
|
83
|
+
onNext: async () => {
|
|
84
|
+
if (inFlight.current || attempted.current) throw new Error('This quote has already been attempted');
|
|
85
|
+
const transient = quoteRef.current;
|
|
86
|
+
if (!transient || !group || !selected || selected.price == null || !selected.currency) throw new Error('The quote is missing or expired. Start over.');
|
|
87
|
+
if (!quoteMatches(transient.identity, { group: group.id, domain: selected.name, years, price: selected.price, currency: selected.currency })) { clearQuote(); throw new Error('The quote expired or its inputs changed. Start over.'); }
|
|
88
|
+
if (typedDomain.trim().toLowerCase() !== transient.identity.domain) throw new Error(`Type ${transient.identity.domain} exactly`);
|
|
89
|
+
inFlight.current = true; attempted.current = true; const accepted = ++generation.current;
|
|
90
|
+
const token = transient.token; quoteRef.current = null;
|
|
91
|
+
try {
|
|
92
|
+
const acknowledgement = await purchaseDomain({ group: transient.identity.group, purchase: transient.identity.purchase, confirm_token: token });
|
|
93
|
+
const settled = await pollPurchaseLedger({ purchase: acknowledgement.purchase, isCurrent: () => generation.current === accepted, fetch: fetchPurchaseDetail });
|
|
94
|
+
if (!settled) setTerminalMessage(`Purchase acknowledgement was received, but bounded polling did not settle. ${PURCHASE_MAY_HAVE_MOVED}`);
|
|
95
|
+
else { setReceipt(settled); setTerminalMessage(settled.status === 'completed' ? 'Registration completed.' : `${redactRegistrarError(settled.error)} ${PURCHASE_MAY_HAVE_MOVED}`); }
|
|
96
|
+
} catch (error) {
|
|
97
|
+
setTerminalMessage(`${redactRegistrarError(error)} ${PURCHASE_MAY_HAVE_MOVED}`);
|
|
98
|
+
try { setReceipt(await fetchPurchaseDetail(transient.identity.purchase)); } catch { /* detail stays local and optional */ }
|
|
99
|
+
} finally {
|
|
100
|
+
await Promise.allSettled([DomainPurchaseModel.invalidate(queryClient), DomainModel.invalidate(queryClient)]);
|
|
101
|
+
inFlight.current = false;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: 'receipt', label: 'Ledger', fields: [], terminal: true,
|
|
107
|
+
content: <div className="dns-purchase-receipt"><h3>{receipt?.status === 'completed' ? 'Purchase complete' : 'Check the durable ledger'}</h3><p className={receipt?.status === 'completed' ? '' : 'form-alert'}>{terminalMessage}</p>{receipt && <><div><span>Purchase</span><code>#{receipt.id}</code></div><div><span>Domain</span><code>{receipt.domain_name}</code></div><div><span>Status</span><Badge tone={receipt.status === 'completed' ? 'success' : receipt.status === 'failed' ? 'danger' : 'warning'}>{receipt.status}</Badge></div></>}<div className="modal-actions"><button className="btn btn-primary" onClick={onDone}>Close</button></div></div>,
|
|
108
|
+
},
|
|
109
|
+
], [base, group, onDone, queryClient, quoted, receipt, results, selected, terminalMessage, tlds, typedDomain, years]);
|
|
110
|
+
|
|
111
|
+
return <FormWizard mode="wizard" sections={sections} onBusyChange={onBusyChange} onCancel={onDone} onFinish={() => undefined} nextText="Continue" />;
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useMe, hasPermission } from '../../client';
|
|
2
|
+
import { Badge, ModelTable, modal, type Column, type FilterDef } from '../../ui';
|
|
3
|
+
import { DomainPurchaseDetail } from './DomainPurchaseDetail';
|
|
4
|
+
import { showDomainOnboarding } from './DomainOnboardingDialog';
|
|
5
|
+
import { DNS_MANAGE_PERMISSIONS, DomainPurchaseModel, type DomainPurchaseRow } from './models';
|
|
6
|
+
|
|
7
|
+
const columns: Column<DomainPurchaseRow>[] = [
|
|
8
|
+
{ key: 'domain_name', label: 'Domain', hideable: false, render: (row) => <code>{row.domain_name}</code> },
|
|
9
|
+
{ key: 'status', label: 'Status', render: (row) => <Badge tone={row.status === 'completed' ? 'success' : row.status === 'failed' || row.status === 'expired' ? 'danger' : 'warning'}>{row.status}</Badge> },
|
|
10
|
+
{ key: 'price', label: 'Price', align: 'end', render: (row) => `${row.price ?? '—'} ${row.currency}` },
|
|
11
|
+
];
|
|
12
|
+
const filters: FilterDef[] = [{ key: 'status', label: 'Status', type: 'multiselect', options: ['quoted', 'submitted', 'completed', 'failed', 'expired'].map((value) => ({ value, label: value })) }];
|
|
13
|
+
export function DomainPurchasesPage() {
|
|
14
|
+
const { data: me } = useMe(); const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
15
|
+
return <ModelTable model={DomainPurchaseModel} eyebrow="Infrastructure · DNS" title="Domain Purchases" searchable searchPlaceholder="Search domain or status" columns={columns} filters={filters} defaultParams={{ sort: '-created' }} persistState persistKey="admin:dns:purchases" onRowClick={(row) => void modal.detail((close) => <DomainPurchaseDetail id={row.id} close={() => close(null)} />)} {...(canManage ? { addLabel: 'Add domain', onAdd: () => void showDomainOnboarding() } : {})} />;
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { Badge, ModelTable, fmt, modal, type Column, type FilterDef } from '../../ui';
|
|
3
|
+
import { DomainDetail } from './DomainDetail';
|
|
4
|
+
import { DomainModel, type DomainRow } from './models';
|
|
5
|
+
import { providerLabel } from './data';
|
|
6
|
+
|
|
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
|
+
const filters: FilterDef[] = [
|
|
16
|
+
{ key: 'provider', label: 'Provider', type: 'select', options: [{ value: 'route53', label: 'Route 53' }, { value: 'godaddy', label: 'GoDaddy' }, { value: 'mojo', label: 'Mojo certificate-only' }] },
|
|
17
|
+
{ key: 'status', label: 'Status', type: 'select', options: ['pending', 'registering', 'active'].map((value) => ({ value, label: value })) },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function DomainsPage() {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const open = async (row: DomainRow) => {
|
|
23
|
+
await DomainModel.fetchOne(queryClient, row.id);
|
|
24
|
+
await modal.detail((close) => <DomainDetail id={row.id} onClose={() => close(null)} />);
|
|
25
|
+
};
|
|
26
|
+
return <ModelTable<DomainRow> model={DomainModel} eyebrow="Infrastructure · DNS" title="Domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={(row) => void open(row)} />;
|
|
27
|
+
}
|