@rapidmx/web-client 0.37.1 → 0.39.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.
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 +38 -37
@@ -4,16 +4,21 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useState } from "react";
7
+ import { takeoverDnsRecords } from "../../../../../lib/admin/dnsProvidersApi.js";
7
8
  import { getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
8
9
  import Alert from "../ActionAlert.js";
9
10
  import Button from "../../../../../lib/components/buttons/Button.js";
10
- import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
11
+ import CopyableValue from "./CopyableValue.js";
12
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
11
13
  import { actionErrorMessage } from "../elevation.js";
14
+ import { TypeMismatchNotice } from "../integrations/dnsProviderParts.js";
12
15
  const RECORD_TYPE_LABELS = {
13
16
  ownership: "Ownership (TXT)",
14
17
  mx: "MX",
15
18
  spf: "SPF",
16
19
  dkim: "DKIM",
20
+ dkim_next: "DKIM (new key)",
21
+ dkim_previous: "DKIM (previous key)",
17
22
  dmarc: "DMARC",
18
23
  openpgpkey_cname: "OpenPGP keys (CNAME)",
19
24
  autodiscover_cname: "Autodiscover (CNAME)",
@@ -25,6 +30,8 @@ const RECORD_TYPE_NAMES = {
25
30
  mx: "MX",
26
31
  spf: "SPF",
27
32
  dkim: "DKIM",
33
+ dkim_next: "new DKIM key",
34
+ dkim_previous: "previous DKIM key",
28
35
  dmarc: "DMARC",
29
36
  openpgpkey_cname: "OpenPGP keys CNAME",
30
37
  autodiscover_cname: "Autodiscover CNAME",
@@ -37,6 +44,13 @@ const RECORD_TYPE_HELP = {
37
44
  autodiscover_cname: "So EAS and Outlook clients can find this server automatically from just an email address. This hostname also needs to be covered by this server's TLS certificate.",
38
45
  autodiscover_srv: "Same purpose as the CNAME above, for clients that check DNS SRV instead - and, unlike the CNAME, needs no extra TLS certificate, since it points at a hostname already covered. Prefer this one when a second certificate is hard to obtain.",
39
46
  };
47
+ /** Why a record is added by hand, shown under "Manual" in the Published column. */
48
+ const MANUAL_REASONS = {
49
+ no_provider: "No DNS provider is assigned to this domain.",
50
+ kind_unsupported: "The DNS provider can't hold this kind of record.",
51
+ outside_zones: "This name is outside the zones of the DNS provider.",
52
+ apex_txt_unmanaged: "Shares the domain's root TXT records, which you opt in to manage below.",
53
+ };
40
54
  /** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
41
55
  function splitMxValue(value) {
42
56
  const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
@@ -47,10 +61,6 @@ function splitSrvValue(value) {
47
61
  const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/.exec(value.trim());
48
62
  return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
49
63
  }
50
- /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
51
- function CopyableValue({ value, copyLabel }) {
52
- 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" })] }));
53
- }
54
64
  /**
55
65
  * A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
56
66
  * detail page and the setup wizard.
@@ -62,6 +72,11 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
62
72
  const [error, setError] = useState(null);
63
73
  const [verifying, setVerifying] = useState(false);
64
74
  const [verifyError, setVerifyError] = useState(null);
75
+ // The conflicting record the administrator is being asked to take over, the request in flight, why it failed, and the records whose takeover was queued.
76
+ const [takeOver, setTakeOver] = useState(null);
77
+ const [takingOver, setTakingOver] = useState(false);
78
+ const [takeOverError, setTakeOverError] = useState(null);
79
+ const [takeOverQueued, setTakeOverQueued] = useState([]);
65
80
  function reload(id) {
66
81
  setLoading(true);
67
82
  setError(null);
@@ -69,7 +84,7 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
69
84
  .then(([d, checks]) => {
70
85
  setDomain(d);
71
86
  setDnsSetup(checks);
72
- onLoaded?.(d);
87
+ onLoaded?.(d, checks);
73
88
  })
74
89
  .catch((err) => setError(actionErrorMessage(err, "Could not load this domain.")))
75
90
  .finally(() => setLoading(false));
@@ -88,7 +103,7 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
88
103
  const [d, checks] = await Promise.all([getDomain(domain.uid), getDnsSetup(domain.uid)]);
89
104
  setDomain(d);
90
105
  setDnsSetup(checks);
91
- onLoaded?.(d);
106
+ onLoaded?.(d, checks);
92
107
  }
93
108
  catch (err) {
94
109
  setVerifyError(actionErrorMessage(err, "Could not verify this domain."));
@@ -97,6 +112,23 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
97
112
  setVerifying(false);
98
113
  }
99
114
  }
115
+ // The record is taken over through the provider the domain is assigned to (a conflict only exists with one). Only the DNS controller applies it, so
116
+ // the list shows the result some time later: "Check again" rather than a refresh that would still show the conflict.
117
+ async function handleTakeOver() {
118
+ setTakingOver(true);
119
+ setTakeOverError(null);
120
+ try {
121
+ await takeoverDnsRecords(domain.dnsProviderUid, domain.uid, [takeOver.type]);
122
+ setTakeOverQueued((queued) => [...queued, takeOver.type]);
123
+ setTakeOver(null);
124
+ }
125
+ catch (err) {
126
+ setTakeOverError(actionErrorMessage(err, "Could not queue the takeover."));
127
+ }
128
+ finally {
129
+ setTakingOver(false);
130
+ }
131
+ }
100
132
  if (loading) {
101
133
  return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
102
134
  }
@@ -106,7 +138,28 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
106
138
  const ownership = dnsSetup.find((c) => c.type === "ownership");
107
139
  const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
108
140
  const ownershipName = ownership?.recordName || domain.name;
109
- return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" }), domain.aliasOf && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Alias of" }), _jsxs("dd", { children: [domain.aliasOf, " ", _jsx("span", { className: "text-xs text-text-muted", children: "(no mailboxes of its own)" })] })] }))] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _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: ownershipName, copyLabel: "Copy name for the ownership TXT 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: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_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 setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] }), RECORD_TYPE_HELP[check.type] && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: RECORD_TYPE_HELP[check.type] }))] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : 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" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
141
+ // A server that doesn't publish says nothing about how a record gets published, so the column stays out.
142
+ const showPublished = dnsSetup.some((c) => c.publication);
143
+ const openpgpkey = dnsSetup.find((c) => c.type === "openpgpkey_cname");
144
+ return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" }), domain.aliasOf && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Alias of" }), _jsxs("dd", { children: [domain.aliasOf, " ", _jsx("span", { className: "text-xs text-text-muted", children: "(no mailboxes of its own)" })] })] }))] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _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: ownershipName, copyLabel: "Copy name for the ownership TXT 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: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_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 setup checklist" }), openpgpkey?.configured && !openpgpkey.matches && (_jsxs("div", { role: "note", "aria-label": "OpenPGP key host warning", className: "text-sm rounded-sm py-3 px-3.5 mb-3 bg-warning text-warning-contrast", children: [openpgpkey.recordName, " doesn't resolve to this deployment yet. Until it does, other people's OpenPGP apps can't find your users' keys (Web Key Directory), and this server can't get a certificate for it.", openpgpkey.publication === "pending" && " It is being published automatically."] })), takeOverQueued.length > 0 && (_jsxs("p", { className: "text-sm mb-3", children: ["Takeover queued for the ", takeOverQueued.map((type) => RECORD_TYPE_NAMES[type]).join(", "), " record. The DNS controller overwrites it shortly, and it shows as published here once it is live. If it stays in conflict, the result of the takeover is under Operations on", " ", _jsx("a", { href: `/admin/integrations/dns-providers/${encodeURIComponent(domain.dnsProviderUid)}`, className: "text-primary-dark hover:underline", children: "the DNS provider's page" }), ".", " ", _jsx("button", { type: "button", className: "font-semibold underline", onClick: () => {
145
+ setTakeOverQueued([]);
146
+ void reload(uid);
147
+ }, children: "Check again" })] })), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", ...(showPublished ? ["Published"] : []), "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] }), RECORD_TYPE_HELP[check.type] && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: RECORD_TYPE_HELP[check.type] })), check.legacyApex && (_jsxs("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: ["Found at the domain's root (the old location). Publish it at ", _jsx("strong", { children: check.recordName }), "; the root value can then be removed."] }))] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : 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" })) }), showPublished && (_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: _jsx(PublishedCell, { check: check, queued: takeOverQueued.includes(check.type), onTakeOver: () => {
148
+ setTakeOverError(null);
149
+ setTakeOver(check);
150
+ } }) })), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] })), _jsx(Modal, { open: takeOver !== null, onClose: () => setTakeOver(null), title: "Take over this record", children: takeOver && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm mb-3", children: ["Taking over overwrites the record at ", _jsx("code", { className: "text-xs break-all", children: takeOver.recordName }), " with the value below. From then on RapidMX manages it, and whatever relied on the old value stops getting it."] }), _jsxs("dl", { className: "text-sm flex flex-col gap-1 mb-3", children: [_jsx("dt", { className: "text-text-muted", children: "Existing value" }), _jsx("dd", { children: takeOver.actualValue ? _jsx("code", { className: "text-xs break-all", children: takeOver.actualValue }) : "(none found)" }), _jsx("dt", { className: "text-text-muted mt-2", children: "New value" }), _jsx("dd", { children: _jsx("code", { className: "text-xs break-all", children: takeOver.recommendedValue }) })] }), takeOverError && _jsx(Alert, { children: takeOverError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: takingOver, onClick: () => setTakeOver(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: takingOver, disabled: takingOver, onClick: handleTakeOver, children: "Take over" })] })] })) })] }));
151
+ }
152
+ /** How a record is published, for the Published column: automatically (with its state; for a conflict also the value in the way and the takeover), or
153
+ * by hand, with the reason. */
154
+ function PublishedCell({ check, queued, onTakeOver }) {
155
+ if (!check.publication) {
156
+ return _jsx("span", { className: "text-xs text-text-muted", children: "\u2014" });
157
+ }
158
+ if (check.publication === "manual") {
159
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Manual" }), check.manualReason && _jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: MANUAL_REASONS[check.manualReason] })] }));
160
+ }
161
+ const state = check.publication;
162
+ return (_jsxs(_Fragment, { children: [_jsxs("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${state === "published" ? "bg-success text-white" : state === "conflict" ? "bg-danger-bg text-danger" : "bg-surface-alt text-text-muted"}`, children: ["Automatic: ", state] }), state === "conflict" && check.conflictReason === "type_mismatch" && _jsx(TypeMismatchNotice, { existing: check.existing }), state === "conflict" && check.conflictReason !== "type_mismatch" && (_jsxs("div", { className: "text-xs mt-1 max-w-xs", children: [check.actualValue ? (_jsxs(_Fragment, { children: ["Not published by RapidMX, and in the way: ", _jsx("code", { className: "break-all", children: check.actualValue })] })) : ("A record is there that this deployment doesn't own."), check.type !== "spf" && (_jsx("div", { className: "mt-1.5", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1", "aria-label": `Take over ${RECORD_TYPE_NAMES[check.type]}`, disabled: queued, onClick: onTakeOver, children: queued ? "Queued" : "Take over" }) }))] }))] }));
110
163
  }
111
164
  /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), a SRV value as
112
165
  * its four form fields (priority, weight, port and target), anything else whole. */
@@ -176,6 +176,9 @@ function DomainStep({ domains, onCreated }) {
176
176
  const [name, setName] = useState("");
177
177
  const [error, setError] = useState(null);
178
178
  const [saving, setSaving] = useState(false);
179
+ // The installation adds the domains it was installed with before anyone signs in: they're shown as already added.
180
+ const seeded = domains.filter((domain) => domain.origin === "seed");
181
+ const added = domains.filter((domain) => domain.origin !== "seed");
179
182
  async function handleSubmit(e) {
180
183
  e.preventDefault();
181
184
  setError(null);
@@ -195,7 +198,12 @@ function DomainStep({ domains, onCreated }) {
195
198
  setSaving(false);
196
199
  }
197
200
  }
198
- return (_jsxs("div", { className: "flex flex-col gap-5 max-w-3xl", children: [domains.map((domain) => (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h3", { className: "text-base font-bold", children: domain.name }), _jsx(DomainDnsSetup, { uid: domain.uid })] }, domain.uid))), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: CARD_CLASS, children: [_jsx(FormField, { label: domains.length === 0 ? "Domain name" : "Add another domain", htmlFor: "setupDomainName", children: _jsx("input", { id: "setupDomainName", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Add domain" })] })] }));
201
+ return (_jsxs("div", { className: "flex flex-col gap-5 max-w-3xl", children: [seeded.length > 0 && (_jsxs("section", { "aria-labelledby": "setupSeededDomains", className: "flex flex-col gap-5", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("h3", { id: "setupSeededDomains", className: "text-sm font-bold uppercase tracking-wide", children: "Added from your installation" }), _jsx("p", { className: "text-sm text-text-muted", children: "Your installation already added these domains. Publish the DNS records for each one." })] }), seeded.map((domain) => (_jsx(DomainPanel, { domain: domain, grouped: true }, domain.uid)))] })), seeded.length > 0 && added.length > 0 ? (_jsxs("section", { "aria-labelledby": "setupAddedDomains", className: "flex flex-col gap-5", children: [_jsx("h3", { id: "setupAddedDomains", className: "text-sm font-bold uppercase tracking-wide", children: "Domains you added" }), added.map((domain) => (_jsx(DomainPanel, { domain: domain, grouped: true }, domain.uid)))] })) : (added.map((domain) => _jsx(DomainPanel, { domain: domain }, domain.uid))), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: CARD_CLASS, children: [_jsx(FormField, { label: domains.length === 0 ? "Domain name" : "Add another domain", htmlFor: "setupDomainName", children: _jsx("input", { id: "setupDomainName", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Add domain" })] })] }));
202
+ }
203
+ // `grouped`: the panel sits under a region's own heading, so its domain heading is one level lower.
204
+ function DomainPanel({ domain, grouped = false }) {
205
+ const Heading = grouped ? "h4" : "h3";
206
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(Heading, { className: "text-base font-bold", children: domain.name }), _jsx(DomainDnsSetup, { uid: domain.uid })] }));
199
207
  }
200
208
  function MailboxesStep({ userUid, domain }) {
201
209
  const client = useApiClient();
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Typed wrappers over `@rapidmx/restapi`'s `BaseConfigKeyRoute` (mounted at `system/config-keys`): the keyring that encrypts the credentials, API keys and
3
+ * tokens saved in the console, and the rotation of its keys. `GET` needs a trusted role; both writes a trusted role and an elevated token. Never any key
4
+ * material: a key is its `kid`, its age and how many values it encrypts.
5
+ */
6
+ import { ApiClient } from "../util/api.js";
7
+ /** Where a rotation is (restapi `ConfigKeyRotationPhase`). */
8
+ export type ConfigKeyRotationPhase = "adding" | "activating" | "reencrypting" | "retiring" | "done" | "failed";
9
+ /** One key of the ring (restapi `PublicConfigKey`). */
10
+ export interface ConfigKeyInfo {
11
+ kid: string;
12
+ /** ISO 8601. */
13
+ createdAt: string;
14
+ /** Whether the server copy that answered encrypts with it. */
15
+ active: boolean;
16
+ /** The values encrypted with it at the end of the last re-encryption pass; left out until one has counted. */
17
+ envelopes?: number;
18
+ }
19
+ /** The current or last rotation (restapi `PublicConfigKeyRotation`). */
20
+ export interface ConfigKeyRotation {
21
+ /** The new key, once it is in the keyring. */
22
+ kid?: string;
23
+ /** The keys it replaces. */
24
+ previousKids: string[];
25
+ compromised: boolean;
26
+ requestedBy: string;
27
+ requestedAt: string;
28
+ phase: ConfigKeyRotationPhase;
29
+ activationStartedAt?: string;
30
+ /** The server copies (pod name and process id) that haven't yet loaded the new key while it is `activating`, and, once activated, those that don't yet
31
+ * encrypt with it. */
32
+ waitingFor?: string[];
33
+ activatedAt?: string;
34
+ /** Set by "Activate anyway", with the copies that hadn't loaded the key then. */
35
+ activationForced?: {
36
+ by: string;
37
+ at: string;
38
+ staleCopies: string[];
39
+ };
40
+ finishedAt?: string;
41
+ /** What failed (a keyring write refused, for example), never a value. */
42
+ lastError?: string;
43
+ /** Why the new key is still not active and what resolves it, once the wait has gone on past `config_encryption:activation:warn_minutes`. */
44
+ warning?: string;
45
+ }
46
+ /** A saved value the last re-encryption pass couldn't decrypt: named by record and field, never its value. */
47
+ export interface ConfigKeyUnreadable {
48
+ record: string;
49
+ uid: string;
50
+ field: string;
51
+ /** The key the envelope names. */
52
+ kid: string;
53
+ }
54
+ export interface ConfigKeyReencryption {
55
+ targetKid: string;
56
+ /** Whether the last pass saw every row and saved every change. */
57
+ complete: boolean;
58
+ startedAt: string;
59
+ finishedAt?: string;
60
+ unreadable?: ConfigKeyUnreadable[];
61
+ }
62
+ /** The plugin secret settings' encryption (spec 5.7). */
63
+ export interface PluginSecretsState {
64
+ /** When the first encryption run completed; until then secret settings are saved in plain text. */
65
+ encryptedAt?: string;
66
+ waitingForRollout?: boolean;
67
+ checkedAt?: string;
68
+ /** Why the run is still waiting (old pods, or the pods can't be listed). */
69
+ reason?: string;
70
+ }
71
+ /** What `GET /system/config-keys` and both writes answer (restapi `PublicConfigKeys`). */
72
+ export interface ConfigKeys {
73
+ /** Whether a rotation can be started: the keyring is in a store the server can write, and the server copies can be coordinated. */
74
+ available: boolean;
75
+ activeKid: string | null;
76
+ keys: ConfigKeyInfo[];
77
+ /** When the values were last counted. */
78
+ countedAt: string | null;
79
+ rotation: ConfigKeyRotation | null;
80
+ reencryption: ConfigKeyReencryption | null;
81
+ pluginSecrets: PluginSecretsState | null;
82
+ }
83
+ /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
84
+ * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
85
+ * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
86
+ export declare function getConfigKeys(client?: ApiClient): Promise<ConfigKeys>;
87
+ /** Starts a rotation of the configuration key. With `compromised`, it also marks a rotation already running as compromised. Needs an elevated token. A 409
88
+ * (one is running) carries its progress: see `configKeysOfRefusal()`. */
89
+ export declare function rotateConfigKey(input?: {
90
+ compromised?: boolean;
91
+ }, client?: ApiClient): Promise<ConfigKeys>;
92
+ /** "Activate anyway": makes the rotation's new key the one that encrypts before every server copy has loaded it. A 409 (the rotation isn't waiting to be
93
+ * activated, or `kid` isn't its key) carries its progress. Needs an elevated token. */
94
+ export declare function activateConfigKey(kid: string, client?: ApiClient): Promise<ConfigKeys>;
95
+ /** The keyring's state a 409 carries as its body (a rotation already running, or not waiting to be activated), `undefined` for any other failure. */
96
+ export declare function configKeysOfRefusal(err: unknown): ConfigKeys | undefined;
@@ -0,0 +1,34 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Typed wrappers over `@rapidmx/restapi`'s `BaseConfigKeyRoute` (mounted at `system/config-keys`): the keyring that encrypts the credentials, API keys and
7
+ * tokens saved in the console, and the rotation of its keys. `GET` needs a trusted role; both writes a trusted role and an elevated token. Never any key
8
+ * material: a key is its `kid`, its age and how many values it encrypts.
9
+ */
10
+ import { ApiRequestError, withClient } from "../util/api.js";
11
+ /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
12
+ * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
13
+ * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
14
+ export function getConfigKeys(client) {
15
+ return withClient(client, "/system/config-keys");
16
+ }
17
+ /** Starts a rotation of the configuration key. With `compromised`, it also marks a rotation already running as compromised. Needs an elevated token. A 409
18
+ * (one is running) carries its progress: see `configKeysOfRefusal()`. */
19
+ export function rotateConfigKey(input = {}, client) {
20
+ return withClient(client, "/system/config-keys/rotate", { method: "POST", body: JSON.stringify(input) });
21
+ }
22
+ /** "Activate anyway": makes the rotation's new key the one that encrypts before every server copy has loaded it. A 409 (the rotation isn't waiting to be
23
+ * activated, or `kid` isn't its key) carries its progress. Needs an elevated token. */
24
+ export function activateConfigKey(kid, client) {
25
+ return withClient(client, "/system/config-keys/activate", { method: "POST", body: JSON.stringify({ kid }) });
26
+ }
27
+ /** The keyring's state a 409 carries as its body (a rotation already running, or not waiting to be activated), `undefined` for any other failure. */
28
+ export function configKeysOfRefusal(err) {
29
+ if (!(err instanceof ApiRequestError) || err.status !== 409) {
30
+ return undefined;
31
+ }
32
+ const body = err.details;
33
+ return body && typeof body === "object" && Array.isArray(body.keys) ? body : undefined;
34
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Typed wrappers over `@rapidmx/restapi`'s `BaseDnsProviderRoute` (mounted at `system/dns-providers`): the DNS provider configs the server publishes
3
+ * domain records through, the provider types the console's form is built from, the operations the DNS controller runs for a config, and what the
4
+ * controller last said about itself. Reads need a trusted role; every write a trusted role and an elevated token. Credentials are write-only.
5
+ */
6
+ import { ApiClient } from "../util/api.js";
7
+ import type { DnsConflictReason, DnsExistingRecord, DnsManualReason, DnsPublicationState, DnsRecordType } from "./domainsApi.js";
8
+ import type { SecretMask } from "./integrationsApi.js";
9
+ export type DnsProviderType = "cloudflare" | "route53" | "rfc2136" | "google" | "azure";
10
+ export type DnsProviderOptionValue = string | number | boolean | string[];
11
+ export type DnsProviderOptionKind = "string" | "string-list" | "integer" | "boolean" | "enum";
12
+ /** One non-secret option of a provider type (restapi `DnsProviderTypeInfo["options"]`). */
13
+ export interface DnsProviderOptionSpec {
14
+ key: string;
15
+ label: string;
16
+ kind: DnsProviderOptionKind;
17
+ required: boolean;
18
+ /** What a value left out is saved as. */
19
+ default?: string | number | boolean;
20
+ /** The choices of an `enum`. */
21
+ values?: string[];
22
+ min?: number;
23
+ max?: number;
24
+ maxItems?: number;
25
+ description: string;
26
+ format?: string;
27
+ }
28
+ /** One credential field of a provider type: saved encrypted on the config, never shown again. */
29
+ export interface DnsProviderSecretFieldSpec {
30
+ key: string;
31
+ label: string;
32
+ /** Always needed. One needed only in some configurations says when in `description`. */
33
+ required: boolean;
34
+ /** A file's content (Google's service-account JSON) rather than a token. */
35
+ multiLine?: boolean;
36
+ description: string;
37
+ format?: string;
38
+ }
39
+ /** What `GET /system/dns-providers/types` answers per type. */
40
+ export interface DnsProviderTypeInfo {
41
+ type: DnsProviderType;
42
+ label: string;
43
+ externalDnsProvider: string;
44
+ /** Whether the type has been tested against the real service; the console marks the others "not yet live-tested". */
45
+ tested: boolean;
46
+ options: DnsProviderOptionSpec[];
47
+ secretFields: DnsProviderSecretFieldSpec[];
48
+ unsupportedRecordKinds: ("MX" | "SRV")[];
49
+ notes: string[];
50
+ }
51
+ /** What the DNS controller last reported about a config's external-dns instance; every field optional. */
52
+ export interface DnsProviderStatus {
53
+ reportedAt?: string;
54
+ readyReplicas?: number;
55
+ image?: string;
56
+ lastRolloutAt?: string;
57
+ lastSyncAt?: string;
58
+ registryErrors?: number;
59
+ sourceErrors?: number;
60
+ providerErrors?: number;
61
+ ownerMismatchRecords?: number;
62
+ lastError?: string;
63
+ }
64
+ /** A provider config as the server shows it (restapi `PublicDnsProvider`): its credentials only as masks, one per key saved. */
65
+ export interface DnsProvider {
66
+ uid: string;
67
+ name: string;
68
+ type: DnsProviderType;
69
+ zones: string[];
70
+ options: Record<string, DnsProviderOptionValue>;
71
+ credentials: Record<string, SecretMask>;
72
+ origin?: "installer" | "console";
73
+ enabled: boolean;
74
+ allowTakeover: boolean;
75
+ status?: DnsProviderStatus;
76
+ /** The domains assigned to this config. */
77
+ domainCount: number;
78
+ version: number;
79
+ dateCreated?: string;
80
+ dateModified?: string;
81
+ }
82
+ /** What a create or an update sends. `credentials` is write-only: a string sets a field, `null` clears it, leaving it out keeps it. */
83
+ export interface DnsProviderInput {
84
+ name?: string;
85
+ /** Create only: a config's type never changes. */
86
+ type?: DnsProviderType;
87
+ zones?: string[];
88
+ options?: Record<string, DnsProviderOptionValue>;
89
+ credentials?: Record<string, string | null>;
90
+ enabled?: boolean;
91
+ allowTakeover?: boolean;
92
+ /** Update only: the version the change was made against; a newer one is a 409. */
93
+ version?: number;
94
+ }
95
+ export type DnsOperationType = "test" | "takeover" | "restore";
96
+ export type DnsOperationState = "queued" | "running" | "succeeded" | "failed";
97
+ /** One operation the DNS controller runs for a config (restapi `PublicDnsOperation`). */
98
+ export interface DnsOperation {
99
+ uid: string;
100
+ type: DnsOperationType;
101
+ providerUid: string;
102
+ domainUid?: string;
103
+ /** `hosts-<provider uid>` for the takeover of the deployment's own host names, which has no domain. */
104
+ endpointSetId?: string;
105
+ state: DnsOperationState;
106
+ /** The controller's result, or why the operation was cancelled (`Cancelled: ...`). */
107
+ message?: string;
108
+ requestedBy?: string;
109
+ startedAt?: string;
110
+ finishedAt?: string;
111
+ dateCreated?: string;
112
+ }
113
+ /** What one status report of the controller is remembered by. */
114
+ export interface DnsControllerReport {
115
+ controllerVersion: string;
116
+ desiredStateVersion?: number;
117
+ leaseHolder: boolean;
118
+ leaseIdentity: string;
119
+ at: string;
120
+ }
121
+ /** What `GET /system/dns-providers/controller` answers: what the DNS controller last said about itself, `null` for what it never said. */
122
+ export interface DnsControllerState {
123
+ handshake: {
124
+ controllerVersion: string;
125
+ apiVersions: string[];
126
+ compatible: boolean;
127
+ selected?: string;
128
+ reason?: string;
129
+ at: string;
130
+ } | null;
131
+ lastReport: DnsControllerReport | null;
132
+ lastStandbyReport: DnsControllerReport | null;
133
+ }
134
+ /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
135
+ * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
136
+ * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
137
+ export declare function listDnsProviders(client?: ApiClient): Promise<DnsProvider[]>;
138
+ export declare function listDnsProviderTypes(client?: ApiClient): Promise<DnsProviderTypeInfo[]>;
139
+ export declare function getDnsControllerState(client?: ApiClient): Promise<DnsControllerState>;
140
+ export declare function getDnsProvider(uid: string, client?: ApiClient): Promise<DnsProvider>;
141
+ export declare function createDnsProvider(input: DnsProviderInput, client?: ApiClient): Promise<DnsProvider>;
142
+ export declare function updateDnsProvider(uid: string, input: DnsProviderInput, client?: ApiClient): Promise<DnsProvider>;
143
+ /** Refused (409) while a domain is assigned to the config or a restore through it is still to run. */
144
+ export declare function deleteDnsProvider(uid: string, client?: ApiClient): Promise<void>;
145
+ /** Queues a credentials test the DNS controller runs; answers the queued operation. */
146
+ export declare function testDnsProvider(uid: string, client?: ApiClient): Promise<DnsOperation>;
147
+ /** Queues the takeover of a domain's records that this deployment doesn't own yet: the DNS controller overwrites them with the wanted values. `records`
148
+ * are the record types to take over, all the domain publishes through the config when left out; never the root TXT set (`spf`). Answers the queued
149
+ * operation. */
150
+ export declare function takeoverDnsRecords(uid: string, domainUid: string, records?: DnsRecordType[], client?: ApiClient): Promise<DnsOperation>;
151
+ /** Queues the takeover of the deployment's own host names (`mail.<domain>`, `auth.<domain>` and the like) that this config publishes and that something
152
+ * else created: the DNS controller overwrites them with the wanted values. `records` are the host names to take over, all the config publishes when left
153
+ * out; a host whose records of another type are in the way (`type_mismatch`) is left out, or refused when named. Answers the queued operation. */
154
+ export declare function takeoverDnsHosts(uid: string, records?: string[], client?: ApiClient): Promise<DnsOperation>;
155
+ /** Why a host name isn't published automatically: a domain record's reasons, or no public address or no mail host to point at. */
156
+ export type DnsHostManualReason = DnsManualReason | "no_addresses" | "no_target";
157
+ /** How one record of the deployment's own host names is published (`GET /system/dns-providers/hosts`, restapi `DnsRecordPublication`). */
158
+ export interface DnsHostPublication {
159
+ type: string;
160
+ recordName: string;
161
+ recordKind: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "SRV";
162
+ state: DnsPublicationState;
163
+ manualReason?: DnsHostManualReason;
164
+ conflictReason?: DnsConflictReason;
165
+ existing?: DnsExistingRecord[];
166
+ /** The config that publishes it, which a takeover names. */
167
+ providerUid?: string;
168
+ }
169
+ /** The publication of each of the deployment's own host names, looked up live. Needs a trusted role; a server without DNS publishing answers 404. */
170
+ export declare function listDnsHostPublications(client?: ApiClient): Promise<DnsHostPublication[]>;
171
+ /** The config's latest operations, newest first. */
172
+ export declare function listDnsProviderOperations(uid: string, client?: ApiClient): Promise<DnsOperation[]>;
@@ -0,0 +1,70 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Typed wrappers over `@rapidmx/restapi`'s `BaseDnsProviderRoute` (mounted at `system/dns-providers`): the DNS provider configs the server publishes
7
+ * domain records through, the provider types the console's form is built from, the operations the DNS controller runs for a config, and what the
8
+ * controller last said about itself. Reads need a trusted role; every write a trusted role and an elevated token. Credentials are write-only.
9
+ */
10
+ import { withClient } from "../util/api.js";
11
+ const BASE = "/system/dns-providers";
12
+ /** The path of the config `uid`. */
13
+ function providerPath(uid) {
14
+ return `${BASE}/${encodeURIComponent(uid)}`;
15
+ }
16
+ /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
17
+ * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
18
+ * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
19
+ export function listDnsProviders(client) {
20
+ return withClient(client, BASE);
21
+ }
22
+ export function listDnsProviderTypes(client) {
23
+ return withClient(client, `${BASE}/types`);
24
+ }
25
+ export function getDnsControllerState(client) {
26
+ return withClient(client, `${BASE}/controller`);
27
+ }
28
+ export function getDnsProvider(uid, client) {
29
+ return withClient(client, providerPath(uid));
30
+ }
31
+ export function createDnsProvider(input, client) {
32
+ return withClient(client, BASE, { method: "POST", body: JSON.stringify(input) });
33
+ }
34
+ export function updateDnsProvider(uid, input, client) {
35
+ return withClient(client, providerPath(uid), { method: "PUT", body: JSON.stringify(input) });
36
+ }
37
+ /** Refused (409) while a domain is assigned to the config or a restore through it is still to run. */
38
+ export function deleteDnsProvider(uid, client) {
39
+ return withClient(client, providerPath(uid), { method: "DELETE" });
40
+ }
41
+ /** Queues a credentials test the DNS controller runs; answers the queued operation. */
42
+ export function testDnsProvider(uid, client) {
43
+ return withClient(client, `${providerPath(uid)}/test`, { method: "POST" });
44
+ }
45
+ /** Queues the takeover of a domain's records that this deployment doesn't own yet: the DNS controller overwrites them with the wanted values. `records`
46
+ * are the record types to take over, all the domain publishes through the config when left out; never the root TXT set (`spf`). Answers the queued
47
+ * operation. */
48
+ export function takeoverDnsRecords(uid, domainUid, records, client) {
49
+ return withClient(client, `${providerPath(uid)}/takeover`, {
50
+ method: "POST",
51
+ body: JSON.stringify({ domainUid, ...(records ? { records } : {}) }),
52
+ });
53
+ }
54
+ /** Queues the takeover of the deployment's own host names (`mail.<domain>`, `auth.<domain>` and the like) that this config publishes and that something
55
+ * else created: the DNS controller overwrites them with the wanted values. `records` are the host names to take over, all the config publishes when left
56
+ * out; a host whose records of another type are in the way (`type_mismatch`) is left out, or refused when named. Answers the queued operation. */
57
+ export function takeoverDnsHosts(uid, records, client) {
58
+ return withClient(client, `${providerPath(uid)}/takeover`, {
59
+ method: "POST",
60
+ body: JSON.stringify({ hosts: true, ...(records ? { records } : {}) }),
61
+ });
62
+ }
63
+ /** The publication of each of the deployment's own host names, looked up live. Needs a trusted role; a server without DNS publishing answers 404. */
64
+ export function listDnsHostPublications(client) {
65
+ return withClient(client, `${BASE}/hosts`);
66
+ }
67
+ /** The config's latest operations, newest first. */
68
+ export function listDnsProviderOperations(uid, client) {
69
+ return withClient(client, `${providerPath(uid)}/operations`);
70
+ }