@rapidmx/web-client 0.37.0 → 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
@@ -3,17 +3,22 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useEffect, useState } from "react";
6
- import { DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
6
+ import { takeoverDnsRecords } from "../../../../../lib/admin/dnsProvidersApi.js";
7
+ import { DnsManualReason, DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
7
8
  import Alert from "../ActionAlert.js";
8
9
  import Button from "../../../../../lib/components/buttons/Button.js";
9
- import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
10
+ import CopyableValue from "./CopyableValue.js";
11
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
10
12
  import { actionErrorMessage } from "../elevation.js";
13
+ import { TypeMismatchNotice } from "../integrations/dnsProviderParts.js";
11
14
 
12
15
  const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
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)",
@@ -26,6 +31,8 @@ const RECORD_TYPE_NAMES: Record<DnsRecordCheck["type"], string> = {
26
31
  mx: "MX",
27
32
  spf: "SPF",
28
33
  dkim: "DKIM",
34
+ dkim_next: "new DKIM key",
35
+ dkim_previous: "previous DKIM key",
29
36
  dmarc: "DMARC",
30
37
  openpgpkey_cname: "OpenPGP keys CNAME",
31
38
  autodiscover_cname: "Autodiscover CNAME",
@@ -43,6 +50,14 @@ const RECORD_TYPE_HELP: Partial<Record<DnsRecordCheck["type"], string>> = {
43
50
  "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.",
44
51
  };
45
52
 
53
+ /** Why a record is added by hand, shown under "Manual" in the Published column. */
54
+ const MANUAL_REASONS: Record<DnsManualReason, string> = {
55
+ no_provider: "No DNS provider is assigned to this domain.",
56
+ kind_unsupported: "The DNS provider can't hold this kind of record.",
57
+ outside_zones: "This name is outside the zones of the DNS provider.",
58
+ apex_txt_unmanaged: "Shares the domain's root TXT records, which you opt in to manage below.",
59
+ };
60
+
46
61
  /** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
47
62
  function splitMxValue(value: string): { priority: string; server: string } | null {
48
63
  const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
@@ -55,20 +70,10 @@ function splitSrvValue(value: string): { priority: string; weight: string; port:
55
70
  return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
56
71
  }
57
72
 
58
- /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
59
- function CopyableValue({ value, copyLabel }: { value: string; copyLabel: string }) {
60
- return (
61
- <div className="flex items-start gap-2">
62
- <code className="flex-1 min-w-0 text-xs break-all">{value}</code>
63
- <CopyIconButton value={value} label={copyLabel} className="shrink-0" />
64
- </div>
65
- );
66
- }
67
-
68
73
  export interface DomainDnsSetupProps {
69
74
  uid: string;
70
- /** Called with the domain every time it's (re)loaded - e.g. after "Verify now". */
71
- onLoaded?: (domain: Domain) => void;
75
+ /** Called with the domain and its checklist every time they are (re)loaded - e.g. after "Verify now". */
76
+ onLoaded?: (domain: Domain, checks: DnsRecordCheck[]) => void;
72
77
  }
73
78
 
74
79
  /**
@@ -82,6 +87,11 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
82
87
  const [error, setError] = useState<string | null>(null);
83
88
  const [verifying, setVerifying] = useState(false);
84
89
  const [verifyError, setVerifyError] = useState<string | null>(null);
90
+ // 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.
91
+ const [takeOver, setTakeOver] = useState<DnsRecordCheck | null>(null);
92
+ const [takingOver, setTakingOver] = useState(false);
93
+ const [takeOverError, setTakeOverError] = useState<string | null>(null);
94
+ const [takeOverQueued, setTakeOverQueued] = useState<DnsRecordCheck["type"][]>([]);
85
95
 
86
96
  function reload(id: string) {
87
97
  setLoading(true);
@@ -90,7 +100,7 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
90
100
  .then(([d, checks]) => {
91
101
  setDomain(d);
92
102
  setDnsSetup(checks);
93
- onLoaded?.(d);
103
+ onLoaded?.(d, checks);
94
104
  })
95
105
  .catch((err) => setError(actionErrorMessage(err, "Could not load this domain.")))
96
106
  .finally(() => setLoading(false));
@@ -111,7 +121,7 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
111
121
  const [d, checks] = await Promise.all([getDomain(domain!.uid), getDnsSetup(domain!.uid)]);
112
122
  setDomain(d);
113
123
  setDnsSetup(checks);
114
- onLoaded?.(d);
124
+ onLoaded?.(d, checks);
115
125
  } catch (err) {
116
126
  setVerifyError(actionErrorMessage(err, "Could not verify this domain."));
117
127
  } finally {
@@ -119,6 +129,22 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
119
129
  }
120
130
  }
121
131
 
132
+ // 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
133
+ // the list shows the result some time later: "Check again" rather than a refresh that would still show the conflict.
134
+ async function handleTakeOver() {
135
+ setTakingOver(true);
136
+ setTakeOverError(null);
137
+ try {
138
+ await takeoverDnsRecords(domain!.dnsProviderUid!, domain!.uid, [takeOver!.type]);
139
+ setTakeOverQueued((queued) => [...queued, takeOver!.type]);
140
+ setTakeOver(null);
141
+ } catch (err) {
142
+ setTakeOverError(actionErrorMessage(err, "Could not queue the takeover."));
143
+ } finally {
144
+ setTakingOver(false);
145
+ }
146
+ }
147
+
122
148
  if (loading) {
123
149
  return <p className="text-sm text-text-muted">Loading&hellip;</p>;
124
150
  }
@@ -129,6 +155,9 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
129
155
  const ownership = dnsSetup.find((c) => c.type === "ownership");
130
156
  const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
131
157
  const ownershipName = ownership?.recordName || domain.name;
158
+ // A server that doesn't publish says nothing about how a record gets published, so the column stays out.
159
+ const showPublished = dnsSetup.some((c) => c.publication);
160
+ const openpgpkey = dnsSetup.find((c) => c.type === "openpgpkey_cname");
132
161
 
133
162
  return (
134
163
  <div className="flex flex-col gap-5">
@@ -192,11 +221,38 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
192
221
  {dnsSetup.length > 0 && (
193
222
  <div className="bg-surface border border-border rounded-md p-6">
194
223
  <h2 className="text-sm font-bold uppercase tracking-wide mb-3">DNS setup checklist</h2>
224
+ {openpgpkey?.configured && !openpgpkey.matches && (
225
+ <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">
226
+ {openpgpkey.recordName} doesn't resolve to this deployment yet. Until it does, other people's OpenPGP apps can't find your
227
+ users' keys (Web Key Directory), and this server can't get a certificate for it.
228
+ {openpgpkey.publication === "pending" && " It is being published automatically."}
229
+ </div>
230
+ )}
231
+ {takeOverQueued.length > 0 && (
232
+ <p className="text-sm mb-3">
233
+ Takeover queued for the {takeOverQueued.map((type) => RECORD_TYPE_NAMES[type]).join(", ")} record. The DNS controller overwrites it
234
+ 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{" "}
235
+ <a href={`/admin/integrations/dns-providers/${encodeURIComponent(domain.dnsProviderUid!)}`} className="text-primary-dark hover:underline">
236
+ the DNS provider's page
237
+ </a>
238
+ .{" "}
239
+ <button
240
+ type="button"
241
+ className="font-semibold underline"
242
+ onClick={() => {
243
+ setTakeOverQueued([]);
244
+ void reload(uid);
245
+ }}
246
+ >
247
+ Check again
248
+ </button>
249
+ </p>
250
+ )}
195
251
  <div className="overflow-x-auto">
196
252
  <table className="w-full text-sm border-collapse">
197
253
  <thead>
198
254
  <tr>
199
- {["Record", "Status", "Name / host", "Recommended value"].map((h) => (
255
+ {["Record", "Status", ...(showPublished ? ["Published"] : []), "Name / host", "Recommended value"].map((h) => (
200
256
  <th
201
257
  key={h}
202
258
  className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
@@ -215,6 +271,12 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
215
271
  {RECORD_TYPE_HELP[check.type] && (
216
272
  <div className="text-xs text-text-muted mt-1 max-w-xs">{RECORD_TYPE_HELP[check.type]}</div>
217
273
  )}
274
+ {check.legacyApex && (
275
+ <div className="text-xs text-text-muted mt-1 max-w-xs">
276
+ Found at the domain's root (the old location). Publish it at <strong>{check.recordName}</strong>; the root
277
+ value can then be removed.
278
+ </div>
279
+ )}
218
280
  </td>
219
281
  <td className="py-2.5 px-2.5 border-b border-border">
220
282
  {!check.configured ? (
@@ -229,6 +291,18 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
229
291
  </span>
230
292
  )}
231
293
  </td>
294
+ {showPublished && (
295
+ <td className="py-2.5 px-2.5 border-b border-border">
296
+ <PublishedCell
297
+ check={check}
298
+ queued={takeOverQueued.includes(check.type)}
299
+ onTakeOver={() => {
300
+ setTakeOverError(null);
301
+ setTakeOver(check);
302
+ }}
303
+ />
304
+ </td>
305
+ )}
232
306
  {/* A floor on the name column: beside a long DKIM key (which breaks anywhere) the table gave it a few characters, and a name wrapped one letter to a line. */}
