@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.
- package/apps/admin/configuration-encryption/index.tsx +17 -0
- package/apps/admin/domains/[uid].tsx +17 -4
- package/apps/admin/integrations/dns-providers/[uid].tsx +205 -0
- package/apps/admin/integrations/dns-providers/index.tsx +164 -0
- package/apps/admin/integrations/dns-providers/new/index.tsx +55 -0
- package/apps/admin/integrations/dns-resolver/index.tsx +16 -0
- package/apps/admin/integrations/giphy/index.tsx +16 -0
- package/apps/admin/integrations/index.tsx +31 -0
- package/apps/admin/integrations/plugin-registry/index.tsx +16 -0
- package/apps/admin/integrations/signing-ca/index.tsx +16 -0
- package/apps/admin/integrations/stun/index.tsx +16 -0
- package/apps/shared/components/admin/configEncryption/ConfigKeysManager.tsx +529 -0
- package/apps/shared/components/admin/configEncryption/keyringParts.ts +37 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +20 -4
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +23 -6
- package/apps/shared/components/admin/diagnostics/KeyringCard.tsx +144 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +21 -6
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +15 -3
- package/apps/shared/components/admin/integrations/DnsProviderForm.tsx +354 -0
- package/apps/shared/components/admin/integrations/DnsProviderHosts.tsx +197 -0
- package/apps/shared/components/admin/integrations/DnsProviderOperations.tsx +115 -0
- package/apps/shared/components/admin/integrations/DnsResolverForm.tsx +61 -0
- package/apps/shared/components/admin/integrations/GiphyForm.tsx +59 -0
- package/apps/shared/components/admin/integrations/IntegrationSectionForm.tsx +234 -0
- package/apps/shared/components/admin/integrations/IntegrationSectionPage.tsx +40 -0
- package/apps/shared/components/admin/integrations/IntegrationsLayout.tsx +72 -0
- package/apps/shared/components/admin/integrations/PluginRegistryForm.tsx +230 -0
- package/apps/shared/components/admin/integrations/SecretField.tsx +91 -0
- package/apps/shared/components/admin/integrations/SigningCaForm.tsx +95 -0
- package/apps/shared/components/admin/integrations/StunForm.tsx +63 -0
- package/apps/shared/components/admin/integrations/dnsProviderParts.tsx +72 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +12 -1
- package/apps/shared/components/admin/settings/CopyableValue.tsx +16 -0
- package/apps/shared/components/admin/settings/DomainApexTxt.tsx +319 -0
- package/apps/shared/components/admin/settings/DomainDkimRotation.tsx +408 -0
- package/apps/shared/components/admin/settings/DomainDnsProvider.tsx +147 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +174 -17
- package/apps/shared/components/admin/setup/SetupWizard.tsx +39 -6
- package/dist/apps/admin/configuration-encryption/index.d.ts +4 -0
- package/dist/apps/admin/configuration-encryption/index.js +7 -0
- package/dist/apps/admin/domains/[uid].js +11 -4
- package/dist/apps/admin/integrations/dns-providers/[uid].d.ts +7 -0
- package/dist/apps/admin/integrations/dns-providers/[uid].js +117 -0
- package/dist/apps/admin/integrations/dns-providers/index.d.ts +3 -0
- package/dist/apps/admin/integrations/dns-providers/index.js +68 -0
- package/dist/apps/admin/integrations/dns-providers/new/index.d.ts +3 -0
- package/dist/apps/admin/integrations/dns-providers/new/index.js +31 -0
- package/dist/apps/admin/integrations/dns-resolver/index.d.ts +3 -0
- package/dist/apps/admin/integrations/dns-resolver/index.js +6 -0
- package/dist/apps/admin/integrations/giphy/index.d.ts +3 -0
- package/dist/apps/admin/integrations/giphy/index.js +6 -0
- package/dist/apps/admin/integrations/index.d.ts +4 -0
- package/dist/apps/admin/integrations/index.js +7 -0
- package/dist/apps/admin/integrations/plugin-registry/index.d.ts +3 -0
- package/dist/apps/admin/integrations/plugin-registry/index.js +6 -0
- package/dist/apps/admin/integrations/signing-ca/index.d.ts +3 -0
- package/dist/apps/admin/integrations/signing-ca/index.js +6 -0
- package/dist/apps/admin/integrations/stun/index.d.ts +3 -0
- package/dist/apps/admin/integrations/stun/index.js +6 -0
- package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.d.ts +9 -0
- package/dist/apps/shared/components/admin/configEncryption/ConfigKeysManager.js +187 -0
- package/dist/apps/shared/components/admin/configEncryption/keyringParts.d.ts +10 -0
- package/dist/apps/shared/components/admin/configEncryption/keyringParts.js +26 -0
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +20 -4
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +8 -4
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +7 -5
- package/dist/apps/shared/components/admin/diagnostics/KeyringCard.d.ts +18 -0
- package/dist/apps/shared/components/admin/diagnostics/KeyringCard.js +51 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +9 -1
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +10 -3
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +13 -3
- package/dist/apps/shared/components/admin/integrations/DnsProviderForm.d.ts +17 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderForm.js +147 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.d.ts +10 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderHosts.js +101 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.d.ts +8 -0
- package/dist/apps/shared/components/admin/integrations/DnsProviderOperations.js +60 -0
- package/dist/apps/shared/components/admin/integrations/DnsResolverForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/DnsResolverForm.js +15 -0
- package/dist/apps/shared/components/admin/integrations/GiphyForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/GiphyForm.js +11 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.d.ts +54 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionForm.js +131 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.d.ts +12 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationSectionPage.js +21 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.d.ts +14 -0
- package/dist/apps/shared/components/admin/integrations/IntegrationsLayout.js +46 -0
- package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/PluginRegistryForm.js +90 -0
- package/dist/apps/shared/components/admin/integrations/SecretField.d.ts +25 -0
- package/dist/apps/shared/components/admin/integrations/SecretField.js +25 -0
- package/dist/apps/shared/components/admin/integrations/SigningCaForm.d.ts +7 -0
- package/dist/apps/shared/components/admin/integrations/SigningCaForm.js +25 -0
- package/dist/apps/shared/components/admin/integrations/StunForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/integrations/StunForm.js +26 -0
- package/dist/apps/shared/components/admin/integrations/dnsProviderParts.d.ts +28 -0
- package/dist/apps/shared/components/admin/integrations/dnsProviderParts.js +43 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +8 -1
- package/dist/apps/shared/components/admin/settings/CopyableValue.d.ts +6 -0
- package/dist/apps/shared/components/admin/settings/CopyableValue.js +6 -0
- package/dist/apps/shared/components/admin/settings/DomainApexTxt.d.ts +13 -0
- package/dist/apps/shared/components/admin/settings/DomainApexTxt.js +148 -0
- package/dist/apps/shared/components/admin/settings/DomainDkimRotation.d.ts +15 -0
- package/dist/apps/shared/components/admin/settings/DomainDkimRotation.js +200 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsProvider.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsProvider.js +70 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +3 -3
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +61 -8
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +9 -1
- package/dist/lib/admin/configKeysApi.d.ts +96 -0
- package/dist/lib/admin/configKeysApi.js +34 -0
- package/dist/lib/admin/dnsProvidersApi.d.ts +172 -0
- package/dist/lib/admin/dnsProvidersApi.js +70 -0
- package/dist/lib/admin/domainsApi.d.ts +116 -1
- package/dist/lib/admin/domainsApi.js +33 -0
- package/dist/lib/admin/integrationsApi.d.ts +75 -0
- package/dist/lib/admin/integrationsApi.js +31 -0
- package/lib/admin/configKeysApi.ts +117 -0
- package/lib/admin/dnsProvidersApi.ts +241 -0
- package/lib/admin/domainsApi.ts +145 -1
- package/lib/admin/integrationsApi.ts +107 -0
- 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’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’s tokens for its registry are only sent to the deployment’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’s namespaces. A changed list saved here replaces them, and the tokens the deployment sets for them
|
|
222
|
+
aren’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’ browsers. TURN servers stay in the Meet plugin’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
|
+
}
|