@rapidmx/web-client 0.37.1 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/apps/admin/configuration-encryption/index.tsx +17 -0
  2. package/apps/admin/domains/[uid].tsx +17 -4
  3. package/apps/admin/integrations/dns-providers/[uid].tsx +205 -0
  4. package/apps/admin/integrations/dns-providers/index.tsx +164 -0
  5. package/apps/admin/integrations/dns-providers/new/index.tsx +55 -0
  6. package/apps/admin/integrations/dns-resolver/index.tsx +16 -0
  7. package/apps/admin/integrations/giphy/index.tsx +16 -0
  8. package/apps/admin/integrations/index.tsx +31 -0
  9. package/apps/admin/integrations/plugin-registry/index.tsx +16 -0
  10. package/apps/admin/integrations/signing-ca/index.tsx +16 -0
  11. package/apps/admin/integrations/stun/index.tsx +16 -0
  12. package/apps/shared/components/admin/configEncryption/ConfigKeysManager.tsx +529 -0
  13. package/apps/shared/components/admin/configEncryption/keyringParts.ts +37 -0
  14. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +20 -4
  15. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +23 -6
  16. package/apps/shared/components/admin/diagnostics/KeyringCard.tsx +144 -0
  17. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +21 -6
  18. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +15 -3
  19. package/apps/shared/components/admin/integrations/DnsProviderForm.tsx +354 -0
  20. package/apps/shared/components/admin/integrations/DnsProviderHosts.tsx +197 -0
  21. package/apps/shared/components/admin/integrations/DnsProviderOperations.tsx +115 -0
  22. package/apps/shared/components/admin/integrations/DnsResolverForm.tsx +61 -0
  23. package/apps/shared/components/admin/integrations/GiphyForm.tsx +59 -0
  24. package/apps/shared/components/admin/integrations/IntegrationSectionForm.tsx +234 -0
  25. package/apps/shared/components/admin/integrations/IntegrationSectionPage.tsx +40 -0
  26. package/apps/shared/components/admin/integrations/IntegrationsLayout.tsx +72 -0
  27. package/apps/shared/components/admin/integrations/PluginRegistryForm.tsx +230 -0
  28. package/apps/shared/components/admin/integrations/SecretField.tsx +91 -0
  29. package/apps/shared/components/admin/integrations/SigningCaForm.tsx +95 -0
  30. package/apps/shared/components/admin/integrations/StunForm.tsx +63 -0
  31. package/apps/shared/components/admin/integrations/dnsProviderParts.tsx +72 -0
  32. package/apps/shared/components/admin/layout/AdminShell.tsx +12 -1
  33. package/apps/shared/components/admin/settings/CopyableValue.tsx +16 -0
  34. package/apps/shared/components/admin/settings/DomainApexTxt.tsx +319 -0
  35. package/apps/shared/components/admin/settings/DomainDkimRotation.tsx +408 -0
  36. package/apps/shared/components/admin/settings/DomainDnsProvider.tsx +147 -0
  37. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +174 -17
  38. package/apps/shared/components/admin/setup/SetupWizard.tsx +39 -6
  39. package/dist/apps/admin/configuration-encryption/index.d.ts +4 -0
  40. package/dist/apps/admin/configuration-encryption/index.js +7 -0
  41. package/dist/apps/admin/domains/[uid].js +11 -4
  42. package/dist/apps/admin/integrations/dns-providers/[uid].d.ts +7 -0
  43. package/dist/apps/admin/integrations/dns-providers/[uid].js +117 -0
  44. package/dist/apps/admin/integrations/dns-providers/index.d.ts +3 -0
  45. package/dist/apps/admin/integrations/dns-providers/index.js +68 -0
  46. package/dist/apps/admin/integrations/dns-providers/new/index.d.ts +3 -0
  47. package/dist/apps/admin/integrations/dns-providers/new/index.js +31 -0
  48. package/dist/apps/admin/integrations/dns-resolver/index.d.ts +3 -0
  49. package/dist/apps/admin/integrations/dns-resolver/index.js +6 -0
  50. package/dist/apps/admin/integrations/giphy/index.d.ts +3 -0
  51. package/dist/apps/admin/integrations/giphy/index.js +6 -0
  52. package/dist/apps/admin/integrations/index.d.ts +4 -0
  53. package/dist/apps/admin/integrations/index.js +7 -0
  54. package/dist/apps/admin/integrations/plugin-registry/index.d.ts +3 -0
  55. package/dist/apps/admin/integrations/plugin-registry/index.js +6 -0
  56. package/dist/apps/admin/integrations/signing-ca/index.d.ts +3 -0
  57. package/dist/apps/admin/integrations/signing-ca/index.js +6 -0
  58. package/dist/apps/admin/integrations/stun/index.d.ts +3 -0
  59. package/dist/apps/admin/integrations/stun/index.js +6 -0
  60. package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.d.ts +9 -0
  61. package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.js +187 -0
  62. package/dist/apps/shared/components/admin/configEncryption/keyringParts.d.ts +10 -0
  63. package/dist/apps/shared/components/admin/configEncryption/keyringParts.js +26 -0
  64. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +20 -4
  65. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +8 -4
  66. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +7 -5
  67. package/dist/apps/shared/components/admin/diagnostics/KeyringCard.d.ts +18 -0
  68. package/dist/apps/shared/components/admin/diagnostics/KeyringCard.js +51 -0
  69. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +9 -1
  70. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +10 -3
  71. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +13 -3
  72. package/dist/apps/shared/components/admin/integrations/DnsProviderForm.d.ts +17 -0
  73. package/dist/apps/shared/components/admin/integrations/DnsProviderForm.js +147 -0
  74. package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.d.ts +10 -0
  75. package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.js +101 -0
  76. package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.d.ts +8 -0
  77. package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.js +60 -0
  78. package/dist/apps/shared/components/admin/integrations/DnsResolverForm.d.ts +7 -0
  79. package/dist/apps/shared/components/admin/integrations/DnsResolverForm.js +15 -0
  80. package/dist/apps/shared/components/admin/integrations/GiphyForm.d.ts +7 -0
  81. package/dist/apps/shared/components/admin/integrations/GiphyForm.js +11 -0
  82. package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.d.ts +54 -0
  83. package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.js +131 -0
  84. package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.d.ts +12 -0
  85. package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.js +21 -0
  86. package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.d.ts +14 -0
  87. package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.js +46 -0
  88. package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.d.ts +7 -0
  89. package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.js +90 -0
  90. package/dist/apps/shared/components/admin/integrations/SecretField.d.ts +25 -0
  91. package/dist/apps/shared/components/admin/integrations/SecretField.js +25 -0
  92. package/dist/apps/shared/components/admin/integrations/SigningCaForm.d.ts +7 -0
  93. package/dist/apps/shared/components/admin/integrations/SigningCaForm.js +25 -0
  94. package/dist/apps/shared/components/admin/integrations/StunForm.d.ts +9 -0
  95. package/dist/apps/shared/components/admin/integrations/StunForm.js +26 -0
  96. package/dist/apps/shared/components/admin/integrations/dnsProviderParts.d.ts +28 -0
  97. package/dist/apps/shared/components/admin/integrations/dnsProviderParts.js +43 -0
  98. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  99. package/dist/apps/shared/components/admin/layout/AdminShell.js +8 -1
  100. package/dist/apps/shared/components/admin/settings/CopyableValue.d.ts +6 -0
  101. package/dist/apps/shared/components/admin/settings/CopyableValue.js +6 -0
  102. package/dist/apps/shared/components/admin/settings/DomainApexTxt.d.ts +13 -0
  103. package/dist/apps/shared/components/admin/settings/DomainApexTxt.js +148 -0
  104. package/dist/apps/shared/components/admin/settings/DomainDkimRotation.d.ts +15 -0
  105. package/dist/apps/shared/components/admin/settings/DomainDkimRotation.js +200 -0
  106. package/dist/apps/shared/components/admin/settings/DomainDnsProvider.d.ts +12 -0
  107. package/dist/apps/shared/components/admin/settings/DomainDnsProvider.js +70 -0
  108. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +3 -3
  109. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +61 -8
  110. package/dist/apps/shared/components/admin/setup/SetupWizard.js +9 -1
  111. package/dist/lib/admin/configKeysApi.d.ts +96 -0
  112. package/dist/lib/admin/configKeysApi.js +34 -0
  113. package/dist/lib/admin/dnsProvidersApi.d.ts +172 -0
  114. package/dist/lib/admin/dnsProvidersApi.js +70 -0
  115. package/dist/lib/admin/domainsApi.d.ts +116 -1
  116. package/dist/lib/admin/domainsApi.js +33 -0
  117. package/dist/lib/admin/integrationsApi.d.ts +75 -0
  118. package/dist/lib/admin/integrationsApi.js +31 -0
  119. package/lib/admin/configKeysApi.ts +117 -0
  120. package/lib/admin/dnsProvidersApi.ts +241 -0
  121. package/lib/admin/domainsApi.ts +145 -1
  122. package/lib/admin/integrationsApi.ts +107 -0
  123. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import "../../../styles/app.css";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useRouter } from "@rapidrest/react/client";
