@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
@@ -0,0 +1,54 @@
1
+ import React, { ReactNode } from "react";
2
+ import { IntegrationSectionName, IntegrationSettings, IntegrationSource } from "../../../../../lib/admin/integrationsApi.js";
3
+ /** How a section's form treats one of its fields. */
4
+ export interface SectionField {
5
+ /** How the field's reset button names it ("Reset API key to the deployment default"). */
6
+ name: string;
7
+ /** The deployment's value, in words, for the line beneath the field. */
8
+ describe: (value: any) => string;
9
+ /** The form's value for a value of the view, saved here (`admin`) or the deployment's (the value itself when left out). */
10
+ toForm?: (value: any, source: IntegrationSource) => unknown;
11
+ /** The form's value as the server shows it, to compare with the saved and the deployment's values (the value itself when left out). */
12
+ toWire?: (formValue: any) => unknown;
13
+ /** What saving sends for the form's value, when it differs from `toWire()`'s. */
14
+ toSend?: (formValue: any) => unknown;
15
+ /** False when the form's value isn't the deployment's though `toWire()` shows it the same: a secret saved here masked like the deployment's.
16
+ * Such a value is never taken as edited back to the deployment's (reset), which would drop the secret saved here. */
17
+ canBeDeployment?: (formValue: any) => boolean;
18
+ }
19
+ /** A secret field: no value in the form (it is write-only, `SecretField`), "set" or "none" for the deployment's. */
20
+ export declare function secretSectionField(name: string): SectionField;
21
+ /** What a section form's fields are drawn with. */
22
+ export interface SectionForm {
23
+ /** The field's value in the form. */
24
+ value(field: string): any;
25
+ /** Changes the field; `undefined` takes back any change. */
26
+ set(field: string, value: unknown): void;
27
+ /** What is saved for a secret field now: nothing, a value saved here, or the deployment's. A pending reset shows the deployment's. */
28
+ secret(field: string): "none" | "here" | "deployment";
29
+ /** Whether the secret saved here for the field is one the server can't decrypt, so that it must be entered again. */
30
+ unreadable(field: string): boolean;
31
+ /** Whether the form holds a value of the administrator's for the field, rather than the deployment's. */
32
+ setHere(field: string): boolean;
33
+ /** The line beneath a field: the deployment's value and, for a field set here, the button that puts it back. */
34
+ footer(field: string): ReactNode;
35
+ }
36
+ export interface IntegrationSectionFormProps {
37
+ section: IntegrationSectionName;
38
+ title: string;
39
+ description: ReactNode;
40
+ /** What a change affects, stated above the Save button. */
41
+ consequence?: ReactNode;
42
+ fields: Record<string, SectionField>;
43
+ settings: IntegrationSettings;
44
+ onChange: (settings: IntegrationSettings) => void;
45
+ children: (form: SectionForm) => ReactNode;
46
+ }
47
+ /**
48
+ * One Integrations section's form, following the policy pages: each field shows the deployment's value beneath it and, while the form holds a value
49
+ * saved here, a "Reset to deployment default" that puts the field back in the form only - Save sends it, with the rest, as `null`. A field is never
50
+ * saved as a copy of the deployment's value (it would stop following the deployment's configuration): edited back to it, it is reset too. Only what
51
+ * changes is sent, with the version the settings were read at, so a change made over someone else's is refused rather than overwriting it. A save
52
+ * the server refuses for want of a recently confirmed identity offers to confirm it again (`ActionAlert`).
53
+ */
54
+ export default function IntegrationSectionForm({ section, title, description, consequence, fields, settings, onChange, children, }: IntegrationSectionFormProps): React.JSX.Element;
@@ -0,0 +1,131 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import React, { useState } from "react";
7
+ import { getIntegrations, isSecretMask, isUnreadableSecret, updateIntegrationSection, } from "../../../../../lib/admin/integrationsApi.js";
8
+ import Button from "../../../../../lib/components/buttons/Button.js";
9
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
10
+ import Alert from "../ActionAlert.js";
11
+ import { actionErrorMessage } from "../elevation.js";
12
+ /** A field put back to the deployment's value, pending the form's Save. */
13
+ const RESET = Symbol("reset");
14
+ /** A secret field: no value in the form (it is write-only, `SecretField`), "set" or "none" for the deployment's. */
15
+ export function secretSectionField(name) {
16
+ return { name, describe: (value) => (isSecretMask(value) ? "set" : "none"), toForm: () => undefined };
17
+ }
18
+ /** Whether two values are the same once sent (`undefined` counting as `null`, and a secret's `unreadable` flag, which is only the server's remark, not
19
+ * counting at all). */
20
+ function same(a, b) {
21
+ const plain = (value) => JSON.stringify(value ?? null, (key, inner) => (key === "unreadable" ? undefined : inner));
22
+ return plain(a) === plain(b);
23
+ }
24
+ /**
25
+ * One Integrations section's form, following the policy pages: each field shows the deployment's value beneath it and, while the form holds a value
26
+ * saved here, a "Reset to deployment default" that puts the field back in the form only - Save sends it, with the rest, as `null`. A field is never
27
+ * saved as a copy of the deployment's value (it would stop following the deployment's configuration): edited back to it, it is reset too. Only what
28
+ * changes is sent, with the version the settings were read at, so a change made over someone else's is refused rather than overwriting it. A save
29
+ * the server refuses for want of a recently confirmed identity offers to confirm it again (`ActionAlert`).
30
+ */
31
+ export default function IntegrationSectionForm({ section, title, description, consequence, fields, settings, onChange, children, }) {
32
+ const view = settings[section];
33
+ const [edits, setEdits] = useState({});
34
+ // Bumped by a save, so the fields start over (a credential being replaced is shown as saved again).
35
+ const [formKey, setFormKey] = useState(0);
36
+ const [error, setError] = useState(null);
37
+ const [saving, setSaving] = useState(false);
38
+ const [saved, setSaved] = useState(false);
39
+ const toForm = (field, value, source) => (fields[field].toForm ? fields[field].toForm(value, source) : value);
40
+ /** The field's change as the server would show it, the deployment's value for a reset. */
41
+ const wire = (field, edit) => (edit === RESET ? view.defaults[field] : fields[field].toWire ? fields[field].toWire(edit) : edit);
42
+ /** Whether the field's change puts it back to the deployment's value: a reset, a value left empty (`null`, which the server takes as unset), or the
43
+ * deployment's own. */
44
+ const isDeployment = (field, edit) => edit === RESET ||
45
+ wire(field, edit) === null ||
46
+ (same(wire(field, edit), view.defaults[field]) && (fields[field].canBeDeployment?.(edit) ?? true));
47
+ const form = {
48
+ value: (field) => field in edits
49
+ ? edits[field] === RESET
50
+ ? toForm(field, view.defaults[field], "deployment")
51
+ : edits[field]
52
+ : toForm(field, view.values[field], view.source[field]),
53
+ set: (field, value) => setEdits((current) => {
54
+ const next = { ...current };
55
+ if (value === undefined) {
56
+ delete next[field];
57
+ }
58
+ else {
59
+ next[field] = value;
60
+ }
61
+ return next;
62
+ }),
63
+ secret: (field) => {
64
+ const reset = edits[field] === RESET;
65
+ const shown = reset ? view.defaults[field] : view.values[field];
66
+ const source = reset ? "deployment" : view.source[field];
67
+ return !isSecretMask(shown) ? "none" : source === "admin" ? "here" : "deployment";
68
+ },
69
+ unreadable: (field) => edits[field] !== RESET && view.source[field] === "admin" && isUnreadableSecret(view.values[field]),
70
+ setHere: (field) => field in edits
71
+ ? !isDeployment(field, edits[field])
72
+ : // A registry elsewhere than the deployment's shows its token as none set here (restapi's view): nothing to reset.
73
+ view.source[field] === "admin" && view.values[field] !== null,
74
+ footer: (field) => (_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 text-xs text-text-muted -mt-1", children: [_jsxs("span", { children: ["Deployment default: ", fields[field].describe(view.defaults[field])] }), form.setHere(field) && (_jsx(Button, { type: "button", variant: "text", className: "text-xs", "aria-label": `Reset ${fields[field].name} to the deployment default`, onClick: () => form.set(field, RESET), children: "Reset to deployment default" }))] })),
75
+ };
76
+ async function handleSubmit(e) {
77
+ e.preventDefault();
78
+ setError(null);
79
+ setSaved(false);
80
+ const patch = {};
81
+ for (const [field, edit] of Object.entries(edits)) {
82
+ const value = wire(field, edit);
83
+ const admin = view.source[field] === "admin";
84
+ if (isDeployment(field, edit)) {
85
+ // Back to the deployment's value: nothing to send unless one is saved here.
86
+ if (admin) {
87
+ patch[field] = null;
88
+ }
89
+ }
90
+ else if (!admin || !same(value, view.values[field])) {
91
+ patch[field] = fields[field].toSend ? fields[field].toSend(edit) : value;
92
+ }
93
+ }
94
+ if (Object.keys(patch).length === 0) {
95
+ setEdits({});
96
+ setSaved(true);
97
+ return;
98
+ }
99
+ setSaving(true);
100
+ try {
101
+ onChange(await updateIntegrationSection(section, { ...patch, version: settings.version }));
102
+ setEdits({});
103
+ setFormKey((key) => key + 1);
104
+ setSaved(true);
105
+ }
106
+ catch (err) {
107
+ setError(await refusalMessage(err));
108
+ }
109
+ finally {
110
+ setSaving(false);
111
+ }
112
+ }
113
+ /** What a failed save says. A change refused for a newer version loads the settings again (keeping the entries, which are compared with them when
114
+ * saving), so that the next Save is made against the version now saved rather than failing the same way until the page is reloaded. */
115
+ async function refusalMessage(err) {
116
+ if (err instanceof ApiRequestError && err.status === 409) {
117
+ try {
118
+ const latest = await getIntegrations();
119
+ if (latest.version !== settings.version) {
120
+ onChange(latest);
121
+ return "The integration settings were changed by someone else. They are shown as saved now, with your entries kept: check them and save again.";
122
+ }
123
+ }
124
+ catch {
125
+ // Not loaded: the server's own message stands.
126
+ }
127
+ }
128
+ return actionErrorMessage(err, `Could not save the ${title} settings.`);
129
+ }
130
+ return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: title }), _jsx("div", { className: "text-sm text-text-muted mb-5", children: description }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsx(React.Fragment, { children: children(form) }, formKey), consequence && _jsx("p", { className: "text-xs text-text-muted", children: consequence }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
131
+ }
@@ -0,0 +1,12 @@
1
+ import React, { ReactNode } from "react";
2
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
3
+ import { AdminShellProps } from "../layout/AdminShell.js";
4
+ import { IntegrationArea } from "./IntegrationsLayout.js";
5
+ /** What a server from before Integrations (no `system/integrations` route) is shown instead of a bare "Not Found". */
6
+ export declare const NO_INTEGRATIONS_MESSAGE = "This server doesn't offer Integrations yet. Upgrade it to manage them here.";
7
+ export interface IntegrationSectionPageProps extends Omit<AdminShellProps, "active"> {
8
+ area: Exclude<IntegrationArea, "dnsProviders">;
9
+ children: (settings: IntegrationSettings, onChange: (settings: IntegrationSettings) => void) => ReactNode;
10
+ }
11
+ /** One Integrations section's page: the console's frame, the Integrations navigation, and the section's form once the settings have loaded. */
12
+ export default function IntegrationSectionPage({ area, children, ...props }: IntegrationSectionPageProps): React.JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getIntegrations } from "../../../../../lib/admin/integrationsApi.js";
3
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
4
+ import AdminShell from "../layout/AdminShell.js";
5
+ import LoadedSettingsForm from "../settings/LoadedSettingsForm.js";
6
+ import IntegrationsLayout from "./IntegrationsLayout.js";
7
+ /** What a server from before Integrations (no `system/integrations` route) is shown instead of a bare "Not Found". */
8
+ export const NO_INTEGRATIONS_MESSAGE = "This server doesn't offer Integrations yet. Upgrade it to manage them here.";
9
+ /** The integration settings, with a server that has none answering `NO_INTEGRATIONS_MESSAGE`. */
10
+ async function loadIntegrations() {
11
+ try {
12
+ return await getIntegrations();
13
+ }
14
+ catch (err) {
15
+ throw err instanceof ApiRequestError && err.status === 404 ? new ApiRequestError(NO_INTEGRATIONS_MESSAGE, 404) : err;
16
+ }
17
+ }
18
+ /** One Integrations section's page: the console's frame, the Integrations navigation, and the section's form once the settings have loaded. */
19
+ export default function IntegrationSectionPage({ area, children, ...props }) {
20
+ return (_jsx(AdminShell, { ...props, active: "integrations", children: _jsx(IntegrationsLayout, { active: area, children: _jsx(LoadedSettingsForm, { load: loadIntegrations, loadErrorMessage: "Could not load the integrations.", children: children }) }) }));
21
+ }
@@ -0,0 +1,14 @@
1
+ import React, { ReactNode } from "react";
2
+ /** The Integrations section's areas, in the order the console shows them. */
3
+ export type IntegrationArea = "dnsProviders" | "giphy" | "stun" | "signingCa" | "dnsResolver" | "pluginRegistry";
4
+ export declare const INTEGRATION_AREAS: readonly {
5
+ id: IntegrationArea;
6
+ href: string;
7
+ label: string;
8
+ description: string;
9
+ }[];
10
+ /** The Integrations heading and the navigation between its areas (`active` is the current one; none on the overview), above the area's page. */
11
+ export default function IntegrationsLayout({ active, children }: {
12
+ active?: IntegrationArea;
13
+ children: ReactNode;
14
+ }): React.JSX.Element;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export const INTEGRATION_AREAS = [
3
+ {
4
+ id: "dnsProviders",
5
+ href: "/admin/integrations/dns-providers",
6
+ label: "DNS providers",
7
+ description: "The DNS hosting accounts that publish the DNS records of this server and its domains, so they don't have to be added by hand.",
8
+ },
9
+ {
10
+ id: "giphy",
11
+ href: "/admin/integrations/giphy",
12
+ label: "Giphy",
13
+ description: "GIF search in the message editor.",
14
+ },
15
+ {
16
+ id: "stun",
17
+ href: "/admin/integrations/stun",
18
+ label: "STUN servers",
19
+ description: "How video meetings find a way between participants' networks.",
20
+ },
21
+ {
22
+ id: "signingCa",
23
+ href: "/admin/integrations/signing-ca",
24
+ label: "S/MIME signing CA",
25
+ description: "How people get the certificates that sign their mail.",
26
+ },
27
+ {
28
+ id: "dnsResolver",
29
+ href: "/admin/integrations/dns-resolver",
30
+ label: "DNS resolver",
31
+ description: "How this server looks up DNS records.",
32
+ },
33
+ {
34
+ id: "pluginRegistry",
35
+ href: "/admin/integrations/plugin-registry",
36
+ label: "Plugin registry",
37
+ description: "Where plugins are found and installed from.",
38
+ },
39
+ ];
40
+ /** The Integrations heading and the navigation between its areas (`active` is the current one; none on the overview), above the area's page. */
41
+ export default function IntegrationsLayout({ active, children }) {
42
+ return (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: "Integrations" }), _jsx("nav", { "aria-label": "Integrations", className: "flex flex-wrap gap-x-4 gap-y-1 border-b border-border mb-6", children: INTEGRATION_AREAS.map((area) => (_jsx("a", { href: area.href, "aria-current": area.id === active ? "page" : undefined, className: [
43
+ "py-2 text-sm font-medium border-b-2 -mb-px",
44
+ area.id === active ? "border-primary text-primary-dark" : "border-transparent text-text-muted hover:text-text",
45
+ ].join(" "), children: area.label }, area.id))) }), children] }));
46
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
3
+ /** The plugin registry section: where plugins are found and installed from, its token, and the namespaces searched. */
4
+ export default function PluginRegistryForm({ settings, onChange }: {
5
+ settings: IntegrationSettings;
6
+ onChange: (settings: IntegrationSettings) => void;
7
+ }): React.JSX.Element;
@@ -0,0 +1,90 @@
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 { useRef } from "react";
7
+ import { isSecretMask, isUnreadableSecret, } from "../../../../../lib/admin/integrationsApi.js";
8
+ import Button from "../../../../../lib/components/buttons/Button.js";
9
+ import IntegrationSectionForm, { secretSectionField } from "./IntegrationSectionForm.js";
10
+ import SecretField, { INPUT_CLASS } from "./SecretField.js";
11
+ /** `name` as the server takes a scope (restapi `normalizePluginNamespaces()`): trimmed, with its `@`. */
12
+ function scopeOf(name) {
13
+ const scope = name.trim();
14
+ return scope.startsWith("@") ? scope : `@${scope}`;
15
+ }
16
+ /** Where `draft`'s saved token is saved: none once the namespace is renamed, since the server keeps a token only under the scope it was saved for. */
17
+ function savedToken(draft) {
18
+ return draft.saved !== "none" && draft.savedName !== undefined && scopeOf(draft.name) === scopeOf(draft.savedName) ? draft.saved : "none";
19
+ }
20
+ /** `drafts` as the server takes them: a token typed is sent, a saved one as the mask (kept) when `keep` says so, none left out. */
21
+ function namespacesOf(drafts, keep) {
22
+ return drafts.map((draft) => {
23
+ const registry = draft.registry.trim();
24
+ const token = draft.token ?? (keep(savedToken(draft)) ? { secret: true } : undefined);
25
+ return { name: draft.name.trim(), ...(registry ? { registry } : {}), ...(token ? { token } : {}) };
26
+ });
27
+ }
28
+ /** Whether two registry URLs are the same registry, credentials in them aside (restapi `sameRegistryUrl()`): a token goes only to its own registry. */
29
+ function sameRegistryUrl(a, b) {
30
+ const href = (value) => {
31
+ try {
32
+ const url = new URL(value.trim());
33
+ url.username = "";
34
+ url.password = "";
35
+ return url.href;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ };
41
+ const left = href(a);
42
+ return left !== undefined && left === href(b);
43
+ }
44
+ const FIELDS = {
45
+ url: { name: "registry URL", describe: (url) => url },
46
+ token: secretSectionField("registry token"),
47
+ namespaces: {
48
+ name: "plugin namespaces",
49
+ describe: (namespaces) => namespaces.map((namespace) => namespace.name).join(", ") || "none",
50
+ toForm: (namespaces, source) => namespaces.map((namespace, index) => ({
51
+ id: `ns-${index}`,
52
+ name: namespace.name,
53
+ registry: namespace.registry ?? "",
54
+ savedName: namespace.name,
55
+ saved: !isSecretMask(namespace.token) ? "none" : source === "admin" ? "here" : "deployment",
56
+ unreadable: source === "admin" && isUnreadableSecret(namespace.token),
57
+ })),
58
+ // Compared as the server shows the list: a saved token as the mask, wherever it is saved.
59
+ toWire: (drafts) => namespacesOf(drafts, (saved) => saved !== "none"),
60
+ // Only a token saved here can be kept; the deployment's stay with the deployment's list.
61
+ toSend: (drafts) => namespacesOf(drafts, (saved) => saved === "here"),
62
+ // A token saved here shows as the same mask as the deployment's but isn't it: a list keeping one is saved, never reset (which deletes it).
63
+ canBeDeployment: (drafts) => drafts.every((draft) => draft.token !== undefined || savedToken(draft) !== "here"),
64
+ },
65
+ };
66
+ /** The plugin namespaces: each a scope, optionally its own registry, and a write-only token. */
67
+ function NamespacesEditor({ form }) {
68
+ const nextId = useRef(0);
69
+ const drafts = form.value("namespaces");
70
+ const change = (next) => form.set("namespaces", next);
71
+ const update = (id, patch) => change(drafts.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft)));
72
+ return (_jsxs("div", { className: "flex flex-col gap-2 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Plugin namespaces" }), _jsx("span", { className: "text-xs text-text-muted", children: "The npm scopes the plugin catalog searches. Plugins in them may be installed." }), drafts.length === 0 && _jsx("p", { className: "text-xs text-text-muted", children: "No namespaces: the plugin catalog searches none." }), drafts.map((draft, index) => {
73
+ const n = index + 1;
74
+ return (_jsxs("fieldset", { className: "border border-border rounded-sm p-3 flex flex-col gap-2", children: [_jsxs("legend", { className: "sr-only", children: ["Namespace ", n] }), _jsx("input", { "aria-label": `Scope of namespace ${n}`, className: INPUT_CLASS, placeholder: "@acme", value: draft.name, onChange: (e) => update(draft.id, { name: e.target.value }) }), _jsx("input", { "aria-label": `Registry of namespace ${n}`, type: "url", className: INPUT_CLASS, placeholder: "Its own registry, if not the one above (https://...)", value: draft.registry, onChange: (e) => update(draft.id, { registry: e.target.value }) }), _jsx(SecretField, { id: `namespace-token-${draft.id}`, label: `Token of namespace ${n}`, name: `token of namespace ${n}`, saved: savedToken(draft), unreadable: draft.unreadable && savedToken(draft) === "here", value: draft.token, onChange: (token) => update(draft.id, { token }) }), draft.saved !== "none" && savedToken(draft) === "none" && (_jsxs("span", { className: "text-xs text-text-muted", children: ["The token saved for ", draft.savedName, " doesn\u2019t follow a new scope: enter it again, or leave it empty for none."] })), _jsx(Button, { type: "button", variant: "text", className: "!w-auto self-start text-xs", "aria-label": `Remove namespace ${n}`, onClick: () => change(drafts.filter((other) => other.id !== draft.id)), children: "Remove" })] }, draft.id));
75
+ }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto self-start", onClick: () => change([...drafts, { id: `new-${nextId.current++}`, name: "", registry: "", saved: "none" }]), children: "Add namespace" })] }));
76
+ }
77
+ /** The plugin registry section: where plugins are found and installed from, its token, and the namespaces searched. */
78
+ export default function PluginRegistryForm({ settings, onChange }) {
79
+ const view = settings.pluginRegistry;
80
+ // The deployment's registry token, and its namespaces' tokens for its registry, are only ever sent to its registry (restapi
81
+ // `buildIntegrationsConfigLayer()`): with another URL in the form, saved or about to be, none of them is used.
82
+ const deploymentRegistryTokens = isSecretMask(view.defaults.token) || view.defaults.namespaces.some((namespace) => !namespace.registry && isSecretMask(namespace.token));
83
+ /** Whether the form's registry gets none of those tokens and none of its own is set or typed. */
84
+ const noTokenUsed = (form) => deploymentRegistryTokens &&
85
+ form.secret("token") !== "here" &&
86
+ form.value("token") === undefined &&
87
+ !sameRegistryUrl(form.value("url"), view.defaults.url);
88
+ const deploymentTokens = view.source.namespaces === "deployment" && view.values.namespaces.some((namespace) => isSecretMask(namespace.token));
89
+ return (_jsx(IntegrationSectionForm, { section: "pluginRegistry", title: "Plugin registry", description: "The npm registry plugins are found and installed from.", consequence: "A change affects the next plugin install or update only; installed plugins are not touched.", 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: "Registry URL" }), _jsx("input", { "aria-label": "Registry URL", type: "url", className: INPUT_CLASS, value: form.value("url"), onChange: (e) => form.set("url", e.target.value) })] }), form.footer("url"), _jsxs("div", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Registry token" }), _jsx(SecretField, { id: "plugin-registry-token", label: "Registry token", name: "registry token", saved: form.secret("token"), unreadable: form.unreadable("token"), value: form.value("token"), onChange: (value) => form.set("token", value) }), noTokenUsed(form) && (_jsx("span", { className: "text-xs text-text-muted", children: "The deployment\u2019s tokens for its registry are only sent to the deployment\u2019s registry, so none is used with this one unless it is set here." }))] }), form.footer("token"), _jsx(NamespacesEditor, { form: form }), deploymentTokens && (_jsx("p", { className: "text-xs text-text-muted", children: "These are the deployment\u2019s namespaces. A changed list saved here replaces them, and the tokens the deployment sets for them aren\u2019t copied: enter them again." })), form.footer("namespaces")] })) }));
90
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ export declare 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";
3
+ export interface SecretFieldProps {
4
+ id: string;
5
+ /** The input's label. */
6
+ label: string;
7
+ /** How the buttons name the field ("Replace API key"). */
8
+ name: string;
9
+ /** What is saved now: nothing, a value saved here, or the deployment's. */
10
+ saved: "none" | "here" | "deployment";
11
+ /** A new value typed, `undefined` for none: what saving sends. */
12
+ value: string | undefined;
13
+ onChange: (value: string | undefined) => void;
14
+ /** A file's content rather than a token: a text area. */
15
+ multiLine?: boolean;
16
+ /** The saved value is set but the server can't decrypt it (its key is gone from the keyring): it must be entered again. */
17
+ unreadable?: boolean;
18
+ }
19
+ /**
20
+ * A write-only credential. The server never sends a saved value back, so a saved one is shown only as "Set" (or "Set by this deployment") with a
21
+ * Replace button; typing a value is what replaces it, and an empty field sends nothing. What is typed is never shown (a password field), and is not
22
+ * offered for the browser to remember. One the server can't decrypt is "Set, but can't be read: enter it again": it is still there, but only a new value
23
+ * helps.
24
+ */
25
+ export default function SecretField({ id, label, name, saved, value, onChange, multiLine, unreadable }: SecretFieldProps): React.JSX.Element;
@@ -0,0 +1,25 @@
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 Button from "../../../../../lib/components/buttons/Button.js";
8
+ export 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";
9
+ /**
10
+ * A write-only credential. The server never sends a saved value back, so a saved one is shown only as "Set" (or "Set by this deployment") with a
11
+ * Replace button; typing a value is what replaces it, and an empty field sends nothing. What is typed is never shown (a password field), and is not
12
+ * offered for the browser to remember. One the server can't decrypt is "Set, but can't be read: enter it again": it is still there, but only a new value
13
+ * helps.
14
+ */
15
+ export default function SecretField({ id, label, name, saved, value, onChange, multiLine = false, unreadable = false }) {
16
+ const [replacing, setReplacing] = useState(false);
17
+ if (saved !== "none" && !replacing) {
18
+ return (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [unreadable ? (_jsx("span", { className: "font-medium text-danger", children: "Set, but can't be read: enter it again" })) : (_jsx("span", { className: "font-medium", children: saved === "here" ? "Set" : "Set by this deployment" })), _jsx(Button, { type: "button", variant: "text", className: "text-xs", "aria-label": `Replace ${name}`, onClick: () => setReplacing(true), children: "Replace" })] }));
19
+ }
20
+ const change = (text) => onChange(text === "" ? undefined : text);
21
+ return (_jsxs("div", { className: "flex flex-col gap-1", children: [multiLine ? (_jsx("textarea", { id: id, "aria-label": label, rows: 6, spellCheck: false, autoComplete: "off", className: `${INPUT_CLASS} font-mono`, value: value ?? "", onChange: (e) => change(e.target.value) })) : (_jsx("input", { id: id, "aria-label": label, type: "password", autoComplete: "new-password", className: INPUT_CLASS, value: value ?? "", onChange: (e) => change(e.target.value) })), saved !== "none" && (_jsx(Button, { type: "button", variant: "text", className: "!w-auto self-start text-xs", "aria-label": `Keep the saved ${name}`, onClick: () => {
22
+ setReplacing(false);
23
+ onChange(undefined);
24
+ }, children: "Keep the saved value" }))] }));
25
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
3
+ /** The S/MIME signing CA section: how people get the certificates that sign their mail. */
4
+ export default function SigningCaForm({ settings, onChange }: {
5
+ settings: IntegrationSettings;
6
+ onChange: (settings: IntegrationSettings) => void;
7
+ }): React.JSX.Element;
@@ -0,0 +1,25 @@
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 BACKENDS = {
5
+ rfc8823: "Automatically, from a public CA (RFC 8823)",
6
+ manual: "Manually, by an administrator",
7
+ none: "Not offered",
8
+ };
9
+ /** Text, "none" when unset. */
10
+ const describeText = (value) => value ?? "none";
11
+ const FIELDS = {
12
+ backend: { name: "certificate issuing", describe: (backend) => BACKENDS[backend] },
13
+ directoryUrl: { name: "ACME directory URL", describe: describeText },
14
+ contactEmail: {
15
+ name: "contact email",
16
+ describe: describeText,
17
+ toForm: (value) => value ?? "",
18
+ toWire: (text) => text.trim() || null,
19
+ },
20
+ };
21
+ /** The S/MIME signing CA section: how people get the certificates that sign their mail. */
22
+ export default function SigningCaForm({ settings, onChange }) {
23
+ const deploymentContact = settings.signingCa.defaults.contactEmail;
24
+ return (_jsx(IntegrationSectionForm, { section: "signingCa", title: "S/MIME signing CA", description: "How a signing certificate is issued when someone asks for one in their encryption settings.", consequence: "A change affects new enrollments only; certificates already being issued finish with the CA they started with.", 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: "How signing certificates are issued" }), _jsx("select", { "aria-label": "How signing certificates are issued", className: INPUT_CLASS, value: form.value("backend"), onChange: (e) => form.set("backend", e.target.value), children: Object.entries(BACKENDS).map(([backend, label]) => (_jsx("option", { value: backend, children: label }, backend))) })] }), form.footer("backend"), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "ACME directory URL" }), _jsx("input", { "aria-label": "ACME directory URL", type: "url", className: INPUT_CLASS, value: form.value("directoryUrl"), onChange: (e) => form.set("directoryUrl", e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "The CA's RFC 8823 ACME directory, https:// only. Used when certificates are issued automatically." })] }), form.footer("directoryUrl"), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Contact email" }), _jsx("input", { "aria-label": "Contact email", type: "email", className: INPUT_CLASS, value: form.value("contactEmail"), onChange: (e) => form.set("contactEmail", e.target.value) }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Where the CA sends notices about this server's account.", deploymentContact && ` Left empty, the deployment's address (${deploymentContact}) is used.`] })] }), form.footer("contactEmail")] })) }));
25
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
3
+ /** The lines of `text` that hold something, trimmed. */
4
+ export declare function lines(text: string): string[];
5
+ /** The STUN section: the servers video meetings use to find a way between participants' networks. */
6
+ export default function StunForm({ settings, onChange }: {
7
+ settings: IntegrationSettings;
8
+ onChange: (settings: IntegrationSettings) => void;
9
+ }): React.JSX.Element;
@@ -0,0 +1,26 @@
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
+ /** The lines of `text` that hold something, trimmed. */
5
+ export function lines(text) {
6
+ return text
7
+ .split("\n")
8
+ .map((line) => line.trim())
9
+ .filter((line) => line !== "");
10
+ }
11
+ const FIELDS = {
12
+ urls: {
13
+ name: "STUN servers",
14
+ describe: (urls) => (urls ? urls.join(", ") : "the meet plugin's built-in list"),
15
+ toForm: (urls) => (urls ?? []).join("\n"),
16
+ // An emptied box is no list (the server refuses an empty one): the deployment's value.
17
+ toWire: (text) => {
18
+ const urls = lines(text);
19
+ return urls.length > 0 ? urls : null;
20
+ },
21
+ },
22
+ };
23
+ /** The STUN section: the servers video meetings use to find a way between participants' networks. */
24
+ export default function StunForm({ settings, onChange }) {
25
+ return (_jsx(IntegrationSectionForm, { section: "stun", title: "STUN servers", description: _jsx(_Fragment, { children: "The STUN servers video meetings hand to participants\u2019 browsers. TURN servers stay in the Meet plugin\u2019s settings." }), consequence: "Meetings started after saving use the new list.", 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: "STUN servers" }), _jsx("textarea", { "aria-label": "STUN servers", rows: 4, spellCheck: false, className: `${INPUT_CLASS} font-mono`, placeholder: "stun:stun.example.com:3478", value: form.value("urls"), onChange: (e) => form.set("urls", e.target.value) }), _jsx("span", { className: "text-xs text-text-muted", children: "One per line, 1 to 10 of them, each a stun: or stuns: address." })] }), form.footer("urls")] })) }));
26
+ }
@@ -0,0 +1,28 @@
1
+ import React, { ReactNode } from "react";
2
+ import type { DnsProvider } from "../../../../../lib/admin/dnsProvidersApi.js";
3
+ import type { DnsExistingRecord } from "../../../../../lib/admin/domainsApi.js";
4
+ /** A small label beside a name ("Not yet live-tested", "Added by the installer"). */
5
+ export declare function Badge({ children }: {
6
+ children: ReactNode;
7
+ }): React.JSX.Element;
8
+ /** How long without a report from the DNS controller before it is shown as possibly down: it reports every minute by default
9
+ * (`DNS_CONTROLLER_STATUS_INTERVAL_SECONDS`), so this is ten missed reports, well past a pod restart or a clock off by a few minutes. */
10
+ export declare const CONTROLLER_SILENT_MS: number;
11
+ /** Whether a report made at `iso` is older than `CONTROLLER_SILENT_MS`: what it says may no longer hold. */
12
+ export declare function isStaleReport(iso: string, now?: number): boolean;
13
+ /** What the console says of a provider config's external-dns instance: Disabled (the admin's switch), Unknown when the DNS controller last reported
14
+ * on it too long ago (it may be down: nothing it said still holds), Error with the last error the controller reported, Running (a ready pod), or Not
15
+ * ready (none yet, including a config the controller hasn't reported on). */
16
+ export declare function dnsProviderStatus(provider: DnsProvider, now?: number): {
17
+ label: "Disabled" | "Unknown" | "Error" | "Running" | "Not ready";
18
+ error?: string;
19
+ };
20
+ /** `iso` as the browser's local date and time, nothing when unset. */
21
+ export declare function formatTime(iso: string | undefined): string;
22
+ /** "1 domain", "2 domains". */
23
+ export declare function domainsText(count: number): string;
24
+ /** What a record in `type_mismatch` conflict says: records of another type stand where this one would be published, RapidMX never deletes or takes them over,
25
+ * and the administrator deletes them at their DNS host. Lists them (type and value) when the server names them. */
26
+ export declare function TypeMismatchNotice({ existing }: {
27
+ existing?: DnsExistingRecord[];
28
+ }): React.JSX.Element;
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** A small label beside a name ("Not yet live-tested", "Added by the installer"). */
3
+ export function Badge({ children }) {
4
+ return _jsx("span", { className: "ml-2 text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: children });
5
+ }
6
+ /** How long without a report from the DNS controller before it is shown as possibly down: it reports every minute by default
7
+ * (`DNS_CONTROLLER_STATUS_INTERVAL_SECONDS`), so this is ten missed reports, well past a pod restart or a clock off by a few minutes. */
8
+ export const CONTROLLER_SILENT_MS = 10 * 60 * 1000;
9
+ /** Whether a report made at `iso` is older than `CONTROLLER_SILENT_MS`: what it says may no longer hold. */
10
+ export function isStaleReport(iso, now = Date.now()) {
11
+ return now - new Date(iso).getTime() > CONTROLLER_SILENT_MS;
12
+ }
13
+ /** What the console says of a provider config's external-dns instance: Disabled (the admin's switch), Unknown when the DNS controller last reported
14
+ * on it too long ago (it may be down: nothing it said still holds), Error with the last error the controller reported, Running (a ready pod), or Not
15
+ * ready (none yet, including a config the controller hasn't reported on). */
16
+ export function dnsProviderStatus(provider, now = Date.now()) {
17
+ if (!provider.enabled) {
18
+ return { label: "Disabled" };
19
+ }
20
+ if (provider.status?.reportedAt && isStaleReport(provider.status.reportedAt, now)) {
21
+ return {
22
+ label: "Unknown",
23
+ error: `The DNS controller hasn't reported on it since ${formatTime(provider.status.reportedAt)}; it may be down.`,
24
+ };
25
+ }
26
+ if (provider.status?.lastError) {
27
+ return { label: "Error", error: provider.status.lastError };
28
+ }
29
+ return { label: (provider.status?.readyReplicas ?? 0) > 0 ? "Running" : "Not ready" };
30
+ }
31
+ /** `iso` as the browser's local date and time, nothing when unset. */
32
+ export function formatTime(iso) {
33
+ return iso ? new Date(iso).toLocaleString() : "";
34
+ }
35
+ /** "1 domain", "2 domains". */
36
+ export function domainsText(count) {
37
+ return count === 1 ? "1 domain" : `${count} domains`;
38
+ }
39
+ /** What a record in `type_mismatch` conflict says: records of another type stand where this one would be published, RapidMX never deletes or takes them over,
40
+ * and the administrator deletes them at their DNS host. Lists them (type and value) when the server names them. */
41
+ export function TypeMismatchNotice({ existing }) {
42
+ return (_jsxs("div", { className: "text-xs mt-1 max-w-xs", children: ["Records of another type are in the way. RapidMX never deletes them; delete them at your DNS host and this record is published.", existing && existing.length > 0 && (_jsx("ul", { className: "mt-1 list-none flex flex-col gap-0.5", children: existing.map((record) => (_jsx("li", { children: _jsx("code", { className: "break-all", children: `${record.type} ${record.value}` }) }, `${record.type} ${record.value}`))) }))] }));
43
+ }
@@ -2,7 +2,7 @@ import "../../../styles/app.css";
2
2
  import React, { PropsWithChildren } from "react";
3
3
  import type { NavItem } from "../../../../../lib/components/navigation/BottomTabBar.js";
4
4
  import { PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
5
- export type AdminSection = "mailboxes" | "quarantine" | "ingestQueue" | "domains" | "auditLog" | "distributionLists" | "transportRules" | "escrowScopes" | "retentionPolicy" | "encryptionPolicy" | "mailboxPolicy" | "setup" | "dataRequests" | "plugins" | "diagnostics" | "branding" | "signingCertificates";
5
+ export type AdminSection = "mailboxes" | "quarantine" | "ingestQueue" | "domains" | "auditLog" | "distributionLists" | "transportRules" | "escrowScopes" | "retentionPolicy" | "encryptionPolicy" | "mailboxPolicy" | "setup" | "dataRequests" | "plugins" | "diagnostics" | "branding" | "signingCertificates" | "integrations" | "configurationEncryption";
6
6
  /** A core `AdminSection`, or a plugin's `adminNav` item id (see `PluginNav`). */
7
7
  export type AdminShellActive = AdminSection | (string & {});
8
8
  export interface AdminShellProps extends PluginNavProps {