@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,230 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useRef } from "react";
6
+ import {
7
+ IntegrationPluginNamespace,
8
+ IntegrationSettings,
9
+ IntegrationSource,
10
+ isSecretMask,
11
+ isUnreadableSecret,
12
+ SecretMask,
13
+ } from "../../../../../lib/admin/integrationsApi.js";
14
+ import Button from "../../../../../lib/components/buttons/Button.js";
15
+ import IntegrationSectionForm, { secretSectionField, SectionField, SectionForm } from "./IntegrationSectionForm.js";
16
+ import SecretField, { INPUT_CLASS } from "./SecretField.js";
17
+
18
+ /** One namespace in the form. Its token is write-only: `saved` says whether one is saved and where, `token` is a new one typed. */
19
+ interface NamespaceDraft {
20
+ /** Stable for the row's life, so its fields keep their state when another row is removed. */
21
+ id: string;
22
+ name: string;
23
+ registry: string;
24
+ /** The scope its saved token is saved under (as loaded): the server keeps a token by scope, so a renamed namespace has none. */
25
+ savedName?: string;
26
+ saved: "none" | "here" | "deployment";
27
+ /** The saved token is one the server can't decrypt. */
28
+ unreadable?: boolean;
29
+ token?: string;
30
+ }
31
+
32
+ /** `name` as the server takes a scope (restapi `normalizePluginNamespaces()`): trimmed, with its `@`. */
33
+ function scopeOf(name: string): string {
34
+ const scope: string = name.trim();
35
+ return scope.startsWith("@") ? scope : `@${scope}`;
36
+ }
37
+
38
+ /** 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. */
39
+ function savedToken(draft: NamespaceDraft): NamespaceDraft["saved"] {
40
+ return draft.saved !== "none" && draft.savedName !== undefined && scopeOf(draft.name) === scopeOf(draft.savedName) ? draft.saved : "none";
41
+ }
42
+
43
+ /** `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. */
44
+ function namespacesOf(drafts: NamespaceDraft[], keep: (saved: NamespaceDraft["saved"]) => boolean): IntegrationPluginNamespace[] {
45
+ return drafts.map((draft) => {
46
+ const registry: string = draft.registry.trim();
47
+ const token: string | SecretMask | undefined = draft.token ?? (keep(savedToken(draft)) ? { secret: true } : undefined);
48
+ return { name: draft.name.trim(), ...(registry ? { registry } : {}), ...(token ? { token } : {}) };
49
+ });
50
+ }
51
+
52
+ /** Whether two registry URLs are the same registry, credentials in them aside (restapi `sameRegistryUrl()`): a token goes only to its own registry. */
53
+ function sameRegistryUrl(a: string, b: string): boolean {
54
+ const href = (value: string): string | undefined => {
55
+ try {
56
+ const url: URL = new URL(value.trim());
57
+ url.username = "";
58
+ url.password = "";
59
+ return url.href;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ };
64
+ const left: string | undefined = href(a);
65
+ return left !== undefined && left === href(b);
66
+ }
67
+
68
+ const FIELDS: Record<string, SectionField> = {
69
+ url: { name: "registry URL", describe: (url: string) => url },
70
+ token: secretSectionField("registry token"),
71
+ namespaces: {
72
+ name: "plugin namespaces",
73
+ describe: (namespaces: IntegrationPluginNamespace[]) => namespaces.map((namespace) => namespace.name).join(", ") || "none",
74
+ toForm: (namespaces: IntegrationPluginNamespace[], source: IntegrationSource): NamespaceDraft[] =>
75
+ namespaces.map((namespace, index) => ({
76
+ id: `ns-${index}`,
77
+ name: namespace.name,
78
+ registry: namespace.registry ?? "",
79
+ savedName: namespace.name,
80
+ saved: !isSecretMask(namespace.token) ? "none" : source === "admin" ? "here" : "deployment",
81
+ unreadable: source === "admin" && isUnreadableSecret(namespace.token),
82
+ })),
83
+ // Compared as the server shows the list: a saved token as the mask, wherever it is saved.
84
+ toWire: (drafts: NamespaceDraft[]) => namespacesOf(drafts, (saved) => saved !== "none"),
85
+ // Only a token saved here can be kept; the deployment's stay with the deployment's list.
86
+ toSend: (drafts: NamespaceDraft[]) => namespacesOf(drafts, (saved) => saved === "here"),
87
+ // 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).
88
+ canBeDeployment: (drafts: NamespaceDraft[]) => drafts.every((draft) => draft.token !== undefined || savedToken(draft) !== "here"),
89
+ },
90
+ };
91
+
92
+ /** The plugin namespaces: each a scope, optionally its own registry, and a write-only token. */
93
+ function NamespacesEditor({ form }: { form: SectionForm }) {
94
+ const nextId = useRef(0);
95
+ const drafts: NamespaceDraft[] = form.value("namespaces");
96
+ const change = (next: NamespaceDraft[]) => form.set("namespaces", next);
97
+ const update = (id: string, patch: Partial<NamespaceDraft>) => change(drafts.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft)));
98
+
99
+ return (
100
+ <div className="flex flex-col gap-2 text-sm">
101
+ <span className="font-semibold">Plugin namespaces</span>
102
+ <span className="text-xs text-text-muted">The npm scopes the plugin catalog searches. Plugins in them may be installed.</span>
103
+ {drafts.length === 0 && <p className="text-xs text-text-muted">No namespaces: the plugin catalog searches none.</p>}
104
+ {drafts.map((draft, index) => {
105
+ const n = index + 1;
106
+ return (
107
+ <fieldset key={draft.id} className="border border-border rounded-sm p-3 flex flex-col gap-2">
108
+ <legend className="sr-only">Namespace {n}</legend>
109
+ <input
110
+ aria-label={`Scope of namespace ${n}`}
111
+ className={INPUT_CLASS}
112
+ placeholder="@acme"
113
+ value={draft.name}
114
+ onChange={(e) => update(draft.id, { name: e.target.value })}
115
+ />
116
+ <input
117
+ aria-label={`Registry of namespace ${n}`}
118
+ type="url"
119
+ className={INPUT_CLASS}
120
+ placeholder="Its own registry, if not the one above (https://...)"
121
+ value={draft.registry}
122
+ onChange={(e) => update(draft.id, { registry: e.target.value })}
123
+ />
124
+ <SecretField
125
+ id={`namespace-token-${draft.id}`}
126
+ label={`Token of namespace ${n}`}
127
+ name={`token of namespace ${n}`}
128
+ saved={savedToken(draft)}
129
+ unreadable={draft.unreadable && savedToken(draft) === "here"}
130
+ value={draft.token}
131
+ onChange={(token) => update(draft.id, { token })}
132
+ />
133
+ {draft.saved !== "none" && savedToken(draft) === "none" && (
134
+ <span className="text-xs text-text-muted">
135
+ The token saved for {draft.savedName} doesn&rsquo;t follow a new scope: enter it again, or leave it empty for none.
136
+ </span>
137
+ )}
138
+ <Button
139
+ type="button"
140
+ variant="text"
141
+ className="!w-auto self-start text-xs"
142
+ aria-label={`Remove namespace ${n}`}
143
+ onClick={() => change(drafts.filter((other) => other.id !== draft.id))}
144
+ >
145
+ Remove
146
+ </Button>
147
+ </fieldset>
148
+ );
149
+ })}
150
+ <Button
151
+ type="button"
152
+ variant="secondary"
153
+ className="!w-auto self-start"
154
+ onClick={() => change([...drafts, { id: `new-${nextId.current++}`, name: "", registry: "", saved: "none" }])}
155
+ >
156
+ Add namespace
157
+ </Button>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ /** The plugin registry section: where plugins are found and installed from, its token, and the namespaces searched. */
163
+ export default function PluginRegistryForm({ settings, onChange }: { settings: IntegrationSettings; onChange: (settings: IntegrationSettings) => void }) {
164
+ const view = settings.pluginRegistry;
165
+ // The deployment's registry token, and its namespaces' tokens for its registry, are only ever sent to its registry (restapi
166
+ // `buildIntegrationsConfigLayer()`): with another URL in the form, saved or about to be, none of them is used.
167
+ const deploymentRegistryTokens: boolean =
168
+ isSecretMask(view.defaults.token) || view.defaults.namespaces.some((namespace) => !namespace.registry && isSecretMask(namespace.token));
169
+ /** Whether the form's registry gets none of those tokens and none of its own is set or typed. */
170
+ const noTokenUsed = (form: SectionForm): boolean =>
171
+ deploymentRegistryTokens &&
172
+ form.secret("token") !== "here" &&
173
+ form.value("token") === undefined &&
174
+ !sameRegistryUrl(form.value("url"), view.defaults.url);
175
+ const deploymentTokens: boolean = view.source.namespaces === "deployment" && view.values.namespaces.some((namespace) => isSecretMask(namespace.token));
176
+ return (
177
+ <IntegrationSectionForm
178
+ section="pluginRegistry"
179
+ title="Plugin registry"
180
+ description="The npm registry plugins are found and installed from."
181
+ consequence="A change affects the next plugin install or update only; installed plugins are not touched."
182
+ fields={FIELDS}
183
+ settings={settings}
184
+ onChange={onChange}
185
+ >
186
+ {(form) => (
187
+ <>
188
+ <label className="flex flex-col gap-1.5 text-sm">
189
+ <span className="font-semibold">Registry URL</span>
190
+ <input
191
+ aria-label="Registry URL"
192
+ type="url"
193
+ className={INPUT_CLASS}
194
+ value={form.value("url")}
195
+ onChange={(e) => form.set("url", e.target.value)}
196
+ />
197
+ </label>
198
+ {form.footer("url")}
199
+ <div className="flex flex-col gap-1.5 text-sm">
200
+ <span className="font-semibold">Registry token</span>
201
+ <SecretField
202
+ id="plugin-registry-token"
203
+ label="Registry token"
204
+ name="registry token"
205
+ saved={form.secret("token")}
206
+ unreadable={form.unreadable("token")}
207
+ value={form.value("token")}
208
+ onChange={(value) => form.set("token", value)}
209
+ />
210
+ {noTokenUsed(form) && (
211
+ <span className="text-xs text-text-muted">
212
+ The deployment&rsquo;s tokens for its registry are only sent to the deployment&rsquo;s registry, so none is used with this one
213
+ unless it is set here.
214
+ </span>
215
+ )}
216
+ </div>
217
+ {form.footer("token")}
218
+ <NamespacesEditor form={form} />
219
+ {deploymentTokens && (
220
+ <p className="text-xs text-text-muted">
221
+ These are the deployment&rsquo;s namespaces. A changed list saved here replaces them, and the tokens the deployment sets for them
222
+ aren&rsquo;t copied: enter them again.
223
+ </p>
224
+ )}
225
+ {form.footer("namespaces")}
226
+ </>
227
+ )}
228
+ </IntegrationSectionForm>
229
+ );
230
+ }
@@ -0,0 +1,91 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import Button from "../../../../../lib/components/buttons/Button.js";
7
+
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
+ export interface SecretFieldProps {
11
+ id: string;
12
+ /** The input's label. */
13
+ label: string;
14
+ /** How the buttons name the field ("Replace API key"). */
15
+ name: string;
16
+ /** What is saved now: nothing, a value saved here, or the deployment's. */
17
+ saved: "none" | "here" | "deployment";
18
+ /** A new value typed, `undefined` for none: what saving sends. */
19
+ value: string | undefined;
20
+ onChange: (value: string | undefined) => void;
21
+ /** A file's content rather than a token: a text area. */
22
+ multiLine?: boolean;
23
+ /** The saved value is set but the server can't decrypt it (its key is gone from the keyring): it must be entered again. */
24
+ unreadable?: boolean;
25
+ }
26
+
27
+ /**
28
+ * 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
29
+ * 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
30
+ * 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
31
+ * helps.
32
+ */
33
+ export default function SecretField({ id, label, name, saved, value, onChange, multiLine = false, unreadable = false }: SecretFieldProps) {
34
+ const [replacing, setReplacing] = useState(false);
35
+
36
+ if (saved !== "none" && !replacing) {
37
+ return (
38
+ <div className="flex items-center gap-3 text-sm">
39
+ {unreadable ? (
40
+ <span className="font-medium text-danger">Set, but can't be read: enter it again</span>
41
+ ) : (
42
+ <span className="font-medium">{saved === "here" ? "Set" : "Set by this deployment"}</span>
43
+ )}
44
+ <Button type="button" variant="text" className="text-xs" aria-label={`Replace ${name}`} onClick={() => setReplacing(true)}>
45
+ Replace
46
+ </Button>
47
+ </div>
48
+ );
49
+ }
50
+ const change = (text: string) => onChange(text === "" ? undefined : text);
51
+ return (
52
+ <div className="flex flex-col gap-1">
53
+ {multiLine ? (
54
+ <textarea
55
+ id={id}
56
+ aria-label={label}
57
+ rows={6}
58
+ spellCheck={false}
59
+ autoComplete="off"
60
+ className={`${INPUT_CLASS} font-mono`}
61
+ value={value ?? ""}
62
+ onChange={(e) => change(e.target.value)}
63
+ />
64
+ ) : (
65
+ <input
66
+ id={id}
67
+ aria-label={label}
68
+ type="password"
69
+ autoComplete="new-password"
70
+ className={INPUT_CLASS}
71
+ value={value ?? ""}
72
+ onChange={(e) => change(e.target.value)}
73
+ />
74
+ )}
75
+ {saved !== "none" && (
76
+ <Button
77
+ type="button"
78
+ variant="text"
79
+ className="!w-auto self-start text-xs"
80
+ aria-label={`Keep the saved ${name}`}
81
+ onClick={() => {
82
+ setReplacing(false);
83
+ onChange(undefined);
84
+ }}
85
+ >
86
+ Keep the saved value
87
+ </Button>
88
+ )}
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,95 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { IntegrationSettings, SigningCaBackend } from "../../../../../lib/admin/integrationsApi.js";
7
+ import IntegrationSectionForm, { SectionField } from "./IntegrationSectionForm.js";
8
+ import { INPUT_CLASS } from "./SecretField.js";
9
+
10
+ const BACKENDS: Record<SigningCaBackend, string> = {
11
+ rfc8823: "Automatically, from a public CA (RFC 8823)",
12
+ manual: "Manually, by an administrator",
13
+ none: "Not offered",
14
+ };
15
+
16
+ /** Text, "none" when unset. */
17
+ const describeText = (value: string | null): string => value ?? "none";
18
+
19
+ const FIELDS: Record<string, SectionField> = {
20
+ backend: { name: "certificate issuing", describe: (backend: SigningCaBackend) => BACKENDS[backend] },
21
+ directoryUrl: { name: "ACME directory URL", describe: describeText },
22
+ contactEmail: {
23
+ name: "contact email",
24
+ describe: describeText,
25
+ toForm: (value: string | null) => value ?? "",
26
+ toWire: (text: string) => text.trim() || null,
27
+ },
28
+ };
29
+
30
+ /** The S/MIME signing CA section: how people get the certificates that sign their mail. */
31
+ export default function SigningCaForm({ settings, onChange }: { settings: IntegrationSettings; onChange: (settings: IntegrationSettings) => void }) {
32
+ const deploymentContact: string | null = settings.signingCa.defaults.contactEmail;
33
+ return (
34
+ <IntegrationSectionForm
35
+ section="signingCa"
36
+ title="S/MIME signing CA"
37
+ description="How a signing certificate is issued when someone asks for one in their encryption settings."
38
+ consequence="A change affects new enrollments only; certificates already being issued finish with the CA they started with."
39
+ fields={FIELDS}
40
+ settings={settings}
41
+ onChange={onChange}
42
+ >
43
+ {(form) => (
44
+ <>
45
+ <label className="flex flex-col gap-1.5 text-sm">
46
+ <span className="font-semibold">How signing certificates are issued</span>
47
+ <select
48
+ aria-label="How signing certificates are issued"
49
+ className={INPUT_CLASS}
50
+ value={form.value("backend")}
51
+ onChange={(e) => form.set("backend", e.target.value)}
52
+ >
53
+ {Object.entries(BACKENDS).map(([backend, label]) => (
54
+ <option key={backend} value={backend}>
55
+ {label}
56
+ </option>
57
+ ))}
58
+ </select>
59
+ </label>
60
+ {form.footer("backend")}
61
+ <label className="flex flex-col gap-1.5 text-sm">
62
+ <span className="font-semibold">ACME directory URL</span>
63
+ <input
64
+ aria-label="ACME directory URL"
65
+ type="url"
66
+ className={INPUT_CLASS}
67
+ value={form.value("directoryUrl")}
68
+ onChange={(e) => form.set("directoryUrl", e.target.value)}
69
+ />
70
+ <span className="text-xs text-text-muted">
71
+ The CA's RFC 8823 ACME directory, https:// only. Used when certificates are issued automatically.
72
+ </span>
73
+ </label>
74
+ {form.footer("directoryUrl")}
75
+ <label className="flex flex-col gap-1.5 text-sm">
76
+ <span className="font-semibold">Contact email</span>
77
+ <input
78
+ aria-label="Contact email"
79
+ type="email"
80
+ className={INPUT_CLASS}
81
+ value={form.value("contactEmail")}
82
+ onChange={(e) => form.set("contactEmail", e.target.value)}
83
+ />
84
+ <span className="text-xs text-text-muted">
85
+ Where the CA sends notices about this server's account.
86
+ {/* An address saved here can't be cleared to none: `null` is the deployment's value, and the server takes no empty one. */}
87
+ {deploymentContact && ` Left empty, the deployment's address (${deploymentContact}) is used.`}
88
+ </span>
89
+ </label>
90
+ {form.footer("contactEmail")}
91
+ </>
92
+ )}
93
+ </IntegrationSectionForm>
94
+ );
95
+ }
@@ -0,0 +1,63 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
7
+ import IntegrationSectionForm, { SectionField } from "./IntegrationSectionForm.js";
8
+ import { INPUT_CLASS } from "./SecretField.js";
9
+
10
+ /** The lines of `text` that hold something, trimmed. */
11
+ export function lines(text: string): string[] {
12
+ return text
13
+ .split("\n")
14
+ .map((line) => line.trim())
15
+ .filter((line) => line !== "");
16
+ }
17
+
18
+ const FIELDS: Record<string, SectionField> = {
19
+ urls: {
20
+ name: "STUN servers",
21
+ describe: (urls: string[] | null) => (urls ? urls.join(", ") : "the meet plugin's built-in list"),
22
+ toForm: (urls: string[] | null) => (urls ?? []).join("\n"),
23
+ // An emptied box is no list (the server refuses an empty one): the deployment's value.
24
+ toWire: (text: string) => {
25
+ const urls: string[] = lines(text);
26
+ return urls.length > 0 ? urls : null;
27
+ },
28
+ },
29
+ };
30
+
31
+ /** The STUN section: the servers video meetings use to find a way between participants' networks. */
32
+ export default function StunForm({ settings, onChange }: { settings: IntegrationSettings; onChange: (settings: IntegrationSettings) => void }) {
33
+ return (
34
+ <IntegrationSectionForm
35
+ section="stun"
36
+ title="STUN servers"
37
+ description={<>The STUN servers video meetings hand to participants&rsquo; browsers. TURN servers stay in the Meet plugin&rsquo;s settings.</>}
38
+ consequence="Meetings started after saving use the new list."
39
+ fields={FIELDS}
40
+ settings={settings}
41
+ onChange={onChange}
42
+ >
43
+ {(form) => (
44
+ <>
45
+ <label className="flex flex-col gap-1.5 text-sm">
46
+ <span className="font-semibold">STUN servers</span>
47
+ <textarea
48
+ aria-label="STUN servers"
49
+ rows={4}
50
+ spellCheck={false}
51
+ className={`${INPUT_CLASS} font-mono`}
52
+ placeholder="stun:stun.example.com:3478"
53
+ value={form.value("urls")}
54
+ onChange={(e) => form.set("urls", e.target.value)}
55
+ />
56
+ <span className="text-xs text-text-muted">One per line, 1 to 10 of them, each a stun: or stuns: address.</span>
57
+ </label>
58
+ {form.footer("urls")}
59
+ </>
60
+ )}
61
+ </IntegrationSectionForm>
62
+ );
63
+ }
@@ -0,0 +1,72 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode } from "react";
6
+ import type { DnsProvider } from "../../../../../lib/admin/dnsProvidersApi.js";
7
+ import type { DnsExistingRecord } from "../../../../../lib/admin/domainsApi.js";
8
+
9
+ /** A small label beside a name ("Not yet live-tested", "Added by the installer"). */
10
+ export function Badge({ children }: { children: ReactNode }) {
11
+ return <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}</span>;
12
+ }
13
+
14
+ /** How long without a report from the DNS controller before it is shown as possibly down: it reports every minute by default
15
+ * (`DNS_CONTROLLER_STATUS_INTERVAL_SECONDS`), so this is ten missed reports, well past a pod restart or a clock off by a few minutes. */
16
+ export const CONTROLLER_SILENT_MS = 10 * 60 * 1000;
17
+
18
+ /** Whether a report made at `iso` is older than `CONTROLLER_SILENT_MS`: what it says may no longer hold. */
19
+ export function isStaleReport(iso: string, now: number = Date.now()): boolean {
20
+ return now - new Date(iso).getTime() > CONTROLLER_SILENT_MS;
21
+ }
22
+
23
+ /** What the console says of a provider config's external-dns instance: Disabled (the admin's switch), Unknown when the DNS controller last reported
24
+ * 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
25
+ * ready (none yet, including a config the controller hasn't reported on). */
26
+ export function dnsProviderStatus(
27
+ provider: DnsProvider,
28
+ now: number = Date.now()
29
+ ): { label: "Disabled" | "Unknown" | "Error" | "Running" | "Not ready"; error?: string } {
30
+ if (!provider.enabled) {
31
+ return { label: "Disabled" };
32
+ }
33
+ if (provider.status?.reportedAt && isStaleReport(provider.status.reportedAt, now)) {
34
+ return {
35
+ label: "Unknown",
36
+ error: `The DNS controller hasn't reported on it since ${formatTime(provider.status.reportedAt)}; it may be down.`,
37
+ };
38
+ }
39
+ if (provider.status?.lastError) {
40
+ return { label: "Error", error: provider.status.lastError };
41
+ }
42
+ return { label: (provider.status?.readyReplicas ?? 0) > 0 ? "Running" : "Not ready" };
43
+ }
44
+
45
+ /** `iso` as the browser's local date and time, nothing when unset. */
46
+ export function formatTime(iso: string | undefined): string {
47
+ return iso ? new Date(iso).toLocaleString() : "";
48
+ }
49
+
50
+ /** "1 domain", "2 domains". */
51
+ export function domainsText(count: number): string {
52
+ return count === 1 ? "1 domain" : `${count} domains`;
53
+ }
54
+
55
+ /** 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,
56
+ * and the administrator deletes them at their DNS host. Lists them (type and value) when the server names them. */
57
+ export function TypeMismatchNotice({ existing }: { existing?: DnsExistingRecord[] }) {
58
+ return (
59
+ <div className="text-xs mt-1 max-w-xs">
60
+ Records of another type are in the way. RapidMX never deletes them; delete them at your DNS host and this record is published.
61
+ {existing && existing.length > 0 && (
62
+ <ul className="mt-1 list-none flex flex-col gap-0.5">
63
+ {existing.map((record) => (
64
+ <li key={`${record.type} ${record.value}`}>
65
+ <code className="break-all">{`${record.type} ${record.value}`}</code>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ )}
70
+ </div>
71
+ );
72
+ }
@@ -6,6 +6,7 @@ import "../../../styles/app.css";
6
6
  import React, { PropsWithChildren, ReactNode, useEffect, useState } from "react";
7
7
  import { useRouter } from "@rapidrest/react/client";
8
8
  import {
9
+ HiOutlineArrowPathRoundedSquare,
9
10
  HiOutlineClipboardDocumentList,
10
11
  HiOutlineClock,
11
12
  HiOutlineCpuChip,
@@ -14,6 +15,7 @@ import {
14
15
  HiOutlineGlobeAlt,
15
16
  HiOutlineInboxStack,
16
17
  HiOutlineKey,
18
+ HiOutlineLink,
17
19
  HiOutlineLockClosed,
18
20
  HiOutlinePaintBrush,
19
21
  HiOutlinePuzzlePiece,
@@ -69,7 +71,9 @@ export type AdminSection =
69
71
  | "plugins"
70
72
  | "diagnostics"
71
73
  | "branding"
72
- | "signingCertificates";
74
+ | "signingCertificates"
75
+ | "integrations"
76
+ | "configurationEncryption";
73
77
 
74
78
  /** A core `AdminSection`, or a plugin's `adminNav` item id (see `PluginNav`). */
75
79
  export type AdminShellActive = AdminSection | (string & {});
@@ -150,6 +154,13 @@ const NAV_ITEMS: NavItem[] = [
150
154
  label: "Signing Certificates",
151
155
  icon: HiOutlineDocumentCheck,
152
156
  },
157
+ { id: "integrations", href: "/admin/integrations", label: "Integrations", icon: HiOutlineLink },
158
+ {
159
+ id: "configurationEncryption",
160
+ href: "/admin/configuration-encryption",
161
+ label: "Configuration Encryption",
162
+ icon: HiOutlineArrowPathRoundedSquare,
163
+ },
153
164
  { id: "plugins", href: "/admin/plugins", label: "Plugins", icon: HiOutlinePuzzlePiece },
154
165
  { id: "diagnostics", href: "/admin/diagnostics", label: "Diagnostics", icon: HiOutlineCpuChip },
155
166
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
@@ -0,0 +1,16 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
7
+
8
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
9
+ export default function CopyableValue({ value, copyLabel }: { value: string; copyLabel: string }) {
10
+ return (
11
+ <div className="flex items-start gap-2">
12
+ <code className="flex-1 min-w-0 text-xs break-all">{value}</code>
13
+ <CopyIconButton value={value} label={copyLabel} className="shrink-0" />
14
+ </div>
15
+ );
16
+ }