@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
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { ConfigKeys } from "../../../../../lib/admin/configKeysApi.js";
3
+ import type { DiagnosticsResource } from "./useDiagnosticsResource.js";
4
+ /**
5
+ * Reads the configuration keyring's state with the configuration keys client: the same answer the Configuration Encryption page shows, so Diagnostics needs no
6
+ * route of its own. A server without the route (404) answers `null`, which the card words, so that it doesn't fail the page.
7
+ */
8
+ export declare function loadKeyring(): Promise<ConfigKeys | null>;
9
+ export interface KeyringCardProps {
10
+ keyring: DiagnosticsResource<ConfigKeys | null>;
11
+ /** The time the ages are counted to. Defaults to now. For a test. */
12
+ now?: number;
13
+ }
14
+ /**
15
+ * The configuration keyring (spec 5.6, 14): each key by its id, when it was made, its age and how many saved values it encrypts, which one encrypts now, and a
16
+ * rotation that is running or failed. Never any key material: the server doesn't send it. Managing the keys is the Configuration Encryption page's.
17
+ */
18
+ export default function KeyringCard({ keyring, now }: KeyringCardProps): React.JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { getConfigKeys } from "../../../../../lib/admin/configKeysApi.js";
3
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
4
+ import Alert from "../ActionAlert.js";
5
+ import { activeKidOf, ageText, isRunning } from "../configEncryption/keyringParts.js";
6
+ import Badge from "./Badge.js";
7
+ import { formatDateTime, NO_VALUE } from "./format.js";
8
+ /**
9
+ * Reads the configuration keyring's state with the configuration keys client: the same answer the Configuration Encryption page shows, so Diagnostics needs no
10
+ * route of its own. A server without the route (404) answers `null`, which the card words, so that it doesn't fail the page.
11
+ */
12
+ export async function loadKeyring() {
13
+ try {
14
+ return await getConfigKeys();
15
+ }
16
+ catch (err) {
17
+ if (err instanceof ApiRequestError && err.status === 404) {
18
+ return null;
19
+ }
20
+ throw err;
21
+ }
22
+ }
23
+ /** Where a running rotation is, in words. */
24
+ const PHASES = {
25
+ adding: "adding the new key",
26
+ activating: "waiting for the server copies to load the new key",
27
+ reencrypting: "re-encrypting the saved values",
28
+ retiring: "removing the old key",
29
+ done: "done",
30
+ failed: "failed",
31
+ };
32
+ const HEADINGS = ["Key ID", "Created", "Age", "Values encrypted", "State"];
33
+ function Note({ role = "note", children }) {
34
+ return (_jsx("div", { role: role, className: "mt-3 rounded-sm bg-warning px-3.5 py-3 text-sm text-warning-contrast", children: children }));
35
+ }
36
+ /**
37
+ * The configuration keyring (spec 5.6, 14): each key by its id, when it was made, its age and how many saved values it encrypts, which one encrypts now, and a
38
+ * rotation that is running or failed. Never any key material: the server doesn't send it. Managing the keys is the Configuration Encryption page's.
39
+ */
40
+ export default function KeyringCard({ keyring, now = Date.now() }) {
41
+ const { data, error, loading } = keyring;
42
+ return (_jsxs("section", { "aria-labelledby": "diagnostics-keyring-heading", className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: "diagnostics-keyring-heading", className: "mb-1 text-base font-bold uppercase tracking-wide", children: "Configuration keyring" }), _jsx("p", { className: "mb-3 text-xs text-text-muted", children: "The keys that encrypt the credentials, API keys and tokens saved in the console. Only each key\u2019s id, age and how many values it encrypts are shown." }), error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data === null && _jsx("p", { className: "text-sm text-text-muted", children: "This server doesn\u2019t offer configuration encryption yet." }), data && _jsx(Keys, { keys: data, now: now })] }));
43
+ }
44
+ function Keys({ keys, now }) {
45
+ const list = keys.keys ?? [];
46
+ const activeKid = activeKidOf({ ...keys, keys: list });
47
+ const rotation = keys.rotation;
48
+ const reencryption = keys.reencryption;
49
+ const unreadable = reencryption?.unreadable?.length ?? 0;
50
+ return (_jsxs(_Fragment, { children: [list.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "The keyring holds no keys." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full border-collapse text-sm", children: [_jsx("thead", { children: _jsx("tr", { children: HEADINGS.map((heading) => (_jsx("th", { className: "border-b border-border px-2.5 py-2 text-left text-xs uppercase tracking-wide text-text-muted", children: heading }, heading))) }) }), _jsx("tbody", { children: list.map((key) => (_jsxs("tr", { children: [_jsx("td", { className: "break-all border-b border-border px-2.5 py-2 font-mono", children: key.kid }), _jsx("td", { className: "border-b border-border px-2.5 py-2", children: formatDateTime(key.createdAt) }), _jsx("td", { className: "border-b border-border px-2.5 py-2", children: ageText(key.createdAt, now) }), _jsx("td", { className: "border-b border-border px-2.5 py-2 tabular-nums", children: key.envelopes ?? "Not counted yet" }), _jsx("td", { className: "border-b border-border px-2.5 py-2", children: key.kid === activeKid ? (_jsx(Badge, { tone: "success", children: "Active" })) : isRunning(rotation) && key.kid === rotation.kid ? (_jsx(Badge, { children: "Waiting to be activated" })) : (NO_VALUE) })] }, key.kid))) })] }) })), _jsx("p", { className: "mt-2 text-xs text-text-muted", children: keys.countedAt ? `Values were last counted ${formatDateTime(keys.countedAt)}.` : "Values have not been counted yet." }), isRunning(rotation) && (_jsxs(Note, { children: ["A key rotation is in progress: ", PHASES[rotation.phase] ?? rotation.phase, ". Follow it on the", " ", _jsx("a", { href: "/admin/configuration-encryption", className: "underline", children: "Configuration Encryption" }), " ", "page."] })), rotation?.phase === "failed" && _jsxs(Note, { children: ["The last key rotation failed: ", rotation.lastError ?? "no reason was recorded."] }), unreadable > 0 && (_jsxs(Note, { role: "alert", children: [reencryption?.finishedAt ? `The re-encryption pass that finished ${formatDateTime(reencryption.finishedAt)}` : "The last re-encryption pass", " could not read", " ", unreadable === 1 ? "1 saved value" : `${unreadable} saved values`, " with the keys in this keyring. Restore the keyring they were saved with, or enter them again; only a later pass updates this, so a value entered again stays counted until then."] }))] }));
51
+ }
@@ -4,6 +4,10 @@ import type { DiagnosticsSetting } from "./diagnosticsApi.js";
4
4
  export declare const SETTINGS_PAGE_SIZE = 100;
5
5
  /** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
6
6
  export declare const HIDDEN_VALUE = "\u2022\u2022\u2022\u2022 (hidden)";
7
+ /** What stands where a secret of the integrations layer is saved: whether it is set is all that is shown of it. */
8
+ export declare const SET_VALUE = "\u2022\u2022\u2022\u2022 (set)";
9
+ /** What stands where a saved secret could not be decrypted, so it is not in use. */
10
+ export declare const UNREADABLE_VALUE = "Set, but can't be read: enter it again";
7
11
  export interface SettingsTableProps {
8
12
  /** The section's heading, which also names its filter box and labels the section. */
9
13
  title: string;
@@ -12,9 +16,13 @@ export interface SettingsTableProps {
12
16
  /** Also the prefix of the heading's element id. */
13
17
  id: string;
14
18
  settings: DiagnosticsSetting[];
19
+ /** What a `redacted` value is: `hidden` (default) where the server withholds it, `set` where it is a saved secret and the setting says only that it is set. */
20
+ secrets?: "hidden" | "set";
21
+ /** What an empty list says. Defaults to "Nothing is set." */
22
+ emptyText?: string;
15
23
  }
16
24
  /**
17
25
  * A filterable, paged list of settings (name and value). A value the server withheld is shown as "•••• (hidden)", never as
18
26
  * anything it could have held. The filter matches names, and the values that are shown: a hidden value can't be searched for.
19
27
  */
20
- export default function SettingsTable({ title, description, id, settings }: SettingsTableProps): React.JSX.Element;
28
+ export default function SettingsTable({ title, description, id, settings, secrets, emptyText }: SettingsTableProps): React.JSX.Element;
@@ -10,12 +10,16 @@ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton
10
10
  export const SETTINGS_PAGE_SIZE = 100;
11
11
  /** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
12
12
  export const HIDDEN_VALUE = "•••• (hidden)";
13
+ /** What stands where a secret of the integrations layer is saved: whether it is set is all that is shown of it. */
14
+ export const SET_VALUE = "•••• (set)";
15
+ /** What stands where a saved secret could not be decrypted, so it is not in use. */
16
+ export const UNREADABLE_VALUE = "Set, but can't be read: enter it again";
13
17
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
18
  /**
15
19
  * A filterable, paged list of settings (name and value). A value the server withheld is shown as "•••• (hidden)", never as
16
20
  * anything it could have held. The filter matches names, and the values that are shown: a hidden value can't be searched for.
17
21
  */
18
- export default function SettingsTable({ title, description, id, settings }) {
22
+ export default function SettingsTable({ title, description, id, settings, secrets = "hidden", emptyText = "Nothing is set." }) {
19
23
  const [query, setQuery] = useState("");
20
24
  const [limit, setLimit] = useState(SETTINGS_PAGE_SIZE);
21
25
  const matching = useMemo(() => {
@@ -23,10 +27,13 @@ export default function SettingsTable({ title, description, id, settings }) {
23
27
  return settings.filter((item) => needle === "" || `${item.name} ${item.redacted ? "" : (item.value ?? "")}`.toLowerCase().includes(needle));
24
28
  }, [settings, query]);
25
29
  const shown = matching.slice(0, limit);
26
- const hiddenCount = settings.filter((item) => item.redacted).length;
30
+ const unreadableCount = settings.filter((item) => item.unreadable).length;
31
+ const hiddenCount = settings.filter((item) => item.redacted && !item.unreadable).length;
32
+ const hiddenText = secrets === "set" ? `${hiddenCount} ${hiddenCount === 1 ? "secret" : "secrets"} set` : `${hiddenCount} with a hidden value`;
27
33
  return (_jsxs("section", { "aria-labelledby": `${id}-heading`, className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: `${id}-heading`, className: "text-base font-bold uppercase tracking-wide mb-1", children: title }), _jsx("p", { className: "mb-3 text-xs text-text-muted", children: description }), _jsx("div", { className: "mb-3", children: _jsx("input", { type: "search", "aria-label": `Filter ${title.toLowerCase()}`, className: INPUT_CLASS, placeholder: "Filter by name or value", value: query, onChange: (event) => {
28
34
  setQuery(event.target.value);
29
35
  setLimit(SETTINGS_PAGE_SIZE);
30
36
  } }) }), _jsx("p", { role: "status", className: "mb-2 text-xs text-text-muted", children: (matching.length === settings.length ? `${settings.length} settings` : `${matching.length} of ${settings.length} settings match`) +
31
- (hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? "Nothing is set." : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: [item.name, _jsx(CopyIconButton, { value: item.name, label: `Copy the name of ${item.name}` })] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.redacted ? (_jsx("span", { className: "text-text-muted", children: HIDDEN_VALUE })) : (_jsxs(_Fragment, { children: [item.value, _jsx(CopyIconButton, { value: item.value ?? "", label: `Copy the value of ${item.name}` })] })) })] }, item.name))) })] }) })), shown.length < matching.length && (_jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLimit(limit + SETTINGS_PAGE_SIZE), children: "Show more" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Showing ", shown.length, " of ", matching.length] })] }))] }));
37
+ (hiddenCount > 0 ? `, ${hiddenText}` : "") +
38
+ (unreadableCount > 0 ? `, ${unreadableCount} that can't be read` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? emptyText : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: [item.name, _jsx(CopyIconButton, { value: item.name, label: `Copy the name of ${item.name}` })] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.unreadable ? (_jsx("span", { className: "text-danger", children: UNREADABLE_VALUE })) : item.redacted ? (_jsx("span", { className: "text-text-muted", children: secrets === "set" ? SET_VALUE : HIDDEN_VALUE })) : (_jsxs(_Fragment, { children: [item.value, _jsx(CopyIconButton, { value: item.value ?? "", label: `Copy the value of ${item.name}` })] })) })] }, item.name))) })] }) })), shown.length < matching.length && (_jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLimit(limit + SETTINGS_PAGE_SIZE), children: "Show more" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Showing ", shown.length, " of ", matching.length] })] }))] }));
32
39
  }