233
307
  <td className="py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]">
234
308
  {check.recordName ? (
@@ -258,10 +332,93 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
258
332
  </p>
259
333
  </div>
260
334
  )}
335
+
336
+ <Modal open={takeOver !== null} onClose={() => setTakeOver(null)} title="Take over this record">
337
+ {takeOver && (
338
+ <>
339
+ <p className="text-sm mb-3">
340
+ Taking over overwrites the record at <code className="text-xs break-all">{takeOver.recordName}</code> with the value below. From then
341
+ on RapidMX manages it, and whatever relied on the old value stops getting it.
342
+ </p>
343
+ <dl className="text-sm flex flex-col gap-1 mb-3">
344
+ <dt className="text-text-muted">Existing value</dt>
345
+ <dd>{takeOver.actualValue ? <code className="text-xs break-all">{takeOver.actualValue}</code> : "(none found)"}</dd>
346
+ <dt className="text-text-muted mt-2">New value</dt>
347
+ <dd>
348
+ <code className="text-xs break-all">{takeOver.recommendedValue}</code>
349
+ </dd>
350
+ </dl>
351
+ {takeOverError && <Alert>{takeOverError}</Alert>}
352
+ <div className="flex gap-3 justify-end mt-5">
353
+ <Button type="button" variant="secondary" className="!w-auto" disabled={takingOver} onClick={() => setTakeOver(null)}>
354
+ Cancel
355
+ </Button>
356
+ <Button type="button" className="!w-auto" loading={takingOver} disabled={takingOver} onClick={handleTakeOver}>
357
+ Take over
358
+ </Button>
359
+ </div>
360
+ </>
361
+ )}
362
+ </Modal>
261
363
  </div>
262
364
  );
263
365
  }
264
366
 
367
+ /** 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
368
+ * by hand, with the reason. */
369
+ function PublishedCell({ check, queued, onTakeOver }: { check: DnsRecordCheck; queued: boolean; onTakeOver: () => void }) {
370
+ if (!check.publication) {
371
+ return <span className="text-xs text-text-muted">&mdash;</span>;
372
+ }
373
+ if (check.publication === "manual") {
374
+ return (
375
+ <>
376
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">Manual</span>
377
+ {check.manualReason && <div className="text-xs text-text-muted mt-1 max-w-xs">{MANUAL_REASONS[check.manualReason]}</div>}
378
+ </>
379
+ );
380
+ }
381
+ const state = check.publication;
382
+ return (
383
+ <>
384
+ <span
385
+ className={`text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${
386
+ state === "published" ? "bg-success text-white" : state === "conflict" ? "bg-danger-bg text-danger" : "bg-surface-alt text-text-muted"
387
+ }`}
388
+ >
389
+ Automatic: {state}
390
+ </span>
391
+ {state === "conflict" && check.conflictReason === "type_mismatch" && <TypeMismatchNotice existing={check.existing} />}
392
+ {state === "conflict" && check.conflictReason !== "type_mismatch" && (
393
+ <div className="text-xs mt-1 max-w-xs">
394
+ {check.actualValue ? (
395
+ <>
396
+ Not published by RapidMX, and in the way: <code className="break-all">{check.actualValue}</code>
397
+ </>
398
+ ) : (
399
+ "A record is there that this deployment doesn't own."
400
+ )}
401
+ {/* The root TXT set is taken over only through its own opt-in. */}
402
+ {check.type !== "spf" && (
403
+ <div className="mt-1.5">
404
+ <Button
405
+ type="button"
406
+ variant="secondary"
407
+ className="!w-auto !py-1"
408
+ aria-label={`Take over ${RECORD_TYPE_NAMES[check.type]}`}
409
+ disabled={queued}
410
+ onClick={onTakeOver}
411
+ >
412
+ {queued ? "Queued" : "Take over"}
413
+ </Button>
414
+ </div>
415
+ )}
416
+ </div>
417
+ )}
418
+ </>
419
+ );
420
+ }
421
+
265
422
  /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), a SRV value as
