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,115 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { hasPermission, useMe } from '../../client';
|
|
4
|
+
import { Badge, DetailView, Eyebrow, FlatRow, fmt, modal, toast } from '../../ui';
|
|
5
|
+
import { revokeCertificate, useAcmeDelegations, useDnsCapabilities } from './api';
|
|
6
|
+
import {
|
|
7
|
+
canInspectHouseCertificate, certificateStatusTone, deriveCertificateRenewalHealth,
|
|
8
|
+
} from './certificate-data';
|
|
9
|
+
import { CertificateModel, DNS_MANAGE_PERMISSIONS, type CertificateRow, type DomainRow } from './models';
|
|
10
|
+
|
|
11
|
+
function healthLabel(row: CertificateRow): string {
|
|
12
|
+
const health = deriveCertificateRenewalHealth(row);
|
|
13
|
+
if (health === 'due') return 'Renewal due';
|
|
14
|
+
if (health === 'renewal-error') return 'Renewal error';
|
|
15
|
+
if (health === 'healthy') return 'Renewal scheduled';
|
|
16
|
+
return health.charAt(0).toUpperCase() + health.slice(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function AllowedCertificateDetail({ id, domain, onClose }: { id: number; domain: DomainRow; onClose: () => void }) {
|
|
20
|
+
const queryClient = useQueryClient();
|
|
21
|
+
const query = CertificateModel.useOne(id);
|
|
22
|
+
const { data: me } = useMe();
|
|
23
|
+
const groupId = typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
|
|
24
|
+
const capabilities = useDnsCapabilities(groupId);
|
|
25
|
+
const delegations = useAcmeDelegations(domain.id);
|
|
26
|
+
const [revoking, setRevoking] = useState(false);
|
|
27
|
+
const certificate = query.data;
|
|
28
|
+
if (query.isPending) return <div className="modal-pad dim">Loading certificate…</div>;
|
|
29
|
+
if (!certificate || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Certificate not found'}</div>;
|
|
30
|
+
const health = deriveCertificateRenewalHealth(certificate);
|
|
31
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
32
|
+
|
|
33
|
+
const revoke = async () => {
|
|
34
|
+
if (revoking) return;
|
|
35
|
+
const confirmed = await modal.confirm({
|
|
36
|
+
title: 'Revoke certificate',
|
|
37
|
+
message: <>Revoke the certificate for <code>{certificate.common_name}</code>? Hosts may continue serving it until they sync a replacement. This cannot be undone.</>,
|
|
38
|
+
confirmText: 'Revoke', danger: true,
|
|
39
|
+
});
|
|
40
|
+
if (!confirmed) return;
|
|
41
|
+
setRevoking(true);
|
|
42
|
+
try {
|
|
43
|
+
await revokeCertificate(queryClient, certificate.id);
|
|
44
|
+
toast.success('Certificate revoked');
|
|
45
|
+
} catch (reason) {
|
|
46
|
+
toast.error(reason instanceof Error ? reason.message : 'Certificate revocation failed');
|
|
47
|
+
} finally {
|
|
48
|
+
setRevoking(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const delegation = (delegations.data ?? [])[0] ?? null;
|
|
53
|
+
const expiryTone = certificate.days_remaining != null && certificate.days_remaining < 14 ? 'danger'
|
|
54
|
+
: certificate.days_remaining != null && certificate.days_remaining < (capabilities.data?.cert_renew_days ?? 30) ? 'warning'
|
|
55
|
+
: 'success';
|
|
56
|
+
return <DetailView<CertificateRow>
|
|
57
|
+
icon="bi-patch-check"
|
|
58
|
+
title={certificate.common_name || `Certificate ${certificate.id}`}
|
|
59
|
+
subtitle={certificate.issuer ?? 'Issued over ACME DNS-01'}
|
|
60
|
+
chips={[
|
|
61
|
+
{ text: certificate.status, tone: certificateStatusTone(certificate.status) },
|
|
62
|
+
...(certificate.days_remaining == null ? [] : [{ text: `${certificate.days_remaining} days left`, tone: expiryTone as 'success' | 'warning' | 'danger' }]),
|
|
63
|
+
]}
|
|
64
|
+
sections={[
|
|
65
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-cert-detail-section">
|
|
66
|
+
<Eyebrow>Certificate</Eyebrow>
|
|
67
|
+
<FlatRow label="Common name"><code>{certificate.common_name}</code></FlatRow>
|
|
68
|
+
<FlatRow label="Status"><Badge tone={certificateStatusTone(certificate.status)}>{certificate.status}</Badge></FlatRow>
|
|
69
|
+
<FlatRow label="Domain"><code>{domain.name}</code></FlatRow>
|
|
70
|
+
<FlatRow label="Issuer">{certificate.issuer ?? '—'}</FlatRow>
|
|
71
|
+
<FlatRow label="Serial"><code>{certificate.serial ?? '—'}</code></FlatRow>
|
|
72
|
+
<FlatRow label="Attempts">{certificate.attempts}</FlatRow>
|
|
73
|
+
{certificate.last_error && <div className="dns-errors"><strong>Last operation error</strong><span>{certificate.last_error}</span></div>}
|
|
74
|
+
</div> },
|
|
75
|
+
{ key: 'names', label: 'Names', icon: 'bi-tags', render: () => <div className="dns-cert-detail-section">
|
|
76
|
+
<Eyebrow>Subject alternative names</Eyebrow>
|
|
77
|
+
{certificate.sans.length ? <ul className="dns-cert-names">{certificate.sans.map((name) => <li key={name}><code>{name}</code></li>)}</ul> : <p className="dim">No names are recorded yet; issuance may still be starting.</p>}
|
|
78
|
+
</div> },
|
|
79
|
+
{ key: 'renewal', label: 'Renewal', icon: 'bi-arrow-repeat', render: () => <div className="dns-cert-detail-section">
|
|
80
|
+
<Eyebrow>Validity and renewal</Eyebrow>
|
|
81
|
+
<FlatRow label="Not before">{fmt.date(certificate.not_before)}</FlatRow>
|
|
82
|
+
<FlatRow label="Not after">{fmt.date(certificate.not_after)}</FlatRow>
|
|
83
|
+
<FlatRow label="Renew after">{fmt.date(certificate.renew_after)}</FlatRow>
|
|
84
|
+
<FlatRow label="Health">{healthLabel(certificate)}</FlatRow>
|
|
85
|
+
{health === 'due' && <div className="dns-provider-note">Renewal is due according to the backend-authored <code>renew_after</code> timestamp. The page watches the bounded lifecycle transition.</div>}
|
|
86
|
+
<Eyebrow>Certificate custody</Eyebrow>
|
|
87
|
+
<p className="dim">Serving hosts sync certificate material themselves after the backend broadcasts an update. Private keys are never loaded into this Admin client or its caches.</p>
|
|
88
|
+
{capabilities.data?.acme.staging && <p className="dns-warning">The deployment currently targets the ACME staging directory. That current setting does not prove how this existing certificate was issued.</p>}
|
|
89
|
+
</div> },
|
|
90
|
+
{ key: 'delegation', label: 'Delegation', icon: 'bi-diagram-2', render: () => <div className="dns-cert-detail-section">
|
|
91
|
+
<Eyebrow>DNS-01 routing</Eyebrow>
|
|
92
|
+
{delegations.isPending ? <p className="dim">Loading delegation status…</p> : delegation ? <>
|
|
93
|
+
<FlatRow label="State"><Badge tone={delegation.state === 'verified' ? 'success' : delegation.state === 'broken' ? 'danger' : 'warning'}>{delegation.state}</Badge></FlatRow>
|
|
94
|
+
<FlatRow label="Source"><code>{delegation.source ?? '—'}</code></FlatRow>
|
|
95
|
+
<FlatRow label="Target"><code>{delegation.target ?? '—'}</code></FlatRow>
|
|
96
|
+
{delegation.last_error_code && <FlatRow label="Error code"><code>{delegation.last_error_code}</code></FlatRow>}
|
|
97
|
+
{delegation.verified_at && delegation.state === 'broken' && <div className="dns-errors">This delegation is sticky after verification. Repair it; issuance will not fall back to direct provider DNS.</div>}
|
|
98
|
+
</> : <p className="dim">No delegated ACME route is attached; provider DNS is used directly.</p>}
|
|
99
|
+
</div> },
|
|
100
|
+
]}
|
|
101
|
+
initialSection="overview"
|
|
102
|
+
contextMenu={canManage && certificate.status === 'active' ? [{ label: revoking ? 'Revoking…' : 'Revoke certificate', icon: 'bi-x-octagon', danger: true, onSelect: () => void revoke() }] : []}
|
|
103
|
+
menuContext={certificate}
|
|
104
|
+
onClose={onClose}
|
|
105
|
+
/>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fail closed without explaining whether a denied id belongs to the house. */
|
|
109
|
+
export function CertificateDetail({ id, domain, onClose }: { id: number; domain: DomainRow; onClose: () => void }) {
|
|
110
|
+
const { data: me } = useMe();
|
|
111
|
+
if (!canInspectHouseCertificate(domain, me?.is_superuser === true)) {
|
|
112
|
+
return <div className="modal-pad"><h2 className="modal-title">Certificate unavailable</h2><p className="dim">This certificate cannot be inspected with the current session.</p><div className="modal-actions"><button className="btn" onClick={onClose}>Close</button></div></div>;
|
|
113
|
+
}
|
|
114
|
+
return <AllowedCertificateDetail id={id} domain={domain} onClose={onClose} />;
|
|
115
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import type { MojoList } from '../../client';
|
|
4
|
+
import { CertificateModel, type CertificateRow } from './models';
|
|
5
|
+
import { deriveCertificateRenewalHealth } from './certificate-data';
|
|
6
|
+
|
|
7
|
+
export const CERTIFICATE_POLL_INTERVAL_MS = 10_000;
|
|
8
|
+
export const CERTIFICATE_POLL_MAX_TICKS = 36;
|
|
9
|
+
|
|
10
|
+
interface Operation {
|
|
11
|
+
kind: 'issuance' | 'renewal';
|
|
12
|
+
ticks: number;
|
|
13
|
+
sawIssuing: boolean;
|
|
14
|
+
baseline: Pick<CertificateRow, 'attempts' | 'modified' | 'not_after' | 'renew_after' | 'last_error'>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function domainId(row: CertificateRow): number {
|
|
18
|
+
return typeof row.domain === 'number' ? row.domain : row.domain.id;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function certificateNeedsLifecyclePolling(row: CertificateRow, nowSeconds = Date.now() / 1000): boolean {
|
|
22
|
+
const health = deriveCertificateRenewalHealth(row, nowSeconds);
|
|
23
|
+
return health === 'pending' || health === 'issuing' || health === 'due';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cachedCertificates(value: unknown): CertificateRow[] {
|
|
27
|
+
if (value == null || typeof value !== 'object') return [];
|
|
28
|
+
if ('rows' in value && Array.isArray((value as MojoList<CertificateRow>).rows)) {
|
|
29
|
+
return (value as MojoList<CertificateRow>).rows;
|
|
30
|
+
}
|
|
31
|
+
if ('id' in value && typeof (value as CertificateRow).id === 'number') return [value as CertificateRow];
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One bounded timer for one mounted certificate surface. New issuance polls
|
|
37
|
+
* pending/issuing rows. Due renewal remembers the active baseline and follows
|
|
38
|
+
* the active -> issuing -> changed-active/error transition. An already-active
|
|
39
|
+
* renewal error is terminal and never starts ordinary polling.
|
|
40
|
+
*/
|
|
41
|
+
export function CertificateLifecyclePoller({ domain, intervalMs = CERTIFICATE_POLL_INTERVAL_MS, maxTicks = CERTIFICATE_POLL_MAX_TICKS }: {
|
|
42
|
+
domain?: number;
|
|
43
|
+
intervalMs?: number;
|
|
44
|
+
maxTicks?: number;
|
|
45
|
+
}) {
|
|
46
|
+
const queryClient = useQueryClient();
|
|
47
|
+
const operations = useRef(new Map<number, Operation>());
|
|
48
|
+
const settled = useRef(new Set<number>());
|
|
49
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
|
+
const [version, setVersion] = useState(0);
|
|
51
|
+
|
|
52
|
+
useEffect(() => queryClient.getQueryCache().subscribe((event) => {
|
|
53
|
+
if (event.query.queryKey[0] === CertificateModel.endpoint) setVersion((value) => value + 1);
|
|
54
|
+
}), [queryClient]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const rowsById = new Map<number, CertificateRow>();
|
|
58
|
+
for (const [, value] of queryClient.getQueriesData({ queryKey: CertificateModel.keys.root })) {
|
|
59
|
+
for (const row of cachedCertificates(value)) {
|
|
60
|
+
if (domain == null || domainId(row) === domain) rowsById.set(row.id, row);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const [id, operation] of operations.current) {
|
|
65
|
+
const row = rowsById.get(id);
|
|
66
|
+
if (!row || operation.ticks >= maxTicks) {
|
|
67
|
+
operations.current.delete(id);
|
|
68
|
+
settled.current.add(id);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (operation.kind === 'issuance') {
|
|
72
|
+
if (row.status !== 'pending' && row.status !== 'issuing') {
|
|
73
|
+
operations.current.delete(id);
|
|
74
|
+
settled.current.add(id);
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (row.status === 'issuing') {
|
|
79
|
+
operation.sawIssuing = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (row.status !== 'active') {
|
|
83
|
+
operations.current.delete(id);
|
|
84
|
+
settled.current.add(id);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const observable = row.attempts !== operation.baseline.attempts
|
|
88
|
+
|| row.not_after !== operation.baseline.not_after
|
|
89
|
+
|| row.renew_after !== operation.baseline.renew_after
|
|
90
|
+
|| row.last_error !== operation.baseline.last_error;
|
|
91
|
+
if (row.last_error || (observable && (operation.sawIssuing || row.attempts > operation.baseline.attempts))) {
|
|
92
|
+
operations.current.delete(id);
|
|
93
|
+
settled.current.add(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const row of rowsById.values()) {
|
|
98
|
+
if (settled.current.has(row.id) || operations.current.has(row.id)) continue;
|
|
99
|
+
const health = deriveCertificateRenewalHealth(row);
|
|
100
|
+
if (health === 'pending' || health === 'issuing') {
|
|
101
|
+
operations.current.set(row.id, {
|
|
102
|
+
kind: 'issuance', ticks: 0, sawIssuing: row.status === 'issuing',
|
|
103
|
+
baseline: { attempts: row.attempts, modified: row.modified, not_after: row.not_after, renew_after: row.renew_after, last_error: row.last_error },
|
|
104
|
+
});
|
|
105
|
+
} else if (health === 'due') {
|
|
106
|
+
operations.current.set(row.id, {
|
|
107
|
+
kind: 'renewal', ticks: 0, sawIssuing: false,
|
|
108
|
+
baseline: { attempts: row.attempts, modified: row.modified, not_after: row.not_after, renew_after: row.renew_after, last_error: row.last_error },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (operations.current.size === 0) {
|
|
114
|
+
if (timer.current) clearTimeout(timer.current);
|
|
115
|
+
timer.current = null;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (timer.current) return;
|
|
119
|
+
timer.current = setTimeout(() => {
|
|
120
|
+
timer.current = null;
|
|
121
|
+
for (const operation of operations.current.values()) operation.ticks += 1;
|
|
122
|
+
const tracked = [...operations.current.keys()].map((id) => CertificateModel.fetchOne(queryClient, id));
|
|
123
|
+
void Promise.allSettled([
|
|
124
|
+
...tracked,
|
|
125
|
+
queryClient.refetchQueries({
|
|
126
|
+
type: 'active',
|
|
127
|
+
predicate: (query) => query.queryKey[0] === CertificateModel.endpoint,
|
|
128
|
+
}),
|
|
129
|
+
]).finally(() => setVersion((value) => value + 1));
|
|
130
|
+
}, Math.max(250, intervalMs));
|
|
131
|
+
}, [domain, intervalMs, maxTicks, queryClient, version]);
|
|
132
|
+
|
|
133
|
+
useEffect(() => () => {
|
|
134
|
+
if (timer.current) clearTimeout(timer.current);
|
|
135
|
+
timer.current = null;
|
|
136
|
+
operations.current.clear();
|
|
137
|
+
settled.current.clear();
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { TagInput, toast } from '../../ui';
|
|
4
|
+
import { requestCertificate, useAcmeDelegations, useDnsCapabilities } from './api';
|
|
5
|
+
import { deriveCertificateReadiness, validateCertificateNames } from './certificate-data';
|
|
6
|
+
import { DnsCredentialModel, DomainModel, type CertificateRow, type DomainRow } from './models';
|
|
7
|
+
|
|
8
|
+
function relationId(value: DomainRow['credential']): number | null {
|
|
9
|
+
if (typeof value === 'number') return value;
|
|
10
|
+
return value?.id ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CertificateRequestDialog({ close, domain: fixedDomain }: {
|
|
14
|
+
close: (value: CertificateRow | null) => void;
|
|
15
|
+
domain?: DomainRow;
|
|
16
|
+
}) {
|
|
17
|
+
const queryClient = useQueryClient();
|
|
18
|
+
const domains = DomainModel.useList({ status: 'active', size: 200, sort: 'name' }, { enabled: fixedDomain == null });
|
|
19
|
+
const [domainId, setDomainId] = useState<number | null>(fixedDomain?.id ?? null);
|
|
20
|
+
const domainQuery = DomainModel.useOne(domainId);
|
|
21
|
+
const domain = domainQuery.data ?? (fixedDomain?.id === domainId ? fixedDomain : undefined);
|
|
22
|
+
const groupId = typeof domain?.group === 'number' ? domain.group : domain?.group?.id ?? null;
|
|
23
|
+
const capabilities = useDnsCapabilities(groupId, { enabled: domain != null });
|
|
24
|
+
const delegations = useAcmeDelegations(domain?.id ?? null, domain != null);
|
|
25
|
+
const credentialId = domain ? relationId(domain.credential) : null;
|
|
26
|
+
const credential = DnsCredentialModel.useOne(credentialId);
|
|
27
|
+
const [namesCsv, setNamesCsv] = useState('');
|
|
28
|
+
const [names, setNames] = useState<string[]>([]);
|
|
29
|
+
const [submitting, setSubmitting] = useState(false);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
const validation = useMemo(
|
|
33
|
+
() => domain ? validateCertificateNames(domain.name, names) : { names: [], errors: [] },
|
|
34
|
+
[domain, names],
|
|
35
|
+
);
|
|
36
|
+
const readiness = domain && capabilities.data && !delegations.isPending
|
|
37
|
+
? deriveCertificateReadiness({
|
|
38
|
+
domain,
|
|
39
|
+
capabilities: capabilities.data,
|
|
40
|
+
delegations: delegations.data ?? [],
|
|
41
|
+
credential: credential.data ?? (typeof domain.credential === 'object' ? domain.credential : null),
|
|
42
|
+
})
|
|
43
|
+
: null;
|
|
44
|
+
const loadingReadiness = domainId != null && (domainQuery.isPending || capabilities.isPending || delegations.isPending || (credentialId != null && credential.isPending));
|
|
45
|
+
|
|
46
|
+
const submit = async (event: React.FormEvent) => {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
if (!domain || !readiness?.ready || validation.errors.length || submitting) return;
|
|
49
|
+
setSubmitting(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
const row = await requestCertificate(queryClient, domain.id, names.length ? validation.names : undefined);
|
|
53
|
+
toast.success('Certificate requested — issuance continues in the background.');
|
|
54
|
+
close(row);
|
|
55
|
+
} catch (reason) {
|
|
56
|
+
setError(reason instanceof Error ? reason.message : 'Certificate request failed');
|
|
57
|
+
} finally {
|
|
58
|
+
setSubmitting(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return <form className="modal-pad dns-cert-request" onSubmit={(event) => void submit(event)}>
|
|
63
|
+
<h2 className="modal-title">Request certificate</h2>
|
|
64
|
+
<p className="dim">Issue over ACME DNS-01. The request returns immediately; lifecycle status updates in the certificate inventory.</p>
|
|
65
|
+
{fixedDomain ? <div className="field"><span className="field-label">Domain</span><code>{fixedDomain.name}</code></div> : <label className="field">
|
|
66
|
+
<span className="field-label">Domain</span>
|
|
67
|
+
<select className="input" required value={domainId ?? ''} onChange={(event) => { setDomainId(event.target.value ? Number(event.target.value) : null); setNamesCsv(''); setNames([]); setError(null); }}>
|
|
68
|
+
<option value="">Select an active domain…</option>
|
|
69
|
+
{(domains.data?.rows ?? []).map((row) => <option key={row.id} value={row.id}>{row.name}</option>)}
|
|
70
|
+
</select>
|
|
71
|
+
</label>}
|
|
72
|
+
<label className="field">
|
|
73
|
+
<span className="field-label">Names</span>
|
|
74
|
+
<TagInput
|
|
75
|
+
name="certificate_names"
|
|
76
|
+
value={namesCsv}
|
|
77
|
+
onChange={(csv, next) => { setNamesCsv(csv); setNames(next); setError(null); }}
|
|
78
|
+
placeholder={domain ? `${domain.name}, *.${domain.name}` : 'Choose a domain first'}
|
|
79
|
+
maxTags={100}
|
|
80
|
+
maxLength={253}
|
|
81
|
+
disabled={!domain || submitting}
|
|
82
|
+
/>
|
|
83
|
+
<span className="field-help">Leave empty for the domain apex plus its wildcard. Every SAN must stay inside the selected zone.</span>
|
|
84
|
+
</label>
|
|
85
|
+
{!!validation.errors.length && <div className="dns-errors" role="alert">{validation.errors.map((message) => <div key={message}>{message}</div>)}</div>}
|
|
86
|
+
{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'}`}>
|
|
88
|
+
<strong>{readiness.label}</strong>
|
|
89
|
+
{readiness.reason && <span>{readiness.reason}</span>}
|
|
90
|
+
</div>}
|
|
91
|
+
{capabilities.data?.acme.staging && <div className="dns-provider-note">
|
|
92
|
+
<strong>Current ACME configuration: staging.</strong> New certificates are not publicly trusted. This flag is current configuration, not historical provenance for existing certificates.
|
|
93
|
+
</div>}
|
|
94
|
+
{capabilities.error && <div className="dns-errors">{capabilities.error.message}</div>}
|
|
95
|
+
{error && <div className="dns-errors" role="alert">{error}</div>}
|
|
96
|
+
<div className="modal-actions">
|
|
97
|
+
<button className="btn" type="button" disabled={submitting} onClick={() => close(null)}>Cancel</button>
|
|
98
|
+
<button className="btn btn-primary" type="submit" disabled={!domain || !readiness?.ready || validation.errors.length > 0 || submitting}>
|
|
99
|
+
{submitting ? 'Requesting…' : 'Request certificate'}
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</form>;
|
|
103
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { hasPermission, useMe } from '../../client';
|
|
3
|
+
import { Badge, ModelTable, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
|
|
4
|
+
import { useDnsCapabilities } from './api';
|
|
5
|
+
import { CertificateDetail } from './CertificateDetail';
|
|
6
|
+
import { CertificateLifecyclePoller } from './CertificateLifecyclePoller';
|
|
7
|
+
import { CertificateRequestDialog } from './CertificateRequestDialog';
|
|
8
|
+
import {
|
|
9
|
+
canInspectHouseCertificate, certificateStatusTone, deriveCertificateRenewalHealth,
|
|
10
|
+
} from './certificate-data';
|
|
11
|
+
import {
|
|
12
|
+
CertificateModel, DNS_MANAGE_PERMISSIONS, DomainModel,
|
|
13
|
+
type CertificateRow,
|
|
14
|
+
} from './models';
|
|
15
|
+
|
|
16
|
+
function domainId(row: CertificateRow): number {
|
|
17
|
+
return typeof row.domain === 'number' ? row.domain : row.domain.id;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renewalLabel(row: CertificateRow): string {
|
|
21
|
+
const health = deriveCertificateRenewalHealth(row);
|
|
22
|
+
if (health === 'healthy') return fmt.date(row.renew_after);
|
|
23
|
+
if (health === 'due') return 'Due now';
|
|
24
|
+
if (health === 'renewal-error') return 'Error';
|
|
25
|
+
return health === 'unknown' ? '—' : health;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const columns: Column<CertificateRow>[] = [
|
|
29
|
+
{ key: 'common_name', label: 'Common name', sortable: true, hideable: false, render: (row) => <code>{row.common_name || `Certificate ${row.id}`}</code> },
|
|
30
|
+
{ key: 'status', label: 'Status', sortable: true, render: (row) => <Badge tone={certificateStatusTone(row.status)}>{row.status}</Badge> },
|
|
31
|
+
{ key: 'sans', label: 'SANs', align: 'end', render: (row) => row.sans.length },
|
|
32
|
+
{ key: 'issuer', label: 'Issuer', render: (row) => row.issuer ?? '—' },
|
|
33
|
+
{ key: 'not_after', label: 'Expires', sortable: true, render: (row) => fmt.date(row.not_after) },
|
|
34
|
+
{ key: 'renew_after', label: 'Renewal', sortable: true, render: (row) => <span className={deriveCertificateRenewalHealth(row) === 'renewal-error' ? 'text-bad' : deriveCertificateRenewalHealth(row) === 'due' ? 'text-warn' : undefined}>{renewalLabel(row)}</span> },
|
|
35
|
+
{ key: 'domain', label: 'Domain', render: (row) => typeof row.domain === 'number' ? `#${row.domain}` : <code>{row.domain.name}</code> },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const filters: FilterDef[] = [
|
|
39
|
+
{ key: 'status', label: 'Status', type: 'multiselect', options: ['pending', 'issuing', 'active', 'failed', 'revoked'].map((value) => ({ value, label: value })) },
|
|
40
|
+
{ key: 'domain', label: 'Domain id', type: 'number', lookup: 'exact' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function CertificatesPage() {
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
const { data: me } = useMe();
|
|
46
|
+
const capabilities = useDnsCapabilities(null);
|
|
47
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
48
|
+
const openCertificate = async (row: CertificateRow) => {
|
|
49
|
+
try {
|
|
50
|
+
const domain = await DomainModel.fetchOne(queryClient, domainId(row));
|
|
51
|
+
if (!canInspectHouseCertificate(domain, me?.is_superuser === true)) {
|
|
52
|
+
toast.error('This certificate cannot be inspected with the current session.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await CertificateModel.fetchOne(queryClient, row.id);
|
|
56
|
+
await modal.detail((close) => <CertificateDetail id={row.id} domain={domain} onClose={() => close(null)} />);
|
|
57
|
+
} catch (reason) {
|
|
58
|
+
toast.error(reason instanceof Error ? reason.message : 'Certificate detail is unavailable');
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const request = async () => {
|
|
62
|
+
await modal.open((close) => <CertificateRequestDialog close={close} />, { size: 'md' });
|
|
63
|
+
};
|
|
64
|
+
return <div className="dns-certificates-page">
|
|
65
|
+
{capabilities.data?.acme.configured === false && <div className="dns-cert-banner is-blocked"><i className="bi bi-exclamation-triangle" /><span><strong>ACME is not configured.</strong> Existing lifecycle state remains visible, but new certificate requests are unavailable.</span></div>}
|
|
66
|
+
{capabilities.data?.acme.staging === true && <div className="dns-cert-banner"><i className="bi bi-cone-striped" /><span><strong>Current ACME configuration uses staging.</strong> New certificates are not publicly trusted. Existing rows do not record historical staging provenance.</span></div>}
|
|
67
|
+
{capabilities.error && <div className="dns-cert-banner is-blocked"><i className="bi bi-exclamation-triangle" /><span>{capabilities.error.message}</span></div>}
|
|
68
|
+
<ModelTable<CertificateRow>
|
|
69
|
+
model={CertificateModel}
|
|
70
|
+
eyebrow="Infrastructure · DNS"
|
|
71
|
+
title="Certificates"
|
|
72
|
+
searchable
|
|
73
|
+
searchPlaceholder="Search common name or status"
|
|
74
|
+
columns={columns}
|
|
75
|
+
filters={filters}
|
|
76
|
+
defaultParams={{ sort: '-created' }}
|
|
77
|
+
columnChooser
|
|
78
|
+
persistState
|
|
79
|
+
persistKey="admin:dns:certificates"
|
|
80
|
+
onRowClick={(row) => void openCertificate(row)}
|
|
81
|
+
{...(canManage && capabilities.data?.acme.configured !== false ? { addLabel: 'Request certificate', onAdd: () => void request() } : {})}
|
|
82
|
+
/>
|
|
83
|
+
<CertificateLifecyclePoller />
|
|
84
|
+
</div>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useMemo, useState, type ClipboardEvent } from 'react';
|
|
2
|
+
import { modal, toast } from '../../ui';
|
|
3
|
+
import { upsertDnsRecord, useDnsRecordCoordinator } from './api';
|
|
4
|
+
import {
|
|
5
|
+
DNS_RECORD_TYPES, autofixFieldValue, blankRecordValue, diffRecordSet, formatRecordValue,
|
|
6
|
+
parseRecordValue, recordWarnings, snapshotRecordOwner, specFor, toFqdn, validateRecordSet,
|
|
7
|
+
type DnsCorrection, type StructuredRecordValue,
|
|
8
|
+
} from './dns-data';
|
|
9
|
+
import type { DnsCapabilities, DnsRecordRow, DomainRow } from './models';
|
|
10
|
+
|
|
11
|
+
export interface DnsRecordEditorProps {
|
|
12
|
+
domain: DomainRow;
|
|
13
|
+
capabilities: DnsCapabilities;
|
|
14
|
+
records: readonly DnsRecordRow[];
|
|
15
|
+
record?: DnsRecordRow;
|
|
16
|
+
onClose: (saved: boolean) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }
|
|
20
|
+
|
|
21
|
+
export function DnsRecordEditor({ domain, capabilities, records, record, onClose }: DnsRecordEditorProps) {
|
|
22
|
+
const supported = DNS_RECORD_TYPES.filter((type) => capabilities.allowed_record_types.map((entry) => entry.toUpperCase()).includes(type));
|
|
23
|
+
const [type, setType] = useState(record?.type.toUpperCase() ?? supported[0] ?? 'A');
|
|
24
|
+
const [name, setName] = useState(record ? record.name : '@');
|
|
25
|
+
const [ttl, setTtl] = useState(String(record?.ttl ?? 300));
|
|
26
|
+
const [rows, setRows] = useState<StructuredRecordValue[]>(() => record?.record_values.map((value) => parseRecordValue(record.type, value)) ?? [blankRecordValue(type)]);
|
|
27
|
+
const [corrections, setCorrections] = useState<DnsCorrection[]>([]);
|
|
28
|
+
const [requestError, setRequestError] = useState<string | null>(null);
|
|
29
|
+
const [stale, setStale] = useState(false);
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
const coordinate = useDnsRecordCoordinator(domain.id);
|
|
32
|
+
const fqdn = toFqdn(name, domain.name);
|
|
33
|
+
const currentSpec = specFor(type);
|
|
34
|
+
const values = rows.map((row) => formatRecordValue(type, row));
|
|
35
|
+
const desired: DnsRecordRow = { type, name: fqdn, record_values: values, ttl: Number(ttl) };
|
|
36
|
+
const validation = validateRecordSet({ type, name, values, ttl, zone: domain.name, existingRecords: records, caps: capabilities, original: record ?? null });
|
|
37
|
+
const pendingCorrection = autofixFieldValue('hostname', name).value !== name
|
|
38
|
+
|| rows.some((row) => currentSpec?.fields.some((field) => field.kind !== 'text' && autofixFieldValue(field.kind, row[field.key]).value !== (row[field.key] ?? '')));
|
|
39
|
+
const diff = diffRecordSet(record ?? null, desired);
|
|
40
|
+
const unchanged = !!record && diff.added.length === 0 && diff.removed.length === 0 && diff.ttl.before === diff.ttl.after;
|
|
41
|
+
const opening = useMemo(() => snapshotRecordOwner(records, record?.type ?? type, record?.name ?? fqdn), [records, record, type, fqdn]);
|
|
42
|
+
|
|
43
|
+
const correctValue = (rowIndex: number, fieldKey: string, raw: string) => {
|
|
44
|
+
const field = currentSpec?.fields.find((entry) => entry.key === fieldKey);
|
|
45
|
+
if (!field) return;
|
|
46
|
+
const result = autofixFieldValue(field.kind, raw, fieldKey, rowIndex);
|
|
47
|
+
setRows((current) => current.map((entry, index) => index === rowIndex ? { ...entry, [fieldKey]: result.value } : entry));
|
|
48
|
+
if (result.corrections.length) setCorrections((current) => [...current, ...result.corrections]);
|
|
49
|
+
};
|
|
50
|
+
const handlePaste = (event: ClipboardEvent<HTMLInputElement>, rowIndex: number, fieldKey: string) => {
|
|
51
|
+
const raw = event.clipboardData.getData('text');
|
|
52
|
+
const field = currentSpec?.fields.find((entry) => entry.key === fieldKey);
|
|
53
|
+
if (!field) return;
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
const result = autofixFieldValue(field.kind, raw, fieldKey, rowIndex);
|
|
56
|
+
setRows((current) => current.map((entry, index) => index === rowIndex ? { ...entry, [fieldKey]: result.value } : entry));
|
|
57
|
+
if (result.corrections.length) setCorrections((current) => [...current, ...result.corrections]);
|
|
58
|
+
};
|
|
59
|
+
const changeType = (next: string) => { setType(next); setRows([blankRecordValue(next)]); setCorrections([]); setRequestError(null); };
|
|
60
|
+
const applyFix = (errorIndex: number) => {
|
|
61
|
+
const fix = validation.errors[errorIndex]?.fix;
|
|
62
|
+
if (fix?.action === 'change-type' && fix.type && !record) changeType(fix.type);
|
|
63
|
+
if (fix?.action === 'set-name' && fix.name) setName(fix.name);
|
|
64
|
+
};
|
|
65
|
+
const save = async () => {
|
|
66
|
+
setRequestError(null);
|
|
67
|
+
const checked = validateRecordSet({ type, name, values, ttl, zone: domain.name, existingRecords: records, caps: capabilities, original: record ?? null });
|
|
68
|
+
if (!checked.ok || pendingCorrection) return;
|
|
69
|
+
const warnings = recordWarnings({ provider: domain.provider, type, name, values, ttl, zone: domain.name, existingRecords: records, before: record?.record_values ?? [] });
|
|
70
|
+
const confirmed = await modal.confirm({
|
|
71
|
+
title: record ? 'Replace complete record set?' : 'Create record set?',
|
|
72
|
+
confirmText: record ? 'Replace set' : 'Create set',
|
|
73
|
+
message: <div className="dns-confirm"><p><code>{type} {fqdn}</code></p>{diff.ttl.before !== diff.ttl.after && <p>TTL: {diff.ttl.before ?? 'new'} → {diff.ttl.after}</p>}<DiffList label="Unchanged" values={diff.unchanged} /><DiffList label="Remove" values={diff.removed} tone="bad" /><DiffList label="Add" values={diff.added} tone="ok" />{domain.provider === 'godaddy' && Number(ttl) < 600 && <p className="dns-warning">GoDaddy will refresh this TTL at its 600-second provider floor.</p>}{warnings.map((warning) => <p className="dns-warning" key={warning}>{warning}</p>)}</div>,
|
|
74
|
+
danger: diff.removed.length > 0,
|
|
75
|
+
});
|
|
76
|
+
if (!confirmed) return;
|
|
77
|
+
setSaving(true);
|
|
78
|
+
try {
|
|
79
|
+
await coordinate({ opening, intended: desired, write: () => upsertDnsRecord(domain.id, desired) });
|
|
80
|
+
toast.success(record ? 'DNS record set updated' : 'DNS record set created');
|
|
81
|
+
onClose(true);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof Error && error.name === 'DnsStaleSnapshotError') setStale(true);
|
|
84
|
+
const ambiguous = error != null && typeof error === 'object' && 'dnsLiveStateApplied' in error;
|
|
85
|
+
setRequestError(`${errorMessage(error)}${ambiguous ? ' The refreshed live zone already reflects the requested state.' : ''}`);
|
|
86
|
+
} finally { setSaving(false); }
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return <div className="modal-pad dns-editor">
|
|
90
|
+
<h2 className="modal-title">{record ? 'Edit DNS record set' : 'Add DNS record set'}</h2>
|
|
91
|
+
<p className="dim">Writes replace the complete <code>(type, name)</code> set. Existing identity is immutable.</p>
|
|
92
|
+
{corrections.length > 0 && <div className="dns-corrections"><strong>Applied corrections</strong>{corrections.map((entry, index) => <div key={`${entry.field}-${index}`}><span>{entry.message}:</span> <code>{entry.before || '∅'}</code> → <code>{entry.after || '∅'}</code></div>)}</div>}
|
|
93
|
+
{requestError && <div className="dns-error" role="alert">{requestError}</div>}
|
|
94
|
+
<div className="dns-editor-grid">
|
|
95
|
+
<label className="field"><span className="field-label">Type</span><select className="input" value={type} disabled={!!record} onChange={(event) => changeType(event.target.value)}>{supported.map((entry) => <option key={entry}>{entry}</option>)}</select></label>
|
|
96
|
+
<label className="field dns-name-field"><span className="field-label">Name</span><input className="input" value={name} disabled={!!record} onChange={(event) => setName(event.target.value)} onBlur={(event) => { const result = autofixFieldValue('hostname', event.target.value, 'name'); setName(result.value || '@'); setCorrections((current) => [...current, ...result.corrections]); }} /></label>
|
|
97
|
+
<label className="field"><span className="field-label">TTL</span><input className="input" inputMode="numeric" value={ttl} onChange={(event) => setTtl(event.target.value)} /></label>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="dns-fqdn">Normalized owner: <code>{fqdn || '—'}</code></div>
|
|
100
|
+
<div className="dns-values"><div className="field-label">{currentSpec?.valuesLabel ?? 'Values'}</div>{rows.map((row, rowIndex) => <div className="dns-value-row" key={rowIndex}>{currentSpec?.fields.map((field) => field.kind === 'enum' ? <label className="field" key={field.key}><span className="field-label">{field.label}</span><select className="input" value={row[field.key] ?? ''} onChange={(event) => setRows((current) => current.map((entry, index) => index === rowIndex ? { ...entry, [field.key]: event.target.value } : entry))}>{field.options?.map((option) => <option key={option}>{option}</option>)}</select></label> : <label className="field" key={field.key}><span className="field-label">{field.label}</span><input className="input" style={field.width ? { width: field.width } : undefined} value={row[field.key] ?? ''} onChange={(event) => setRows((current) => current.map((entry, index) => index === rowIndex ? { ...entry, [field.key]: event.target.value } : entry))} onPaste={(event) => handlePaste(event, rowIndex, field.key)} onBlur={(event) => correctValue(rowIndex, field.key, event.target.value)} /></label>)}{currentSpec?.multi && rows.length > 1 && <button className="btn-icon" type="button" title="Remove value" onClick={() => setRows((current) => current.filter((_, index) => index !== rowIndex))}><i className="bi bi-x-lg" /></button>}</div>)}{currentSpec?.multi && <button className="btn" type="button" onClick={() => setRows((current) => [...current, blankRecordValue(type)])}><i className="bi bi-plus-lg" /> Add value</button>}</div>
|
|
101
|
+
{validation.errors.length > 0 && <div className="dns-errors" role="alert">{validation.errors.map((error, index) => <div key={`${error.field}-${error.index}-${index}`}>{error.message}{error.fix && (!record || error.fix.action !== 'change-type') && <button type="button" className="btn-link" onClick={() => applyFix(index)}>{error.fix.label}</button>}</div>)}</div>}
|
|
102
|
+
{pendingCorrection && <div className="dns-errors" role="alert">Paste or leave the changed field to review and acknowledge its correction before saving.</div>}
|
|
103
|
+
<div className="modal-actions"><button type="button" className="btn" onClick={() => onClose(false)}>Cancel</button><button type="button" className="btn btn-primary" disabled={saving || stale || !validation.ok || pendingCorrection || unchanged} onClick={() => void save()}>{saving ? 'Checking live zone…' : record ? 'Review replacement' : 'Review creation'}</button></div>
|
|
104
|
+
</div>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function DiffList({ label, values, tone }: { label: string; values: readonly string[]; tone?: 'bad' | 'ok' }) {
|
|
108
|
+
if (!values.length) return null;
|
|
109
|
+
return <div className={tone ? `text-${tone}` : undefined}><strong>{label}</strong><ul>{values.map((value, index) => <li key={`${value}-${index}`}><code>{value}</code></li>)}</ul></div>;
|
|
110
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { CollectionSelect } from '../../ui';
|
|
4
|
+
import { DnsRecordsPanel } from './DnsRecordsPanel';
|
|
5
|
+
import { DomainModel, type DomainRow } from './models';
|
|
6
|
+
|
|
7
|
+
export function DnsRecordsPage() {
|
|
8
|
+
const [params, setParams] = useSearchParams();
|
|
9
|
+
const rawDomain = params.get('domain');
|
|
10
|
+
const parsed = rawDomain != null && /^[1-9]\d*$/.test(rawDomain) ? Number(rawDomain) : null;
|
|
11
|
+
const invalidSyntax = rawDomain != null && parsed == null;
|
|
12
|
+
const first = DomainModel.useList({ status: 'active', sort: 'name', size: 1 }, { enabled: rawDomain == null });
|
|
13
|
+
const selected = DomainModel.useOne(parsed);
|
|
14
|
+
const recordType = params.get('record_type') ?? '';
|
|
15
|
+
const update = (key: 'domain' | 'record_type', value: string | number | null) => {
|
|
16
|
+
const next = new URLSearchParams(params);
|
|
17
|
+
if (value == null || value === '') next.delete(key); else next.set(key, String(value));
|
|
18
|
+
setParams(next, { replace: true });
|
|
19
|
+
};
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (rawDomain != null || !first.data?.rows[0]) return;
|
|
22
|
+
update('domain', first.data.rows[0].id);
|
|
23
|
+
}, [rawDomain, first.data]);
|
|
24
|
+
const domain = selected.data;
|
|
25
|
+
return <div className="dns-records-page">
|
|
26
|
+
<div className="panel panel-pad dns-records-picker"><div><div className="eyebrow">Infrastructure · DNS</div><h1>DNS Records</h1><p className="dim">Live provider record sets. Writes always replace a complete type/name set.</p></div><CollectionSelect<DomainRow> model={DomainModel} label="Active domain" value={parsed} onChange={(id) => update('domain', id)} defaultParams={{ status: 'active', sort: 'name' }} placeholder="Search active domains…" maxItems={50} /></div>
|
|
27
|
+
{rawDomain == null && first.isPending && <div className="panel panel-pad dim">Finding the first active domain…</div>}
|
|
28
|
+
{rawDomain == null && first.isError && <div className="panel panel-pad text-bad">{first.error.message}</div>}
|
|
29
|
+
{rawDomain == null && first.data && first.data.rows.length === 0 && <div className="panel panel-pad dim">No active DNS domains are available.</div>}
|
|
30
|
+
{invalidSyntax && <div className="panel panel-pad text-bad">The deep-linked domain id is invalid. Choose an active domain explicitly.</div>}
|
|
31
|
+
{parsed != null && selected.isPending && <div className="panel panel-pad dim">Loading the selected domain…</div>}
|
|
32
|
+
{parsed != null && selected.isError && <div className="panel panel-pad text-bad">{selected.error.message}. The selection was preserved; no fallback domain was chosen.</div>}
|
|
33
|
+
{domain && domain.status !== 'active' && <div className="panel panel-pad text-bad">{domain.name} is {domain.status}, not active. The selection was preserved; choose another domain explicitly.</div>}
|
|
34
|
+
{domain?.status === 'active' && <div className="panel panel-pad"><DnsRecordsPanel domain={domain} recordType={recordType} onRecordTypeChange={(value) => update('record_type', value)} /></div>}
|
|
35
|
+
</div>;
|
|
36
|
+
}
|