@@ -8,8 +8,11 @@
8
8
  * cluster's nodes or the kubelet's statistics. That is why a node here is only what the pods say of it, and why the
9
9
  * machine figures are those of the node running the server, read from inside its own container.
10
10
  */
11
- /** The components of a RapidMX install that run beside the server itself, in the order the server lists them. */
12
- export type DiagnosticsComponentName = "postfix" | "postfix-bridge" | "mongodb" | "postgresql" | "redis" | "coturn" | "rspamd" | "clamav";
11
+ /**
12
+ * The components of a RapidMX install that run beside the server itself, in the order the server lists them. `dns-controller` (one pod) and
13
+ * `external-dns` (one pod per DNS provider config) are DNS publishing's and come after the eight others, only from a server that runs it.
14
+ */
15
+ export type DiagnosticsComponentName = "postfix" | "postfix-bridge" | "mongodb" | "postgresql" | "redis" | "coturn" | "rspamd" | "clamav" | "dns-controller" | "external-dns";
13
16
  export interface DiagnosticsContainer {
14
17
  name: string;
15
18
  image: string;
@@ -55,7 +58,7 @@ export interface DiagnosticsVersions {
55
58
  version: string;
56
59
  direct: boolean;
57
60
  }[];
58
- /** Always all eight, in the order of `DiagnosticsComponentName`. */
61
+ /** The eight others, in the order of `DiagnosticsComponentName`, and the two of DNS publishing when the server runs it. */
59
62
  components: DiagnosticsComponent[];
60
63
  kubernetes: {
61
64
  available: boolean;
@@ -70,11 +73,18 @@ export interface DiagnosticsSetting {
70
73
  name: string;
71
74
  value?: string;
72
75
  redacted: boolean;
76
+ /** A saved secret the server holds but could not decrypt (a key missing from its keyring), so it is not in use: set with `redacted`, and the administrator enters it again. */
77
+ unreadable?: boolean;
73
78
  }
74
79
  /** The server's environment variables and effective configuration, each sorted by name. */
75
80
  export interface DiagnosticsInformation {
76
81
  environment: DiagnosticsSetting[];
77
82
  configuration: DiagnosticsSetting[];
83
+ /**
84
+ * The integrations config layer as a source of its own: what is saved on the console's Integrations pages and applied without a restart, a secret
85
+ * redacted. Left out by a server that predates it (nothing is shown), an empty list when nothing is saved there.
86
+ */
87
+ integrations?: DiagnosticsSetting[];
78
88
  }
79
89
  /** A node that runs some of this install's pods, derived from the pods: nothing else about the node is readable. */
80
90
  export interface DiagnosticsNode {
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import type { DnsProvider, DnsProviderInput, DnsProviderTypeInfo } from "../../../../../lib/admin/dnsProvidersApi.js";
3
+ export interface DnsProviderFormProps {
4
+ /** The provider types, from `GET /system/dns-providers/types`: what the form is built from. */
5
+ types: DnsProviderTypeInfo[];
6
+ /** The config being edited; none to create one (then the type is chosen here; it never changes afterwards). */
7
+ provider?: DnsProvider;
8
+ submitLabel: string;
9
+ /** Saves what the form holds; a rejection is shown (the server's validation is the authority). */
10
+ onSave: (input: DnsProviderInput) => Promise<void>;
11
+ }
12
+ /**
13
+ * A DNS provider config's form, generated from its type's description: one field per option (text, a list one entry per line, a number, a switch or a
14
+ * choice), and the type's credentials, write-only - a saved one shows only as set, with Replace, and Remove where the type can do without it. Only what
15
+ * is typed into a credential is sent; the rest of the config is sent whole.
16
+ */
17
+ export default function DnsProviderForm({ types, provider, submitLabel, onSave }: DnsProviderFormProps): React.JSX.Element;
@@ -0,0 +1,147 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { isUnreadableSecret } from "../../../../../lib/admin/integrationsApi.js";
8
+ import Button from "../../../../../lib/components/buttons/Button.js";
9
+ import Alert from "../ActionAlert.js";
10
+ import { actionErrorMessage } from "../elevation.js";
11
+ import { Badge } from "./dnsProviderParts.js";
12
+ import SecretField, { INPUT_CLASS } from "./SecretField.js";
13
+ /** The entries of a list typed one per line (commas and spaces separate them too). */
14
+ function listOf(text) {
15
+ return text.split(/[\s,]+/).filter((entry) => entry !== "");
16
+ }
17
+ /** An option's value as the form holds it: a switch, or text (a list one entry per line). */
18
+ function toFormValue(option, value) {
19
+ if (option.kind === "boolean") {
20
+ return value === true;
21
+ }
22
+ return Array.isArray(value) ? value.join("\n") : value === undefined ? "" : String(value);
23
+ }
24
+ /** An option's value as the server takes it; `undefined` for one left empty, which the server fills with its default or refuses as required. */
25
+ function fromFormValue(option, value) {
26
+ if (typeof value === "boolean") {
27
+ return value;
28
+ }
29
+ if (option.kind === "string-list") {
30
+ const list = listOf(value);
31
+ return list.length > 0 ? list : undefined;
32
+ }
33
+ const text = value.trim();
34
+ if (text === "") {
35
+ return undefined;
36
+ }
37
+ return option.kind === "integer" ? Number(text) : text;
38
+ }
39
+ /** The form's values of `spec`'s options: `provider`'s, else each option's default. */
40
+ function initialOptions(spec, provider) {
41
+ return Object.fromEntries(spec.options.map((option) => [option.key, toFormValue(option, provider?.options[option.key] ?? option.default)]));
42
+ }
43
+ /**
44
+ * A DNS provider config's form, generated from its type's description: one field per option (text, a list one entry per line, a number, a switch or a
45
+ * choice), and the type's credentials, write-only - a saved one shows only as set, with Replace, and Remove where the type can do without it. Only what
46
+ * is typed into a credential is sent; the rest of the config is sent whole.
47
+ */
48
+ export default function DnsProviderForm({ types, provider, submitLabel, onSave }) {
49
+ const [type, setType] = useState(provider?.type ?? types[0].type);
50
+ const spec = types.find((candidate) => candidate.type === type);
51
+ const [name, setName] = useState(provider?.name ?? "");
52
+ const [zones, setZones] = useState((provider?.zones ?? []).join("\n"));
53
+ const [options, setOptions] = useState(() => initialOptions(spec, provider));
54
+ // A new value typed (string), or `null` for a saved credential to remove; a field left alone has no entry.
55
+ const [credentials, setCredentials] = useState({});
56
+ const [enabled, setEnabled] = useState(provider?.enabled ?? true);
57
+ const [allowTakeover, setAllowTakeover] = useState(provider?.allowTakeover ?? false);
58
+ // Bumped by a save, so the credentials start over as saved.
59
+ const [savedCount, setSavedCount] = useState(0);
60
+ const [error, setError] = useState(null);
61
+ const [saving, setSaving] = useState(false);
62
+ const [saved, setSaved] = useState(false);
63
+ function changeType(next) {
64
+ setType(next);
65
+ setOptions(initialOptions(types.find((candidate) => candidate.type === next)));
66
+ setCredentials({});
67
+ }
68
+ function setCredential(key, value) {
69
+ setCredentials((current) => {
70
+ const next = { ...current };
71
+ if (value === undefined) {
72
+ delete next[key];
73
+ }
74
+ else {
75
+ next[key] = value;
76
+ }
77
+ return next;
78
+ });
79
+ }
80
+ async function handleSubmit(e) {
81
+ e.preventDefault();
82
+ setError(null);
83
+ setSaved(false);
84
+ const trimmedName = name.trim();
85
+ if (!trimmedName) {
86
+ setError("A name is required.");
87
+ return;
88
+ }
89
+ const zoneList = listOf(zones);
90
+ if (zoneList.length === 0) {
91
+ setError("Enter at least one zone.");
92
+ return;
93
+ }
94
+ const values = {};
95
+ for (const option of spec.options) {
96
+ const value = fromFormValue(option, options[option.key]);
97
+ if (value !== undefined) {
98
+ values[option.key] = value;
99
+ }
100
+ }
101
+ const input = {
102
+ name: trimmedName,
103
+ ...(provider ? {} : { type }),
104
+ zones: zoneList,
105
+ options: values,
106
+ ...(Object.keys(credentials).length > 0 ? { credentials } : {}),
107
+ enabled,
108
+ allowTakeover,
109
+ };
110
+ setSaving(true);
111
+ try {
112
+ await onSave(input);
113
+ setCredentials({});
114
+ setSavedCount((count) => count + 1);
115
+ setSaved(true);
116
+ }
117
+ catch (err) {
118
+ setError(actionErrorMessage(err, "Could not save the DNS provider."));
119
+ }
120
+ finally {
121
+ setSaving(false);
122
+ }
123
+ }
124
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4 max-w-xl", children: [error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "text-sm text-success font-medium", children: "Saved." }), !provider && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: ["Type", !spec.tested && _jsx(Badge, { children: "Not yet live-tested" })] }), _jsx("select", { "aria-label": "Type", className: INPUT_CLASS, value: type, onChange: (e) => changeType(e.target.value), children: types.map((candidate) => (_jsx("option", { value: candidate.type, children: candidate.label }, candidate.type))) })] })), spec.notes.length > 0 && (_jsx("ul", { className: "list-disc pl-5 text-xs text-text-muted", children: spec.notes.map((note) => (_jsx("li", { children: note }, note))) })), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Name" }), _jsx("input", { "aria-label": "Name", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Zones" }), _jsx("textarea", { "aria-label": "Zones", rows: 3, spellCheck: false, className: INPUT_CLASS, value: zones, onChange: (e) => setZones(e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The zones this provider may publish into, one per line, such as example.com. A domain can be assigned to it only when it is one of them or under one." })] }), spec.options.map((option) => (_jsx(OptionField, { option: option, value: options[option.key], onChange: (value) => setOptions({ ...options, [option.key]: value }) }, `${type}-${option.key}`))), _jsxs("fieldset", { className: "flex flex-col gap-3", children: [_jsx("legend", { className: "text-sm font-semibold mb-1", children: "Credentials" }), _jsx("p", { className: "text-xs text-text-muted -mt-2", children: "Saved encrypted, and never shown again." }), spec.secretFields.map((field) => {
125
+ const isSaved = !!provider?.credentials[field.key];
126
+ const removing = credentials[field.key] === null;
127
+ return (_jsxs("div", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: [field.label, !field.required && _jsx("span", { className: "font-normal text-text-muted", children: " (optional)" })] }), removing ? (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { children: "Removed when you save." }), _jsx(Button, { type: "button", variant: "text", className: "text-xs", "aria-label": `Undo removing ${field.label}`, onClick: () => setCredential(field.key, undefined), children: "Undo" })] })) : (_jsx(SecretField, { id: `credential-${field.key}`, label: field.label, name: field.label, saved: isSaved ? "here" : "none", unreadable: isUnreadableSecret(provider?.credentials[field.key]), value: credentials[field.key] ?? undefined, onChange: (value) => setCredential(field.key, value), multiLine: field.multiLine })), isSaved && !field.required && !removing && (_jsx(Button, { type: "button", variant: "text", className: "!w-auto self-start text-xs", "aria-label": `Remove ${field.label}`, onClick: () => setCredential(field.key, null), children: "Remove" })), _jsx("span", { className: "text-xs text-text-muted", children: field.description })] }, `${type}-${field.key}`));
128
+ })] }, savedCount), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": "Publish through this provider", checked: enabled, onChange: (e) => setEnabled(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Publish through this provider" }), _jsx("span", { className: "text-xs text-text-muted", children: "Off, its external-dns instance is stopped; the records it published stay as they are." })] })] }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": "Allow taking over existing records", checked: allowTakeover, onChange: (e) => setAllowTakeover(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Allow taking over existing records" }), _jsx("span", { className: "text-xs text-text-muted", children: "Lets this provider replace records that something else created at the names it publishes, such as an MX record added by hand: such records are taken over automatically a few minutes after they are found, for the domains and for this server\u2019s own host names. A domain\u2019s root TXT records (SPF) are never taken over this way, only through the root TXT opt-in on the domain. Records of another type in the way are never taken over: they are listed for you to delete at your DNS host. Without it, such records show as conflicts, with a Take over action on their domain and, for the host names, on this provider\u2019s page." })] })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: submitLabel }) })] }));
129
+ }
130
+ /** One option of the provider type, drawn by its kind. */
131
+ function OptionField({ option, value, onChange }) {
132
+ const hint = _jsx("span", { className: "text-xs text-text-muted", children: option.description });
133
+ if (option.kind === "boolean") {
134
+ return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": option.label, checked: value, onChange: (e) => onChange(e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: option.label }), hint] })] }));
135
+ }
136
+ let control;
137
+ if (option.kind === "enum") {
138
+ control = (_jsxs("select", { "aria-label": option.label, className: INPUT_CLASS, value: value, onChange: (e) => onChange(e.target.value), children: [option.default === undefined && _jsx("option", { value: "", children: "Not set" }), option.values.map((choice) => (_jsx("option", { value: choice, children: choice }, choice)))] }));
139
+ }
140
+ else if (option.kind === "string-list") {
141
+ control = (_jsx("textarea", { "aria-label": option.label, rows: 3, spellCheck: false, className: `${INPUT_CLASS} font-mono`, value: value, onChange: (e) => onChange(e.target.value) }));
142
+ }
143
+ else {
144
+ control = (_jsx("input", { "aria-label": option.label, type: option.kind === "integer" ? "number" : "text", min: option.min, max: option.max, className: INPUT_CLASS, value: value, onChange: (e) => onChange(e.target.value) }));
145
+ }
146
+ return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: [option.label, !option.required && _jsx("span", { className: "font-normal text-text-muted", children: " (optional)" })] }), control, hint, option.kind === "string-list" && _jsx("span", { className: "text-xs text-text-muted", children: "One per line." })] }));
147
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ /**
3
+ * The deployment's own host names (mail, auth, autodiscover, openpgpkey) that this config publishes, each with how it is published, and "Take over host
4
+ * names" for the ones another system created (a record this deployment doesn't own). A host whose records of another type stand in the way is never taken
5
+ * over: it lists them for the administrator to delete at their DNS host. Shown only when the config publishes a host, and not at all on a server without
6
+ * the route.
7
+ */
8
+ export default function DnsProviderHosts({ uid }: {
9
+ uid: string;
10
+ }): React.JSX.Element | null;
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { listDnsHostPublications, takeoverDnsHosts } from "../../../../../lib/admin/dnsProvidersApi.js";
8
+ import Button from "../../../../../lib/components/buttons/Button.js";
9
+ import Modal from "../../../../../lib/components/overlays/Modal.js";
10
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
11
+ import Alert from "../ActionAlert.js";
12
+ import { actionErrorMessage } from "../elevation.js";
13
+ import { TypeMismatchNotice } from "./dnsProviderParts.js";
14
+ const MANUAL_REASONS = {
15
+ no_provider: "No DNS provider is assigned.",
16
+ kind_unsupported: "The DNS provider can't hold this kind of record.",
17
+ outside_zones: "This name is outside the zones of the DNS provider.",
18
+ apex_txt_unmanaged: "Shares the domain's root TXT records, which are managed only on the domain's opt-in.",
19
+ no_addresses: "The server has no public address to publish: set the public addresses, then it is published.",
20
+ no_target: "There is no mail host to point it at.",
21
+ };
22
+ const STATE_LABELS = {
23
+ published: "Published",
24
+ pending: "Pending",
25
+ conflict: "Conflict",
26
+ manual: "Manual",
27
+ };
28
+ /** A host record's name once per name: an address host has an A and an AAAA record at one name. */
29
+ function uniqueNames(records) {
30
+ return [...new Set(records.map((record) => record.recordName))];
31
+ }
32
+ /**
33
+ * The deployment's own host names (mail, auth, autodiscover, openpgpkey) that this config publishes, each with how it is published, and "Take over host
34
+ * names" for the ones another system created (a record this deployment doesn't own). A host whose records of another type stand in the way is never taken
35
+ * over: it lists them for the administrator to delete at their DNS host. Shown only when the config publishes a host, and not at all on a server without
36
+ * the route.
37
+ */
38
+ export default function DnsProviderHosts({ uid }) {
39
+ const [hosts, setHosts] = useState(null);
40
+ const [error, setError] = useState(null);
41
+ const [confirming, setConfirming] = useState(false);
42
+ const [taking, setTaking] = useState(false);
43
+ const [takeError, setTakeError] = useState(null);
44
+ const [queued, setQueued] = useState(false);
45
+ function load(isCurrent = () => true) {
46
+ listDnsHostPublications()
47
+ .then((loaded) => {
48
+ if (!isCurrent())
49
+ return;
50
+ setHosts(loaded.filter((host) => host.providerUid === uid));
51
+ setError(null);
52
+ })
53
+ .catch((err) => {
54
+ if (!isCurrent())
55
+ return;
56
+ if (err instanceof ApiRequestError && err.status === 404) {
57
+ setHosts([]);
58
+ return;
59
+ }
60
+ setError(actionErrorMessage(err, "Could not load the host names."));
61
+ });
62
+ }
63
+ useEffect(() => {
64
+ let current = true;
65
+ load(() => current);
66
+ return () => {
67
+ current = false;
68
+ };
69
+ }, [uid]);
70
+ async function handleTakeOver(names) {
71
+ setTaking(true);
72
+ setTakeError(null);
73
+ try {
74
+ await takeoverDnsHosts(uid, names);
75
+ setQueued(true);
76
+ setConfirming(false);
77
+ }
78
+ catch (err) {
79
+ setTakeError(actionErrorMessage(err, "Could not queue the takeover."));
80
+ }
81
+ finally {
82
+ setTaking(false);
83
+ }
84
+ }
85
+ if (error) {
86
+ return (_jsx("section", { className: "mt-8 max-w-3xl", children: _jsx(Alert, { children: error }) }));
87
+ }
88
+ if (!hosts || hosts.length === 0) {
89
+ return null;
90
+ }
91
+ const mismatched = new Set(uniqueNames(hosts.filter((host) => host.state === "conflict" && host.conflictReason === "type_mismatch")));
92
+ const notOwned = uniqueNames(hosts.filter((host) => host.state === "conflict" && host.conflictReason !== "type_mismatch")).filter((name) => !mismatched.has(name));
93
+ return (_jsxs("section", { className: "mt-8 max-w-3xl", children: [_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Host names" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "The names of this server itself that this provider publishes, such as the mail host. Records you already have at these names are shown as conflicts." }), _jsxs("table", { "aria-label": "Host names", className: "w-full text-sm border-collapse mb-3", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Record", "Published"].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: hosts.map((host) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2 px-2.5 border-b border-border", children: _jsx("code", { className: "text-xs break-all", children: host.recordName }) }), _jsx("td", { className: "py-2 px-2.5 border-b border-border text-text-muted", children: host.recordKind }), _jsxs("td", { className: "py-2 px-2.5 border-b border-border", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${host.state === "published"
94
+ ? "bg-success text-white"
95
+ : host.state === "conflict"
96
+ ? "bg-danger-bg text-danger"
97
+ : "bg-surface-alt text-text-muted"}`, children: STATE_LABELS[host.state] }), host.state === "manual" && host.manualReason && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: MANUAL_REASONS[host.manualReason] })), host.state === "conflict" && host.conflictReason === "type_mismatch" && _jsx(TypeMismatchNotice, { existing: host.existing }), host.state === "conflict" && host.conflictReason !== "type_mismatch" && (_jsx("div", { className: "text-xs mt-1 max-w-xs", children: "A record is there that this deployment doesn\u2019t own." }))] })] }, `${host.recordName} ${host.recordKind}`))) })] }), queued && _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Takeover queued. The DNS controller overwrites the records shortly; refresh to see the result." }), _jsxs("div", { className: "flex gap-3", children: [notOwned.length > 0 && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: queued, onClick: () => setConfirming(true), children: "Take over host names" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => {
98
+ setQueued(false);
99
+ load();
100
+ }, children: "Refresh" })] }), _jsx(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Take over host names", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsx("p", { children: "Taking over overwrites the records at these names with the values this server publishes. From then on RapidMX manages them, and what was there is lost." }), _jsx("ul", { className: "list-none flex flex-col gap-0.5", children: notOwned.map((name) => (_jsx("li", { children: _jsx("code", { className: "text-xs break-all", children: name }) }, name))) }), takeError && _jsx(Alert, { children: takeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: taking, onClick: () => setConfirming(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: taking, disabled: taking, onClick: () => handleTakeOver(notOwned), children: "Take over" })] })] }) })] }));
101
+ }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ /**
3
+ * "Test connection" and the config's latest operations (connection tests, takeovers, restores of a domain's root TXT records), which the DNS controller
4
+ * runs and reports on. A test is only queued here: its result arrives with the controller's next report, so the list can be refreshed.
5
+ */
6
+ export default function DnsProviderOperations({ uid }: {
7
+ uid: string;
8
+ }): React.JSX.Element;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { listDnsProviderOperations, testDnsProvider } from "../../../../../lib/admin/dnsProvidersApi.js";
8
+ import Button from "../../../../../lib/components/buttons/Button.js";
9
+ import Alert from "../ActionAlert.js";
10
+ import { actionErrorMessage } from "../elevation.js";
11
+ import { formatTime } from "./dnsProviderParts.js";
12
+ const TYPE_LABELS = {
13
+ test: "Connection test",
14
+ takeover: "Take over",
15
+ restore: "Restore",
16
+ };
17
+ const STATE_LABELS = {
18
+ queued: "Queued",
19
+ running: "Running",
20
+ succeeded: "Succeeded",
21
+ failed: "Failed",
22
+ };
23
+ /**
24
+ * "Test connection" and the config's latest operations (connection tests, takeovers, restores of a domain's root TXT records), which the DNS controller
25
+ * runs and reports on. A test is only queued here: its result arrives with the controller's next report, so the list can be refreshed.
26
+ */
27
+ export default function DnsProviderOperations({ uid }) {
28
+ const [operations, setOperations] = useState([]);
29
+ const [loaded, setLoaded] = useState(false);
30
+ const [error, setError] = useState(null);
31
+ const [testing, setTesting] = useState(false);
32
+ const [testError, setTestError] = useState(null);
33
+ const [queued, setQueued] = useState(false);
34
+ function load() {
35
+ listDnsProviderOperations(uid)
36
+ .then((loadedOperations) => {
37
+ setOperations(loadedOperations);
38
+ setLoaded(true);
39
+ setError(null);
40
+ })
41
+ .catch((err) => setError(actionErrorMessage(err, "Could not load the operations.")));
42
+ }
43
+ useEffect(load, [uid]);
44
+ async function handleTest() {
45
+ setTesting(true);
46
+ setTestError(null);
47
+ try {
48
+ const operation = await testDnsProvider(uid);
49
+ setOperations((current) => [operation, ...current]);
50
+ setQueued(true);
51
+ }
52
+ catch (err) {
53
+ setTestError(actionErrorMessage(err, "Could not queue the connection test."));
54
+ }
55
+ finally {
56
+ setTesting(false);
57
+ }
58
+ }
59
+ return (_jsxs("section", { className: "mt-8 max-w-3xl", children: [_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Connection and operations" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Test connection has the DNS controller run external-dns once with the saved credentials, to check that they work and reach the zones." }), _jsxs("div", { className: "flex gap-3 mb-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: testing, disabled: testing, onClick: handleTest, children: "Test connection" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: load, children: "Refresh" })] }), testError && _jsx(Alert, { children: testError }), queued && (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "Test queued. The DNS controller runs the test and reports the result here; refresh to see it." })), error && _jsx(Alert, { children: error }), operations.length > 0 ? (_jsxs("table", { "aria-label": "Recent operations", className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Operation", "State", "Result", "When"].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: operations.map((operation) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2 px-2.5 border-b border-border", children: TYPE_LABELS[operation.type] + (operation.domainUid ? ` (${operation.domainUid})` : operation.endpointSetId?.startsWith("hosts-") ? " (host names)" : "") }), _jsx("td", { className: "py-2 px-2.5 border-b border-border", children: STATE_LABELS[operation.state] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border text-text-muted", children: operation.message }), _jsx("td", { className: "py-2 px-2.5 border-b border-border text-text-muted whitespace-nowrap", children: formatTime(operation.finishedAt ?? operation.startedAt ?? operation.dateCreated) })] }, operation.uid))) })] })) : loaded ? (_jsx("p", { className: "text-sm text-text-muted", children: "No operations yet." })) : (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }))] }));
60
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
3
+ /** The DNS resolver section: how this server looks up DNS records. */
4
+ export default function DnsResolverForm({ settings, onChange }: {
5
+ settings: IntegrationSettings;
6
+ onChange: (settings: IntegrationSettings) => void;
7
+ }): React.JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import IntegrationSectionForm from "./IntegrationSectionForm.js";
3
+ import { INPUT_CLASS } from "./SecretField.js";
4
+ const MODES = {
5
+ node: "The system resolver",
6
+ "doh-dnssec": "DNS over HTTPS, with DNSSEC validation",
7
+ };
8
+ const FIELDS = {
9
+ mode: { name: "resolver", describe: (mode) => MODES[mode] },
10
+ dohEndpoint: { name: "DoH endpoint", describe: (url) => url },
11
+ };
12
+ /** The DNS resolver section: how this server looks up DNS records. */
13
+ export default function DnsResolverForm({ settings, onChange }) {
14
+ return (_jsx(IntegrationSectionForm, { section: "dnsResolver", title: "DNS resolver", description: "How this server looks up DNS records: for domain checks and verification, key discovery and mail delivery decisions.", consequence: "Every DNS check, domain verification and key discovery uses the new resolver as soon as it is saved, and answers already looked up are dropped.", fields: FIELDS, settings: settings, onChange: onChange, children: (form) => (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Resolver" }), _jsx("select", { "aria-label": "Resolver", className: INPUT_CLASS, value: form.value("mode"), onChange: (e) => form.set("mode", e.target.value), children: Object.entries(MODES).map(([mode, label]) => (_jsx("option", { value: mode, children: label }, mode))) })] }), form.footer("mode"), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "DoH endpoint" }), _jsx("input", { "aria-label": "DoH endpoint", type: "url", className: INPUT_CLASS, value: form.value("dohEndpoint"), onChange: (e) => form.set("dohEndpoint", e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The DNS over HTTPS service queried, https:// only. Used with DNS over HTTPS." })] }), form.footer("dohEndpoint")] })) }));
15
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
3
+ /** The Giphy section: whether GIF search is offered, and the API key it uses. */
4
+ export default function GiphyForm({ settings, onChange }: {
5
+ settings: IntegrationSettings;
6
+ onChange: (settings: IntegrationSettings) => void;
7
+ }): React.JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import IntegrationSectionForm, { secretSectionField } from "./IntegrationSectionForm.js";
3
+ import SecretField from "./SecretField.js";
4
+ const FIELDS = {
5
+ enabled: { name: "GIF search", describe: (value) => (value ? "on" : "off") },
6
+ apiKey: secretSectionField("API key"),
7
+ };
8
+ /** The Giphy section: whether GIF search is offered, and the API key it uses. */
9
+ export default function GiphyForm({ settings, onChange }) {
10
+ return (_jsx(IntegrationSectionForm, { section: "giphy", title: "Giphy", description: "GIF search in the message editor, through Giphy's API.", fields: FIELDS, settings: settings, onChange: onChange, children: (form) => (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", "aria-label": "Use Giphy for GIF search", checked: form.value("enabled"), onChange: (e) => form.set("enabled", e.target.checked) }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Use Giphy for GIF search" }), _jsx("span", { className: "text-xs text-text-muted", children: "Off, the editor offers no GIF search and the key is not used." })] })] }), form.footer("enabled"), _jsxs("div", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "API key" }), _jsx(SecretField, { id: "giphy-api-key", label: "API key", name: "API key", saved: form.secret("apiKey"), unreadable: form.unreadable("apiKey"), value: form.value("apiKey"), onChange: (value) => form.set("apiKey", value) })] }), form.footer("apiKey")] })) }));
11
+ }