266
423
  * its four form fields (priority, weight, port and target), anything else whole. */
267
424
  function DnsRecordValue({ check, value }: { check: DnsRecordCheck; value: string }) {
@@ -343,6 +343,9 @@ function DomainStep({ domains, onCreated }: { domains: Domain[]; onCreated: (dom
343
343
  const [name, setName] = useState("");
344
344
  const [error, setError] = useState<string | null>(null);
345
345
  const [saving, setSaving] = useState(false);
346
+ // The installation adds the domains it was installed with before anyone signs in: they're shown as already added.
347
+ const seeded = domains.filter((domain) => domain.origin === "seed");
348
+ const added = domains.filter((domain) => domain.origin !== "seed");
346
349
 
347
350
  async function handleSubmit(e: FormEvent) {
348
351
  e.preventDefault();
@@ -364,12 +367,31 @@ function DomainStep({ domains, onCreated }: { domains: Domain[]; onCreated: (dom
364
367
 
365
368
  return (
366
369
  <div className="flex flex-col gap-5 max-w-3xl">
367
- {domains.map((domain) => (
368
- <div key={domain.uid} className="flex flex-col gap-3">
369
- <h3 className="text-base font-bold">{domain.name}</h3>
370
- <DomainDnsSetup uid={domain.uid} />
371
- </div>
372
- ))}
370
+ {seeded.length > 0 && (
371
+ <section aria-labelledby="setupSeededDomains" className="flex flex-col gap-5">
372
+ <div className="flex flex-col gap-1">
373
+ <h3 id="setupSeededDomains" className="text-sm font-bold uppercase tracking-wide">
374
+ Added from your installation
375
+ </h3>
376
+ <p className="text-sm text-text-muted">Your installation already added these domains. Publish the DNS records for each one.</p>
377
+ </div>
378
+ {seeded.map((domain) => (
379
+ <DomainPanel key={domain.uid} domain={domain} grouped />
380
+ ))}
381
+ </section>
382
+ )}
383
+ {seeded.length > 0 && added.length > 0 ? (
384
+ <section aria-labelledby="setupAddedDomains" className="flex flex-col gap-5">
385
+ <h3 id="setupAddedDomains" className="text-sm font-bold uppercase tracking-wide">
386
+ Domains you added
387
+ </h3>
388
+ {added.map((domain) => (
389
+ <DomainPanel key={domain.uid} domain={domain} grouped />
390
+ ))}
391
+ </section>
392
+ ) : (
393
+ added.map((domain) => <DomainPanel key={domain.uid} domain={domain} />)
394
+ )}
373
395
  {error && <Alert>{error}</Alert>}
374
396
  <form onSubmit={handleSubmit} className={CARD_CLASS}>
375
397
  <FormField label={domains.length === 0 ? "Domain name" : "Add another domain"} htmlFor="setupDomainName">
@@ -390,6 +412,17 @@ function DomainStep({ domains, onCreated }: { domains: Domain[]; onCreated: (dom
390
412
  );
391
413
  }
392
414
 
415
+ // `grouped`: the panel sits under a region's own heading, so its domain heading is one level lower.
416
+ function DomainPanel({ domain, grouped = false }: { domain: Domain; grouped?: boolean }) {
417
+ const Heading = grouped ? "h4" : "h3";
418
+ return (
419
+ <div className="flex flex-col gap-3">
420
+ <Heading className="text-base font-bold">{domain.name}</Heading>
421
+ <DomainDnsSetup uid={domain.uid} />
422
+ </div>
423
+ );
424
+ }
425
+
393
426
  function MailboxesStep({ userUid, domain }: { userUid: string; domain?: string }) {
394
427
  const client = useApiClient();
395
428
  const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
@@ -0,0 +1,4 @@
1
+ import React from "react";
2
+ import { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
3
+ /** The configuration keyring: the keys that encrypt the credentials saved in the console, and their rotation. */
4
+ export default function ConfigurationEncryptionPage(props: Omit<AdminShellProps, "active">): React.JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
3
+ import ConfigKeysManager from "../../shared/components/admin/configEncryption/ConfigKeysManager.js";
4
+ /** The configuration keyring: the keys that encrypt the credentials saved in the console, and their rotation. */
5
+ export default function ConfigurationEncryptionPage(props) {
6
+ return (_jsxs(AdminShell, { ...props, active: "configurationEncryption", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: "Configuration encryption" }), _jsx(ConfigKeysManager, {})] }));
7
+ }
@@ -7,6 +7,9 @@ import { useState } from "react";
7
7
  import { useRouter } from "@rapidrest/react/client";
8
8
  import { deleteDomain, updateDomain } from "../../../lib/admin/domainsApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
+ import DomainApexTxt from "../../shared/components/admin/settings/DomainApexTxt.js";
11
+ import DomainDkimRotation from "../../shared/components/admin/settings/DomainDkimRotation.js";
12
+ import DomainDnsProvider from "../../shared/components/admin/settings/DomainDnsProvider.js";
10
13
  import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
11
14
  import Alert from "../../shared/components/admin/ActionAlert.js";
12
15
  import Button from "../../../lib/components/buttons/Button.js";
@@ -20,6 +23,8 @@ export default function DomainDetailPage(props) {
20
23
  function DomainDetailContent({ uid }) {
21
24
  const { navigate } = useRouter();
22
25
  const [domain, setDomain] = useState(null);
26
+ // The checklist the DNS setup panel loaded, which the DKIM rotation panel reads the rotation's records from.
27
+ const [checks, setChecks] = useState([]);
23
28
  const [confirmingDelete, setConfirmingDelete] = useState(false);
24
29
  const [deleting, setDeleting] = useState(false);
25
30
  const [deleteError, setDeleteError] = useState(null);
@@ -29,9 +34,10 @@ function DomainDetailContent({ uid }) {
29
34
  const [savingAlias, setSavingAlias] = useState(false);
30
35
  const [aliasError, setAliasError] = useState(null);
31
36
  const [aliasSaved, setAliasSaved] = useState(false);
32
- // Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
37
+ // Bumped after a successful save (the alias, the DNS provider, the root TXT records) to force `DomainDnsSetup` (which owns its own `domain` state,
33
38
  // fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
34
39
  const [refreshCount, setRefreshCount] = useState(0);
40
+ const refresh = () => setRefreshCount((c) => c + 1);
35
41
  function closeDeleteModal() {
36
42
  setConfirmingDelete(false);
37
43
  }
@@ -48,7 +54,8 @@ function DomainDetailContent({ uid }) {
48
54
  setDeleting(false);
49
55
  }
50
56
  }
51
- function handleLoaded(d) {
57
+ function handleLoaded(d, loadedChecks) {
58
+ setChecks(loadedChecks);
52
59
  setDomain(d);
53
60
  setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
54
61
  }
@@ -62,7 +69,7 @@ function DomainDetailContent({ uid }) {
62
69
  setDomain(updated);
63
70
  setAliasInput(updated.aliasOf ?? "");
64
71
  setAliasSaved(true);
65
- setRefreshCount((c) => c + 1);
72
+ refresh();
66
73
  }
67
74
  catch (err) {
68
75
  setAliasError(actionErrorMessage(err, "Could not update this domain."));
@@ -71,7 +78,7 @@ function DomainDetailContent({ uid }) {
71
78
  setSavingAlias(false);
72
79
  }
73
80
  }
74
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_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: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS,
81
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && _jsx(DomainDnsProvider, { domain: domain, onChanged: refresh }), domain && _jsx(DomainDkimRotation, { domain: domain, checks: checks, onChanged: refresh }), domain?.dnsProviderUid && _jsx(DomainApexTxt, { domain: domain, onChanged: refresh }), domain && (_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: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS,
75
82
  // Set in the same update as `domain` (see `handleLoaded()`), which is what this section waits for.
76
83
  value: aliasInput, onChange: (e) => {
77
84
  setAliasInput(e.target.value);
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
3
+ export default function DnsProviderPage(props: Omit<AdminShellProps, "active"> & {
4
+ params: {
5
+ uid: string;
6
+ };
7
+ }): React.JSX.Element;
@@ -0,0 +1,117 @@
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 { useRouter } from "@rapidrest/react/client";
8
+ import { deleteDnsProvider, getDnsProvider, listDnsProviderTypes, updateDnsProvider, } from "../../../../lib/admin/dnsProvidersApi.js";
9
+ import Button from "../../../../lib/components/buttons/Button.js";
10
+ import { ApiRequestError } from "../../../../lib/util/api.js";
11
+ import Modal from "../../../../lib/components/overlays/Modal.js";
12
+ import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
13
+ import DnsProviderForm from "../../../shared/components/admin/integrations/DnsProviderForm.js";
14
+ import DnsProviderHosts from "../../../shared/components/admin/integrations/DnsProviderHosts.js";
15
+ import DnsProviderOperations from "../../../shared/components/admin/integrations/DnsProviderOperations.js";
16
+ import IntegrationsLayout from "../../../shared/components/admin/integrations/IntegrationsLayout.js";
17
+ import { Badge, dnsProviderStatus, domainsText, formatTime } from "../../../shared/components/admin/integrations/dnsProviderParts.js";
18
+ import Alert from "../../../shared/components/admin/ActionAlert.js";
19
+ import { actionErrorMessage } from "../../../shared/components/admin/elevation.js";
20
+ const LIST_HREF = "/admin/integrations/dns-providers";
21
+ /**
22
+ * What of a config an administrator can change, as the server shows it: the controller's status reports move the row's version without touching any of it.
23
+ * A credential's `unreadable` flag is the server's own finding (a keyring that changed), not something an administrator set, so it is left out.
24
+ */
25
+ function configOf(provider) {
26
+ const { name, zones, options, credentials, enabled, allowTakeover } = provider;
27
+ const shown = {};
28
+ for (const [key, mask] of Object.entries(credentials)) {
29
+ shown[key] = { ...mask, unreadable: undefined };
30
+ }
31
+ return JSON.stringify({ name, zones, options, credentials: shown, enabled, allowTakeover });
32
+ }
33
+ export default function DnsProviderPage(props) {
34
+ return (_jsx(AdminShell, { ...props, active: "integrations", children: _jsx(IntegrationsLayout, { active: "dnsProviders", children: _jsx(DnsProviderDetail, { uid: props.params.uid }) }) }));
35
+ }
36
+ function DnsProviderDetail({ uid }) {
37
+ const { navigate } = useRouter();
38
+ const [provider, setProvider] = useState(null);
39
+ // The version a save is made against: the one shown, or a newer one found after a refusal (the form isn't reset to show it).
40
+ const version = useRef(0);
41
+ // The config an administrator last saw, which a refusal compares the latest one against: the one loaded or saved, then the one a refusal reported.
42
+ const seen = useRef("");
43
+ const [types, setTypes] = useState([]);
44
+ const [loadError, setLoadError] = useState(null);
45
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
46
+ const [deleting, setDeleting] = useState(false);
47
+ const [deleteError, setDeleteError] = useState(null);
48
+ useEffect(() => {
49
+ Promise.all([getDnsProvider(uid), listDnsProviderTypes()])
50
+ .then(([loaded, loadedTypes]) => {
51
+ setProvider(loaded);
52
+ version.current = loaded.version;
53
+ seen.current = configOf(loaded);
54
+ setTypes(loadedTypes);
55
+ })
56
+ .catch((err) => setLoadError(actionErrorMessage(err, "Could not load the DNS provider.")));
57
+ }, [uid]);
58
+ if (loadError) {
59
+ return _jsx(Alert, { children: loadError });
60
+ }
61
+ if (!provider) {
62
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
63
+ }
64
+ const spec = types.find((candidate) => candidate.type === provider.type);
65
+ const status = dnsProviderStatus(provider);
66
+ function accept(saved) {
67
+ version.current = saved.version;
68
+ seen.current = configOf(saved);
69
+ setProvider(saved);
70
+ }
71
+ // Made against the version shown, so a change someone else saved meanwhile is refused (409) rather than overwritten. The DNS controller moves the
72
+ // version too (every status report that changes, and at least every few minutes), so a refusal reads the config again: when nothing an administrator
73
+ // sets differs, only the status moved and the save is made again against the new version; otherwise the entries stay in the form, the new version is
74
+ // kept, and saving once more replaces the other change (a refusal after that compares against what the first one reported, so the change is told once).
75
+ async function handleSave(input) {
76
+ const sent = version.current;
77
+ try {
78
+ accept(await updateDnsProvider(uid, { ...input, version: sent }));
79
+ }
80
+ catch (err) {
81
+ if (!(err instanceof ApiRequestError) || err.status !== 409) {
82
+ throw err;
83
+ }
84
+ let latest;
85
+ try {
86
+ latest = await getDnsProvider(uid);
87
+ }
88
+ catch {
89
+ throw err;
90
+ }
91
+ if (latest.version === sent) {
92
+ // Refused for another reason than a newer version.
93
+ throw err;
94
+ }
95
+ version.current = latest.version;
96
+ const reported = configOf(latest);
97
+ if (reported !== seen.current) {
98
+ seen.current = reported;
99
+ throw new ApiRequestError("This DNS provider was changed by someone else. Your entries are kept; save again to replace their change, or reload the page to see it.", 409);
100
+ }
101
+ accept(await updateDnsProvider(uid, { ...input, version: latest.version }));
102
+ }
103
+ }
104
+ async function handleDelete() {
105
+ setDeleting(true);
106
+ setDeleteError(null);
107
+ try {
108
+ await deleteDnsProvider(provider.uid);
109
+ void navigate(LIST_HREF);
110
+ }
111
+ catch (err) {
112
+ setDeleteError(actionErrorMessage(err, "Could not delete the DNS provider."));
113
+ setDeleting(false);
114
+ }
115
+ }
116
+ return (_jsxs(_Fragment, { children: [_jsx("a", { href: LIST_HREF, className: "text-sm text-primary-dark hover:underline", children: "\u2190 DNS providers" }), _jsxs("h2", { className: "text-lg font-bold uppercase tracking-wide mt-2 mb-1", children: [provider.name, provider.origin === "installer" && _jsx(Badge, { children: "Added by the installer" })] }), _jsxs("div", { className: "flex items-center text-sm text-text-muted mb-4", children: [_jsxs("p", { children: ["Type: ", spec?.label ?? provider.type] }), spec && !spec.tested && _jsx(Badge, { children: "Not yet live-tested" })] }), _jsxs("div", { className: "border border-border rounded-sm bg-surface p-4 mb-6 max-w-xl text-sm flex flex-col gap-1", children: [_jsxs("p", { className: "font-semibold", children: ["Status: ", status.label] }), status.error && _jsx("p", { className: "text-danger", children: status.error }), _jsx("p", { className: "text-text-muted", children: provider.domainCount === 0 ? "No domains are published through it yet." : `Publishing ${domainsText(provider.domainCount)}.` }), provider.status?.lastSyncAt && _jsxs("p", { className: "text-text-muted", children: ["Last synchronized ", formatTime(provider.status.lastSyncAt), "."] })] }), spec ? (_jsx(DnsProviderForm, { types: types, provider: provider, submitLabel: "Save", onSave: handleSave })) : (_jsx("p", { className: "text-sm text-text-muted max-w-xl", children: "This console doesn\u2019t know this type, so its settings can\u2019t be edited here. A newer admin console can edit it." })), _jsx(DnsProviderHosts, { uid: provider.uid }), _jsx(DnsProviderOperations, { uid: provider.uid }), _jsx("div", { className: "mt-8", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingDelete(true), children: "Delete DNS provider" }) }), _jsx(Modal, { open: confirmingDelete, onClose: () => setConfirmingDelete(false), title: "Delete DNS provider?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [deleteError && _jsx(Alert, { children: deleteError }), provider.domainCount > 0 && (_jsxs("p", { children: ["The server refuses while domains are assigned to it (", domainsText(provider.domainCount), "): assign them to another provider, or to manual publishing, first."] })), _jsx("p", { children: "The records it publishes for this server\u2019s own host names are removed from your DNS host, then its external-dns instance and its credentials are deleted." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }) })] }));
117
+ }
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ import { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
3
+ export default function DnsProvidersPage(props: Omit<AdminShellProps, "active">): React.JSX.Element;