9
- import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, HiOutlineChevronDoubleLeft, HiOutlineChevronDoubleRight, } from "react-icons/hi2";
9
+ import { HiOutlineArrowPathRoundedSquare, HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLink, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, HiOutlineChevronDoubleLeft, HiOutlineChevronDoubleRight, } from "react-icons/hi2";
10
10
  import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
11
11
  import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
12
12
  import { useSessionRefresh } from "../../../../../lib/auth/session.js";
@@ -80,6 +80,13 @@ const NAV_ITEMS = [
80
80
  label: "Signing Certificates",
81
81
  icon: HiOutlineDocumentCheck,
82
82
  },
83
+ { id: "integrations", href: "/admin/integrations", label: "Integrations", icon: HiOutlineLink },
84
+ {
85
+ id: "configurationEncryption",
86
+ href: "/admin/configuration-encryption",
87
+ label: "Configuration Encryption",
88
+ icon: HiOutlineArrowPathRoundedSquare,
89
+ },
83
90
  { id: "plugins", href: "/admin/plugins", label: "Plugins", icon: HiOutlinePuzzlePiece },
84
91
  { id: "diagnostics", href: "/admin/diagnostics", label: "Diagnostics", icon: HiOutlineCpuChip },
85
92
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
3
+ export default function CopyableValue({ value, copyLabel }: {
4
+ value: string;
5
+ copyLabel: string;
6
+ }): React.JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
3
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
4
+ export default function CopyableValue({ value, copyLabel }) {
5
+ return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyIconButton, { value: value, label: copyLabel, className: "shrink-0" })] }));
6
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { Domain } from "../../../../../lib/admin/domainsApi.js";
3
+ export interface DomainApexTxtProps {
4
+ domain: Domain;
5
+ /** Called after a change was saved, so the page can load the domain and its checklist again. */
6
+ onChanged: () => void;
7
+ }
8
+ /**
9
+ * Whether RapidMX owns the domain's root TXT records to publish its SPF record there. A provider that owns a record set owns every value in it, so
10
+ * nothing is published there unless the administrator opts in with the values to keep; while it is managed, values added at the DNS host are removed, so
11
+ * this is the only place to edit them (spec 9.4). Shown for a domain a DNS provider publishes.
12
+ */
13
+ export default function DomainApexTxt({ domain, onChanged }: DomainApexTxtProps): React.JSX.Element;
@@ -0,0 +1,148 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { listDnsProviders } from "../../../../../lib/admin/dnsProvidersApi.js";
8
+ import { getApexTxt, updateApexTxt } from "../../../../../lib/admin/domainsApi.js";
9
+ import Button from "../../../../../lib/components/buttons/Button.js";
10
+ import FormField from "../../../../../lib/components/forms/FormField.js";
11
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
12
+ import Alert from "../ActionAlert.js";
13
+ import { actionErrorMessage } from "../elevation.js";
14
+ const INPUT_CLASS = "w-full text-sm font-mono py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+ /** The values in a "values to keep" box: one per line, trimmed, blank lines dropped, each once. */
16
+ function parseKeep(text) {
17
+ return [...new Set(text.split("\n").map((line) => line.trim()).filter(Boolean))];
18
+ }
19
+ /** What the values to keep start as: while managed the saved list; otherwise the suggestion from the live lookup plus the values kept before an opt-out,
20
+ * which are restored as plain records only after a few minutes and so are not at the root yet. */
21
+ function startingKeep(view) {
22
+ return view.managed ? view.keep : [...new Set([...view.suggestedKeep, ...view.keep])];
23
+ }
24
+ /** TXT values, each in full (they can be long). */
25
+ function Values({ label, values }) {
26
+ return (_jsx("ul", { "aria-label": label, className: "flex flex-col gap-1 mb-3", children: values.map((value) => (_jsx("li", { children: _jsx("code", { className: "text-xs break-all", children: value }) }, value))) }));
27
+ }
28
+ function Card({ children }) {
29
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Root TXT records" }), children] }));
30
+ }
31
+ /**
32
+ * Whether RapidMX owns the domain's root TXT records to publish its SPF record there. A provider that owns a record set owns every value in it, so
33
+ * nothing is published there unless the administrator opts in with the values to keep; while it is managed, values added at the DNS host are removed, so
34
+ * this is the only place to edit them (spec 9.4). Shown for a domain a DNS provider publishes.
35
+ */
36
+ export default function DomainApexTxt({ domain, onChanged }) {
37
+ const [view, setView] = useState(null);
38
+ // Whether the view is what the server said when the page last asked: not while the opt-in dialog is waiting for the values at the root now.
39
+ const [fresh, setFresh] = useState(false);
40
+ // The type of the domain's provider, which decides what it can keep; null while unknown (then the server decides).
41
+ const [providerType, setProviderType] = useState(null);
42
+ const [loadError, setLoadError] = useState(null);
43
+ const [keepText, setKeepText] = useState("");
44
+ const [saving, setSaving] = useState(false);
45
+ const [saved, setSaved] = useState(false);
46
+ const [error, setError] = useState(null);
47
+ const [stopped, setStopped] = useState(false);
48
+ // Which confirmation is open: opting in (with what it will do), or stopping.
49
+ const [confirming, setConfirming] = useState(null);
50
+ // An Azure provider keeps one value per name, so the set holds the SPF record alone; rfc2136 can't keep an SPF value at all (the server's `providerKeepsTxt`).
51
+ const keepsNothing = providerType === "azure";
52
+ const cantManage = providerType === "rfc2136";
53
+ // The newest request, so an answer to an older one is dropped, and whether the keep list holds edits the server hasn't seen (a reload leaves them).
54
+ const latest = useRef(0);
55
+ const dirty = useRef(false);
56
+ function load(resetKeep = true) {
57
+ const request = ++latest.current;
58
+ setLoadError(null);
59
+ return getApexTxt(domain.uid)
60
+ .then((loaded) => {
61
+ if (request !== latest.current) {
62
+ return;
63
+ }
64
+ setView(loaded);
65
+ setFresh(true);
66
+ if (resetKeep && !dirty.current) {
67
+ setKeepText(startingKeep(loaded).join("\n"));
68
+ }
69
+ // The opt-in must not go on without knowing what is at the root.
70
+ if (loaded.lookupFailed) {
71
+ setConfirming((open) => (open === "in" ? null : open));
72
+ }
73
+ })
74
+ .catch((err) => {
75
+ if (request !== latest.current) {
76
+ return;
77
+ }
78
+ setView(null);
79
+ setConfirming(null);
80
+ setLoadError(actionErrorMessage(err, "Could not load the root TXT records."));
81
+ });
82
+ }
83
+ // Again when the domain was saved (its version moved), which is how a change made here arrives.
84
+ useEffect(() => {
85
+ void load();
86
+ }, [domain.uid, domain.dnsProviderUid, domain.version]);
87
+ useEffect(() => {
88
+ let current = true;
89
+ listDnsProviders()
90
+ .then((providers) => current && setProviderType(providers.find((provider) => provider.uid === domain.dnsProviderUid)?.type ?? null))
91
+ .catch(() => current && setProviderType(null));
92
+ return () => {
93
+ current = false;
94
+ };
95
+ }, [domain.dnsProviderUid]);
96
+ async function save(managed) {
97
+ setSaving(true);
98
+ setError(null);
99
+ setSaved(false);
100
+ setStopped(false);
101
+ try {
102
+ const keep = keepsNothing ? [] : parseKeep(keepText);
103
+ await updateApexTxt(domain.uid, managed ? { managed, keep } : { managed });
104
+ dirty.current = false;
105
+ if (managed) {
106
+ // An edit while managed leaves the domain as it was, so the view takes the new list itself; an opt-in arrives with the reloaded domain.
107
+ setView((current) => (current.managed ? { ...current, keep } : current));
108
+ setSaved(true);
109
+ }
110
+ else {
111
+ setStopped(true);
112
+ }
113
+ setConfirming(null);
114
+ onChanged();
115
+ }
116
+ catch (err) {
117
+ setError(actionErrorMessage(err, "Could not change the root TXT records."));
118
+ }
119
+ finally {
120
+ setSaving(false);
121
+ }
122
+ }
123
+ // The values removed are listed from what is at the root when the dialog opens, not when the page loaded; the keep list stays as it is.
124
+ function confirm(kind) {
125
+ setError(null);
126
+ setConfirming(kind);
127
+ if (kind === "in") {
128
+ setFresh(false);
129
+ void load(false);
130
+ }
131
+ }
132
+ if (loadError) {
133
+ return (_jsx(Card, { children: _jsx(Alert, { children: loadError }) }));
134
+ }
135
+ if (!view) {
136
+ return (_jsx(Card, { children: _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }) }));
137
+ }
138
+ const keep = keepsNothing ? [] : parseKeep(keepText);
139
+ const removed = view.current.filter((value) => !keep.includes(value));
140
+ // What a stop restores, and what an earlier stop restored: the SPF value RapidMX published and the values it kept.
141
+ const published = [...(view.spf ? [view.spf] : []), ...view.keep];
142
+ const keepField = keepsNothing ? (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "An Azure DNS provider keeps one value per name, so the root TXT records would hold the SPF record alone and RapidMX would remove every other value." })) : (_jsxs(FormField, { label: "Values to keep", htmlFor: "apexTxtKeep", children: [_jsx("textarea", { id: "apexTxtKeep", className: INPUT_CLASS, rows: Math.max(3, keep.length + 1), value: keepText, onChange: (e) => {
143
+ dirty.current = true;
144
+ setKeepText(e.target.value);
145
+ setSaved(false);
146
+ } }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "One value per line, exactly as it is in the DNS (a site-verification record, for example)." })] }));
147
+ return (_jsxs(Card, { children: [error && confirming === null && _jsx(Alert, { children: error }), stopped && !view.managed && (_jsx("p", { className: "text-sm mb-3", children: "RapidMX no longer manages the root TXT records. The SPF record and the kept values are being restored as plain records; they are missing for a few minutes meanwhile." })), view.managed ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm font-semibold mb-3", children: "Values added at your DNS host are removed within a minute; add them here." }), _jsx("p", { className: "text-sm text-text-muted mb-1", children: "RapidMX publishes this SPF record at the root, and the values to keep below:" }), _jsx(Values, { label: "Published SPF record", values: [view.spf] }), keepField, _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: saving, disabled: saving || keep.join("\n") === view.keep.join("\n"), onClick: () => save(true), children: "Save values to keep" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: () => confirm("out"), children: "Stop managing" }), saved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })) : cantManage ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-text-muted mb-3", children: "An rfc2136 DNS provider can't keep the SPF value among the root TXT records, so RapidMX can't manage them. Add the SPF record and the other root TXT records at your DNS host." }), view.current.length > 0 && _jsx(Values, { label: "Current root TXT records", values: view.current })] })) : view.lookupFailed ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-text-muted mb-3", children: "The root TXT records couldn't be looked up, and taking them over without knowing what is there would delete it. Try again in a moment." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => void load(), children: "Try again" })] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-text-muted mb-3", children: "SPF is a TXT record at the root of the domain, shared with the verification records of other services. RapidMX publishes nothing there unless you opt in, and then it owns every value in the set: it publishes SPF and the values you keep, and removes the rest." }), view.current.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm font-semibold mb-1", children: "At the root now" }), _jsx(Values, { label: "Current root TXT records", values: view.current })] })) : (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "There are no TXT records at the root now." })), published.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm font-semibold mb-1", children: "Restored when RapidMX stopped managing them" }), _jsx(Values, { label: "Restored root TXT records", values: published })] })), keepField, _jsx(Button, { type: "button", className: "!w-auto", onClick: () => confirm("in"), children: "Manage the root TXT records" })] })), _jsxs(Modal, { open: confirming === "in", onClose: () => setConfirming(null), title: "Manage the root TXT records", children: [_jsxs("p", { className: "text-sm mb-3", children: ["RapidMX will publish an SPF record at the root of ", _jsx("strong", { children: domain.name }), " (yours if it already allows this domain's mail servers, otherwise ", _jsx("code", { className: "text-xs", children: "v=spf1 mx ~all" }), "), and these values:"] }), keep.length > 0 ? _jsx(Values, { label: "Values RapidMX will keep", values: keep }) : _jsx("p", { className: "text-sm text-text-muted mb-3", children: "(none)" }), _jsx("p", { className: "text-sm mb-1", children: "At the root now and not on that list, so removed (an SPF record is replaced by the one above):" }), removed.length > 0 ? (_jsx(Values, { label: "Values removed", values: removed })) : (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "Nothing else is there to remove." })), !fresh && !error && _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Checking what is at the root now\u2026" }), _jsx("p", { className: "text-sm mb-3", children: "Anything you add at your DNS host later is removed within a minute; add it here instead." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: () => setConfirming(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: saving, disabled: saving || !fresh, onClick: () => save(true), children: "Manage them" })] })] }), _jsxs(Modal, { open: confirming === "out", onClose: () => setConfirming(null), title: "Stop managing the root TXT records", children: [_jsx("p", { className: "text-sm mb-3", children: "RapidMX lets go of the set and restores these values as plain records you edit at your DNS host. The root TXT records are missing for a few minutes while this happens." }), _jsx(Values, { label: "Values restored", values: published }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: () => setConfirming(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: saving, disabled: saving, onClick: () => save(false), children: "Stop managing" })] })] })] }));
148
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import { DnsRecordCheck, Domain } from "../../../../../lib/admin/domainsApi.js";
3
+ export interface DomainDkimRotationProps {
4
+ domain: Domain;
5
+ /** The domain's DNS checklist, which says how each record is published and carries the rotation's records. */
6
+ checks: DnsRecordCheck[];
7
+ /** Called after a change was made, so the page can load the domain and its checklist again. */
8
+ onChanged: () => void;
9
+ }
10
+ /**
11
+ * A domain's DKIM key rotation (spec 6.2): the active selector and its age, the phase with its next step, "Rotate now", "Key compromised", "Cancel rotation"
12
+ * and the pause of scheduled rotation, and, for a record the administrator publishes by hand, the record to add. Nothing is shown on a server without the
13
+ * rotation route.
14
+ */
15
+ export default function DomainDkimRotation({ domain, checks, onChanged }: DomainDkimRotationProps): React.JSX.Element | null;
@@ -0,0 +1,200 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { cancelDkimRotation, getDkimRotation, rotateDkim, setDkimRotationPaused, } from "../../../../../lib/admin/domainsApi.js";
8
+ import Button from "../../../../../lib/components/buttons/Button.js";
9
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
10
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
11
+ import Alert from "../ActionAlert.js";
12
+ import { actionErrorMessage } from "../elevation.js";
13
+ import CopyableValue from "./CopyableValue.js";
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+ /** How long a manual domain's new record may stay unpublished before the panel reminds the administrator (spec 6.2). */
16
+ const REMINDER_DAYS = 7;
17
+ /** Why a domain's key doesn't rotate: restapi's own refusals of "Rotate now" (`DKIM_ROTATION_REFUSALS`). */
18
+ const BLOCKER_MESSAGES = {
19
+ unsupported: "This deployment's DKIM key provider doesn't rotate keys.",
20
+ ses: "Outbound mail is signed by Amazon SES's Easy DKIM, which rotates its own keys.",
21
+ no_key: "This domain has no DKIM key yet.",
22
+ imported: "This domain's DKIM key was imported by hand, so RapidMX can't rotate it.",
23
+ unknown_key: "The DKIM key provider can't tell whether it holds this domain's key, so it isn't rotated.",
24
+ };
25
+ const REASON_MESSAGES = {
26
+ scheduled: "Started by the schedule.",
27
+ manual: "Started by an administrator.",
28
+ compromised: "Started because the key was reported compromised.",
29
+ };
30
+ /** Whole days from `iso` to `now`, never negative. */
31
+ function daysSince(iso, now) {
32
+ return Math.max(0, Math.floor((now - new Date(iso).getTime()) / DAY_MS));
33
+ }
34
+ function formatDays(days) {
35
+ return days < 1 ? "less than a day" : days === 1 ? "1 day" : `${days} days`;
36
+ }
37
+ function formatDate(iso) {
38
+ return new Date(iso).toLocaleDateString();
39
+ }
40
+ /** Whether the administrator adds `check`'s record by hand: the checklist says so, or says nothing about how it is published. */
41
+ function publishedByHand(check) {
42
+ return !check.publication || check.publication === "manual";
43
+ }
44
+ /** The record to add at the DNS host: its name and value to copy, and whether the live check sees it. */
45
+ function RecordToPublish({ check, noun, showLive }) {
46
+ return (_jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm mb-3", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: check.recordKind }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${noun} record` }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: check.recommendedValue ?? "", copyLabel: `Copy value for the ${noun} record` }) }), showLive && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted py-2", children: "Status" }), _jsx("dd", { className: "py-2", children: check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found yet" })) })] }))] }));
47
+ }
48
+ /**
49
+ * A domain's DKIM key rotation (spec 6.2): the active selector and its age, the phase with its next step, "Rotate now", "Key compromised", "Cancel rotation"
50
+ * and the pause of scheduled rotation, and, for a record the administrator publishes by hand, the record to add. Nothing is shown on a server without the
51
+ * rotation route.
52
+ */
53
+ export default function DomainDkimRotation({ domain, checks, onChanged }) {
54
+ // `undefined` while loading, `null` where the server has no rotation route (an older server).
55
+ const [view, setView] = useState(undefined);
56
+ // Set when the first read failed for a reason other than a missing route, so the card stays and offers to try again.
57
+ const [loadFailed, setLoadFailed] = useState(false);
58
+ const [busy, setBusy] = useState(false);
59
+ const [error, setError] = useState(null);
60
+ const [confirming, setConfirming] = useState(null);
61
+ const latest = useRef(0);
62
+ // Reads the rotation again; an answer to an older request is dropped. Only a 404 means the server has no rotation route; any other failure keeps what
63
+ // is shown, or, where nothing is shown yet, the card with a way to try again.
64
+ function load() {
65
+ const id = ++latest.current;
66
+ return getDkimRotation(domain.uid).then((answer) => {
67
+ if (id === latest.current) {
68
+ setView(answer);
69
+ setLoadFailed(false);
70
+ }
71
+ }, (err) => {
72
+ if (id === latest.current) {
73
+ if (err instanceof ApiRequestError && err.status === 404) {
74
+ setView((current) => current ?? null);
75
+ }
76
+ else {
77
+ setLoadFailed(true);
78
+ }
79
+ }
80
+ });
81
+ }
82
+ // The domain's version moves with every change the rotation job makes, so the panel follows it.
83
+ useEffect(() => {
84
+ void load();
85
+ }, [domain.uid, domain.version]);
86
+ function card(children) {
87
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DKIM key rotation" }), children] }));
88
+ }
89
+ if (view === null || (view === undefined && !loadFailed)) {
90
+ return null;
91
+ }
92
+ if (!view) {
93
+ return card(_jsxs(_Fragment, { children: [_jsx(Alert, { children: "Could not load the DKIM rotation." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => void load(), children: "Reload the rotation" })] }));
94
+ }
95
+ async function run(action, fallback) {
96
+ setBusy(true);
97
+ setError(null);
98
+ try {
99
+ await action();
100
+ setConfirming(null);
101
+ await load();
102
+ onChanged();
103
+ }
104
+ catch (err) {
105
+ setError(actionErrorMessage(err, fallback));
106
+ }
107
+ finally {
108
+ setBusy(false);
109
+ }
110
+ }
111
+ const state = view.rotation ?? { phase: "idle" };
112
+ const phase = state.phase;
113
+ const blockerText = _jsx("p", { className: "text-sm text-text-muted mb-3", children: view.blocker ? BLOCKER_MESSAGES[view.blocker] : "This domain's DKIM key doesn't rotate." });
114
+ // A key that can't rotate offers nothing, unless a rotation is already in progress (the provider was swapped, the key re-imported): that one can still be
115
+ // seen and cancelled, though nothing new starts.
116
+ if (!view.rotatable && phase === "idle") {
117
+ return card(blockerText);
118
+ }
119
+ const rotatable = view.rotatable;
120
+ const next = state.next;
121
+ const now = Date.now();
122
+ // The active key is the new one from the switch on; before, since the last rotation or the domain's creation.
123
+ const activeSince = (phase === "switching" || phase === "overlap") && state.switchedAt ? state.switchedAt : (state.lastRotatedAt ?? domain.dateCreated);
124
+ const reason = phase === "pending" ? next?.reason : phase === "idle" ? undefined : state.reason;
125
+ // Once the key being replaced is reported compromised there is nothing more to report; an overlap is retired at once.
126
+ const canCompromise = rotatable && (phase === "idle" || phase === "overlap" || (phase === "pending" && next?.reason !== "compromised") || (phase === "switching" && state.reason !== "compromised"));
127
+ // An overlap finishes on its own.
128
+ const canCancel = phase === "pending" || phase === "switching";
129
+ const nextCheck = checks.find((check) => check.type === "dkim_next" && check.configured);
130
+ const previousCheck = checks.find((check) => check.type === "dkim_previous" && check.configured);
131
+ const retireAt = state.previous?.retireAt;
132
+ const retired = !!retireAt && new Date(retireAt).getTime() <= now;
133
+ const waitingDays = phase === "pending" && next && !next.liveSince ? daysSince(next.createdAt, now) : 0;
134
+ let step;
135
+ if (phase === "pending" && next) {
136
+ const selector = _jsx("code", { className: "text-xs", children: next.selector });
137
+ if (!next.publicKey) {
138
+ step = _jsxs(_Fragment, { children: ["Creating the new key (selector ", selector, ")."] });
139
+ }
140
+ else if (!next.liveSince) {
141
+ step = (_jsxs(_Fragment, { children: ["Waiting for the new record for ", selector, " to be live.", " ", next.reason === "compromised" ? "Signing switches to it as soon as it is." : "Signing keeps using the current key until then."] }));
142
+ }
143
+ else if (next.reason === "compromised") {
144
+ step = _jsxs(_Fragment, { children: ["The new record is live (since ", formatDate(next.liveSince), "). Signing switches to the new key next."] });
145
+ }
146
+ else {
147
+ step = (_jsxs(_Fragment, { children: ["The new record has been live since ", formatDate(next.liveSince), ". Signing switches to the new key once it has been live long enough for resolvers to drop any cached negative answer."] }));
148
+ }
149
+ }
150
+ else if (phase === "switching") {
151
+ step = _jsx(_Fragment, { children: "Switching: signing has moved to the new key, and the mail server hasn't confirmed it yet." });
152
+ }
153
+ else if (phase === "overlap") {
154
+ step = retireAt ? (_jsxs(_Fragment, { children: ["The old key retires on ", formatDate(retireAt), ". Mail signed with it that is still in transit keeps verifying until then."] })) : (_jsx(_Fragment, { children: "The old key retires shortly." }));
155
+ }
156
+ else {
157
+ step = _jsx(_Fragment, { children: "No rotation is in progress." });
158
+ }
159
+ const scheduleNote = phase !== "idle" ? null : state.paused ? (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "Scheduled rotation is paused." })) : view.nextScheduledAt ? (_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Next scheduled rotation: ", formatDate(view.nextScheduledAt), "."] })) : (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "No rotation is scheduled." }));
160
+ // A record published by hand is shown to copy; one a provider publishes is only mentioned.
161
+ let records = null;
162
+ if (phase === "pending" && nextCheck && next?.publicKey) {
163
+ if (publishedByHand(nextCheck)) {
164
+ records = (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold mb-2", children: nextCheck.matches ? "The record you published is live." : "Publish this record to finish rotating" }), _jsx(RecordToPublish, { check: nextCheck, noun: "new DKIM key", showLive: true }), waitingDays >= REMINDER_DAYS && !nextCheck.matches && (_jsxs("div", { role: "note", className: "text-sm rounded-sm py-3 px-3.5 bg-warning text-warning-contrast", children: ["This record has been waiting for ", waitingDays, " days. Signing doesn't switch until it is live, so add it at your DNS host, or cancel the rotation."] }))] }));
165
+ }
166
+ else if (nextCheck.publication === "conflict") {
167
+ records = _jsx("p", { className: "text-sm text-text-muted mb-3", children: "The DNS provider can't publish this record while another one is in the way; see the checklist above." });
168
+ }
169
+ else {
170
+ records = _jsx("p", { className: "text-sm text-text-muted mb-3", children: "This record is published automatically by the DNS provider." });
171
+ }
172
+ }
173
+ else if ((phase === "switching" || phase === "overlap") && previousCheck && publishedByHand(previousCheck)) {
174
+ records = (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold mb-2", children: retired
175
+ ? "The old key has retired: you may now remove this record."
176
+ : phase === "overlap" && retireAt
177
+ ? `Keep this record published until ${formatDate(retireAt)}, then you may remove it.`
178
+ : "Keep this record published until the old key retires." }), _jsx(RecordToPublish, { check: previousCheck, noun: "previous DKIM key", showLive: false })] }));
179
+ }
180
+ const compromisedText = phase === "overlap"
181
+ ? "The old record and key are removed now, without waiting for the overlap to end. Mail signed with the old key that is still in transit may then fail DKIM."
182
+ : phase === "switching"
183
+ ? "The old record is removed as soon as the mail server confirms the switch, with no overlap. Mail signed with the old key that is still in transit may then fail DKIM."
184
+ : "Signing moves to the new key as soon as its record is live (the publish wait is skipped), and the old record is removed as soon as the mail server confirms the switch, with no overlap. Mail signed with the old key that is still in transit may then fail DKIM.";
185
+ const cancelText = phase === "switching"
186
+ ? "Signing goes back to the previous key, and the new key is deleted. The cancel is refused if the previous key is gone or the mail server can't be pointed back at it. The next scheduled rotation counts from now."
187
+ : "The new key is deleted and its record is no longer published. Signing stays with the current key. The next scheduled rotation counts from now.";
188
+ // The refusal a dialog shows belongs to it, so it goes with it rather than moving to the card.
189
+ function closeDialog() {
190
+ if (!busy) {
191
+ setConfirming(null);
192
+ setError(null);
193
+ }
194
+ }
195
+ function openDialog(which) {
196
+ setError(null);
197
+ setConfirming(which);
198
+ }
199
+ return (_jsxs(_Fragment, { children: [card(_jsxs(_Fragment, { children: [!rotatable && blockerText, domain.dkimSelector && (_jsxs("p", { className: "text-sm mb-3", children: ["Active selector ", _jsx("code", { className: "text-xs", children: domain.dkimSelector }), ", in use for ", formatDays(daysSince(activeSince, now)), "."] })), _jsx("p", { className: "text-sm mb-1", children: step }), reason && _jsx("p", { className: "text-sm text-text-muted mb-3", children: REASON_MESSAGES[reason] }), scheduleNote, state.lastError && _jsx(Alert, { children: state.lastError }), records, error && confirming === null && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [rotatable && phase === "idle" && (_jsx(Button, { type: "button", className: "!w-auto", disabled: busy, onClick: () => void run(() => rotateDkim(domain.uid), "Could not start the rotation."), children: "Rotate now" })), canCompromise && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", disabled: busy, onClick: () => openDialog("compromised"), children: "Key compromised: rotate now" })), canCancel && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => openDialog("cancel"), children: "Cancel rotation" })), rotatable && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => void run(() => setDkimRotationPaused(domain.uid, !state.paused), state.paused ? "Could not resume scheduled rotation." : "Could not pause scheduled rotation."), children: state.paused ? "Resume scheduled rotation" : "Pause scheduled rotation" }))] })] })), _jsxs(Modal, { open: confirming === "compromised", onClose: closeDialog, title: "Key compromised", children: [_jsx("p", { className: "text-sm mb-3", children: compromisedText }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: closeDialog, children: "Back" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy, onClick: () => void run(() => rotateDkim(domain.uid, { compromised: true }), "Could not rotate the key."), children: "Confirm" })] })] }), _jsxs(Modal, { open: confirming === "cancel", onClose: closeDialog, title: "Cancel rotation", children: [_jsx("p", { className: "text-sm mb-3", children: cancelText }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: closeDialog, children: "Back" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy, onClick: () => void run(() => cancelDkimRotation(domain.uid), "Could not cancel the rotation."), children: "Confirm" })] })] })] }));
200
+ }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { Domain } from "../../../../../lib/admin/domainsApi.js";
3
+ export interface DomainDnsProviderProps {
4
+ domain: Domain;
5
+ /** Called after the assignment was saved, so the page can load the domain and its checklist again. */
6
+ onChanged: () => void;
7
+ }
8
+ /**
9
+ * The domain's DNS provider: "Manual", where the administrator adds the records by hand, or the provider config whose external-dns instance publishes
10
+ * them. Nothing is shown on a server without DNS providers.
11
+ */
12
+ export default function DomainDnsProvider({ domain, onChanged }: DomainDnsProviderProps): React.JSX.Element | null;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { listDnsProviders } from "../../../../../lib/admin/dnsProvidersApi.js";
8
+ import { assignDnsProvider } from "../../../../../lib/admin/domainsApi.js";
9
+ import Button from "../../../../../lib/components/buttons/Button.js";
10
+ import FormField from "../../../../../lib/components/forms/FormField.js";
11
+ import Alert from "../ActionAlert.js";
12
+ import { actionErrorMessage } from "../elevation.js";
13
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary disabled:opacity-55";
14
+ /** The length of the longest zone of `provider` that covers `name` (the name itself or a name under it), 0 when none does. */
15
+ function coveringZoneLength(name, provider) {
16
+ return Math.max(0, ...provider.zones.filter((zone) => name === zone || name.endsWith(`.${zone}`)).map((zone) => zone.length));
17
+ }
18
+ /** The providers the select offers: only the one whose zone covers the domain most specifically, since the server accepts no other (another config's
19
+ * zone nested under the parent's is left out of the parent's external-dns instance), and the one assigned now, whatever it covers. */
20
+ function offeredProviders(domain, providers) {
21
+ const lengths = providers.map((provider) => coveringZoneLength(domain.name, provider));
22
+ const longest = Math.max(0, ...lengths);
23
+ return providers.filter((provider, i) => (longest > 0 && lengths[i] === longest) || provider.uid === domain.dnsProviderUid);
24
+ }
25
+ /**
26
+ * The domain's DNS provider: "Manual", where the administrator adds the records by hand, or the provider config whose external-dns instance publishes
27
+ * them. Nothing is shown on a server without DNS providers.
28
+ */
29
+ export default function DomainDnsProvider({ domain, onChanged }) {
30
+ // `undefined` while loading, `null` where the server has no DNS providers (an older server): then there is nothing to choose.
31
+ const [providers, setProviders] = useState(undefined);
32
+ const [selected, setSelected] = useState(domain.dnsProviderUid ?? "");
33
+ const [saving, setSaving] = useState(false);
34
+ const [error, setError] = useState(null);
35
+ const [saved, setSaved] = useState(false);
36
+ useEffect(() => {
37
+ listDnsProviders()
38
+ .then(setProviders)
39
+ .catch(() => setProviders(null));
40
+ }, [domain.uid]);
41
+ useEffect(() => {
42
+ setSelected(domain.dnsProviderUid ?? "");
43
+ }, [domain.dnsProviderUid]);
44
+ if (!providers) {
45
+ return null;
46
+ }
47
+ async function handleSave() {
48
+ setSaving(true);
49
+ setError(null);
50
+ setSaved(false);
51
+ try {
52
+ await assignDnsProvider(domain.uid, selected || null);
53
+ setSaved(true);
54
+ onChanged();
55
+ }
56
+ catch (err) {
57
+ setError(actionErrorMessage(err, "Could not change the DNS provider."));
58
+ }
59
+ finally {
60
+ setSaving(false);
61
+ }
62
+ }
63
+ const assigned = domain.dnsProviderUid ?? "";
64
+ const offered = offeredProviders(domain, providers);
65
+ const locked = !!domain.apexTxt?.managed;
66
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS provider" }), offered.length === 0 && !assigned ? (_jsxs("p", { className: "text-sm text-text-muted", children: ["No DNS provider covers ", domain.name, ". To have its DNS records published automatically, add one under", " ", _jsx("a", { href: "/admin/integrations/dns-providers", className: "text-primary-dark hover:underline", children: "DNS providers" }), " ", "with a zone that includes it. Until then, add the records below at your DNS host."] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-text-muted mb-3", children: "Choose the DNS provider that publishes this domain's records, or add them yourself at your DNS host." }), error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "DNS provider", htmlFor: "dnsProviderUid", children: _jsxs("select", { id: "dnsProviderUid", className: INPUT_CLASS, value: selected, disabled: locked, onChange: (e) => {
67
+ setSelected(e.target.value);
68
+ setSaved(false);
69
+ }, children: [_jsx("option", { value: "", children: "Manual: I publish the records myself" }), offered.map((provider) => (_jsx("option", { value: provider.uid, children: provider.name + (provider.enabled ? "" : " (disabled)") }, provider.uid))), assigned && !providers.some((provider) => provider.uid === assigned) && (_jsxs("option", { value: assigned, children: [assigned, " (no longer exists)"] }))] }) }), locked && (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "RapidMX manages this domain's root TXT records through its provider. Stop managing the root TXT records first, then change the provider." })), assigned && selected !== assigned && (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "Saving removes the records the current provider published for this domain from your DNS, until the new provider has published them (or you have added them yourself)." })), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", "aria-label": "Save DNS provider", loading: saving, disabled: saving || locked || selected === assigned, onClick: handleSave, children: "Save" }), saved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] }))] }));
70
+ }
@@ -1,9 +1,9 @@
1
1
  import React from "react";
2
- import { Domain } from "../../../../../lib/admin/domainsApi.js";
2
+ import { DnsRecordCheck, Domain } from "../../../../../lib/admin/domainsApi.js";
3
3
  export interface DomainDnsSetupProps {
4
4
  uid: string;
5
- /** Called with the domain every time it's (re)loaded - e.g. after "Verify now". */
6
- onLoaded?: (domain: Domain) => void;
5
+ /** Called with the domain and its checklist every time they are (re)loaded - e.g. after "Verify now". */
6
+ onLoaded?: (domain: Domain, checks: DnsRecordCheck[]) => void;
7
7
  }
8
8
  /**
9
9
  * A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain