@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.
- 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 +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useState } from "react";
|
|
6
|
+
import { isUnreadableSecret } from "../../../../../lib/admin/integrationsApi.js";
|
|
7
|
+
import type {
|
|
8
|
+
DnsProvider,
|
|
9
|
+
DnsProviderInput,
|
|
10
|
+
DnsProviderOptionSpec,
|
|
11
|
+
DnsProviderOptionValue,
|
|
12
|
+
DnsProviderType,
|
|
13
|
+
DnsProviderTypeInfo,
|
|
14
|
+
} from "../../../../../lib/admin/dnsProvidersApi.js";
|
|
15
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
16
|
+
import Alert from "../ActionAlert.js";
|
|
17
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
18
|
+
import { Badge } from "./dnsProviderParts.js";
|
|
19
|
+
import SecretField, { INPUT_CLASS } from "./SecretField.js";
|
|
20
|
+
|
|
21
|
+
/** The entries of a list typed one per line (commas and spaces separate them too). */
|
|
22
|
+
function listOf(text: string): string[] {
|
|
23
|
+
return text.split(/[\s,]+/).filter((entry) => entry !== "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An option's value as the form holds it: a switch, or text (a list one entry per line). */
|
|
27
|
+
function toFormValue(option: DnsProviderOptionSpec, value: DnsProviderOptionValue | undefined): string | boolean {
|
|
28
|
+
if (option.kind === "boolean") {
|
|
29
|
+
return value === true;
|
|
30
|
+
}
|
|
31
|
+
return Array.isArray(value) ? value.join("\n") : value === undefined ? "" : String(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An option's value as the server takes it; `undefined` for one left empty, which the server fills with its default or refuses as required. */
|
|
35
|
+
function fromFormValue(option: DnsProviderOptionSpec, value: string | boolean): DnsProviderOptionValue | undefined {
|
|
36
|
+
if (typeof value === "boolean") {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
if (option.kind === "string-list") {
|
|
40
|
+
const list: string[] = listOf(value);
|
|
41
|
+
return list.length > 0 ? list : undefined;
|
|
42
|
+
}
|
|
43
|
+
const text: string = value.trim();
|
|
44
|
+
if (text === "") {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return option.kind === "integer" ? Number(text) : text;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The form's values of `spec`'s options: `provider`'s, else each option's default. */
|
|
51
|
+
function initialOptions(spec: DnsProviderTypeInfo, provider?: DnsProvider): Record<string, string | boolean> {
|
|
52
|
+
return Object.fromEntries(spec.options.map((option) => [option.key, toFormValue(option, provider?.options[option.key] ?? option.default)]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DnsProviderFormProps {
|
|
56
|
+
/** The provider types, from `GET /system/dns-providers/types`: what the form is built from. */
|
|
57
|
+
types: DnsProviderTypeInfo[];
|
|
58
|
+
/** The config being edited; none to create one (then the type is chosen here; it never changes afterwards). */
|
|
59
|
+
provider?: DnsProvider;
|
|
60
|
+
submitLabel: string;
|
|
61
|
+
/** Saves what the form holds; a rejection is shown (the server's validation is the authority). */
|
|
62
|
+
onSave: (input: DnsProviderInput) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A DNS provider config's form, generated from its type's description: one field per option (text, a list one entry per line, a number, a switch or a
|
|
67
|
+
* choice), and the type's credentials, write-only - a saved one shows only as set, with Replace, and Remove where the type can do without it. Only what
|
|
68
|
+
* is typed into a credential is sent; the rest of the config is sent whole.
|
|
69
|
+
*/
|
|
70
|
+
export default function DnsProviderForm({ types, provider, submitLabel, onSave }: DnsProviderFormProps) {
|
|
71
|
+
const [type, setType] = useState<DnsProviderType>(provider?.type ?? types[0].type);
|
|
72
|
+
const spec: DnsProviderTypeInfo = types.find((candidate) => candidate.type === type)!;
|
|
73
|
+
const [name, setName] = useState(provider?.name ?? "");
|
|
74
|
+
const [zones, setZones] = useState((provider?.zones ?? []).join("\n"));
|
|
75
|
+
const [options, setOptions] = useState(() => initialOptions(spec, provider));
|
|
76
|
+
// A new value typed (string), or `null` for a saved credential to remove; a field left alone has no entry.
|
|
77
|
+
const [credentials, setCredentials] = useState<Record<string, string | null>>({});
|
|
78
|
+
const [enabled, setEnabled] = useState(provider?.enabled ?? true);
|
|
79
|
+
const [allowTakeover, setAllowTakeover] = useState(provider?.allowTakeover ?? false);
|
|
80
|
+
// Bumped by a save, so the credentials start over as saved.
|
|
81
|
+
const [savedCount, setSavedCount] = useState(0);
|
|
82
|
+
const [error, setError] = useState<string | null>(null);
|
|
83
|
+
const [saving, setSaving] = useState(false);
|
|
84
|
+
const [saved, setSaved] = useState(false);
|
|
85
|
+
|
|
86
|
+
function changeType(next: DnsProviderType) {
|
|
87
|
+
setType(next);
|
|
88
|
+
setOptions(initialOptions(types.find((candidate) => candidate.type === next)!));
|
|
89
|
+
setCredentials({});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setCredential(key: string, value: string | null | undefined) {
|
|
93
|
+
setCredentials((current) => {
|
|
94
|
+
const next = { ...current };
|
|
95
|
+
if (value === undefined) {
|
|
96
|
+
delete next[key];
|
|
97
|
+
} else {
|
|
98
|
+
next[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handleSubmit(e: FormEvent) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
setError(null);
|
|
107
|
+
setSaved(false);
|
|
108
|
+
const trimmedName: string = name.trim();
|
|
109
|
+
if (!trimmedName) {
|
|
110
|
+
setError("A name is required.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const zoneList: string[] = listOf(zones);
|
|
114
|
+
if (zoneList.length === 0) {
|
|
115
|
+
setError("Enter at least one zone.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const values: Record<string, DnsProviderOptionValue> = {};
|
|
119
|
+
for (const option of spec.options) {
|
|
120
|
+
const value = fromFormValue(option, options[option.key]);
|
|
121
|
+
if (value !== undefined) {
|
|
122
|
+
values[option.key] = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const input: DnsProviderInput = {
|
|
126
|
+
name: trimmedName,
|
|
127
|
+
...(provider ? {} : { type }),
|
|
128
|
+
zones: zoneList,
|
|
129
|
+
options: values,
|
|
130
|
+
...(Object.keys(credentials).length > 0 ? { credentials } : {}),
|
|
131
|
+
enabled,
|
|
132
|
+
allowTakeover,
|
|
133
|
+
};
|
|
134
|
+
setSaving(true);
|
|
135
|
+
try {
|
|
136
|
+
await onSave(input);
|
|
137
|
+
setCredentials({});
|
|
138
|
+
setSavedCount((count) => count + 1);
|
|
139
|
+
setSaved(true);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
setError(actionErrorMessage(err, "Could not save the DNS provider."));
|
|
142
|
+
} finally {
|
|
143
|
+
setSaving(false);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-xl">
|
|
149
|
+
{error && <Alert>{error}</Alert>}
|
|
150
|
+
{saved && !error && <div className="text-sm text-success font-medium">Saved.</div>}
|
|
151
|
+
|
|
152
|
+
{!provider && (
|
|
153
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
154
|
+
<span className="font-semibold">
|
|
155
|
+
Type
|
|
156
|
+
{!spec.tested && <Badge>Not yet live-tested</Badge>}
|
|
157
|
+
</span>
|
|
158
|
+
<select aria-label="Type" className={INPUT_CLASS} value={type} onChange={(e) => changeType(e.target.value as DnsProviderType)}>
|
|
159
|
+
{types.map((candidate) => (
|
|
160
|
+
<option key={candidate.type} value={candidate.type}>
|
|
161
|
+
{candidate.label}
|
|
162
|
+
</option>
|
|
163
|
+
))}
|
|
164
|
+
</select>
|
|
165
|
+
</label>
|
|
166
|
+
)}
|
|
167
|
+
{spec.notes.length > 0 && (
|
|
168
|
+
<ul className="list-disc pl-5 text-xs text-text-muted">
|
|
169
|
+
{spec.notes.map((note) => (
|
|
170
|
+
<li key={note}>{note}</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
176
|
+
<span className="font-semibold">Name</span>
|
|
177
|
+
<input aria-label="Name" className={INPUT_CLASS} value={name} onChange={(e) => setName(e.target.value)} />
|
|
178
|
+
</label>
|
|
179
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
180
|
+
<span className="font-semibold">Zones</span>
|
|
181
|
+
<textarea aria-label="Zones" rows={3} spellCheck={false} className={INPUT_CLASS} value={zones} onChange={(e) => setZones(e.target.value)} />
|
|
182
|
+
<span className="text-xs text-text-muted">
|
|
183
|
+
The zones this provider may publish into, one per line, such as example.com. A domain can be assigned to it only when it is one of them or
|
|
184
|
+
under one.
|
|
185
|
+
</span>
|
|
186
|
+
</label>
|
|
187
|
+
|
|
188
|
+
{spec.options.map((option) => (
|
|
189
|
+
<OptionField
|
|
190
|
+
key={`${type}-${option.key}`}
|
|
191
|
+
option={option}
|
|
192
|
+
value={options[option.key]}
|
|
193
|
+
onChange={(value) => setOptions({ ...options, [option.key]: value })}
|
|
194
|
+
/>
|
|
195
|
+
))}
|
|
196
|
+
|
|
197
|
+
<fieldset key={savedCount} className="flex flex-col gap-3">
|
|
198
|
+
<legend className="text-sm font-semibold mb-1">Credentials</legend>
|
|
199
|
+
<p className="text-xs text-text-muted -mt-2">Saved encrypted, and never shown again.</p>
|
|
200
|
+
{spec.secretFields.map((field) => {
|
|
201
|
+
const isSaved: boolean = !!provider?.credentials[field.key];
|
|
202
|
+
const removing: boolean = credentials[field.key] === null;
|
|
203
|
+
return (
|
|
204
|
+
<div key={`${type}-${field.key}`} className="flex flex-col gap-1.5 text-sm">
|
|
205
|
+
<span className="font-semibold">
|
|
206
|
+
{field.label}
|
|
207
|
+
{!field.required && <span className="font-normal text-text-muted"> (optional)</span>}
|
|
208
|
+
</span>
|
|
209
|
+
{removing ? (
|
|
210
|
+
<div className="flex items-center gap-3">
|
|
211
|
+
<span>Removed when you save.</span>
|
|
212
|
+
<Button
|
|
213
|
+
type="button"
|
|
214
|
+
variant="text"
|
|
215
|
+
className="text-xs"
|
|
216
|
+
aria-label={`Undo removing ${field.label}`}
|
|
217
|
+
onClick={() => setCredential(field.key, undefined)}
|
|
218
|
+
>
|
|
219
|
+
Undo
|
|
220
|
+
</Button>
|
|
221
|
+
</div>
|
|
222
|
+
) : (
|
|
223
|
+
<SecretField
|
|
224
|
+
id={`credential-${field.key}`}
|
|
225
|
+
label={field.label}
|
|
226
|
+
name={field.label}
|
|
227
|
+
saved={isSaved ? "here" : "none"}
|
|
228
|
+
unreadable={isUnreadableSecret(provider?.credentials[field.key])}
|
|
229
|
+
value={credentials[field.key] ?? undefined}
|
|
230
|
+
onChange={(value) => setCredential(field.key, value)}
|
|
231
|
+
multiLine={field.multiLine}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
{isSaved && !field.required && !removing && (
|
|
235
|
+
<Button
|
|
236
|
+
type="button"
|
|
237
|
+
variant="text"
|
|
238
|
+
className="!w-auto self-start text-xs"
|
|
239
|
+
aria-label={`Remove ${field.label}`}
|
|
240
|
+
onClick={() => setCredential(field.key, null)}
|
|
241
|
+
>
|
|
242
|
+
Remove
|
|
243
|
+
</Button>
|
|
244
|
+
)}
|
|
245
|
+
<span className="text-xs text-text-muted">{field.description}</span>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
})}
|
|
249
|
+
</fieldset>
|
|
250
|
+
|
|
251
|
+
<label className="flex items-start gap-2 text-sm">
|
|
252
|
+
<input
|
|
253
|
+
type="checkbox"
|
|
254
|
+
className="mt-1"
|
|
255
|
+
aria-label="Publish through this provider"
|
|
256
|
+
checked={enabled}
|
|
257
|
+
onChange={(e) => setEnabled(e.target.checked)}
|
|
258
|
+
/>
|
|
259
|
+
<span>
|
|
260
|
+
<span className="font-semibold block">Publish through this provider</span>
|
|
261
|
+
<span className="text-xs text-text-muted">Off, its external-dns instance is stopped; the records it published stay as they are.</span>
|
|
262
|
+
</span>
|
|
263
|
+
</label>
|
|
264
|
+
<label className="flex items-start gap-2 text-sm">
|
|
265
|
+
<input
|
|
266
|
+
type="checkbox"
|
|
267
|
+
className="mt-1"
|
|
268
|
+
aria-label="Allow taking over existing records"
|
|
269
|
+
checked={allowTakeover}
|
|
270
|
+
onChange={(e) => setAllowTakeover(e.target.checked)}
|
|
271
|
+
/>
|
|
272
|
+
<span>
|
|
273
|
+
<span className="font-semibold block">Allow taking over existing records</span>
|
|
274
|
+
<span className="text-xs text-text-muted">
|
|
275
|
+
Lets this provider replace records that something else created at the names it publishes, such as an MX record added by hand: such
|
|
276
|
+
records are taken over automatically a few minutes after they are found, for the domains and for this server’s own host names.
|
|
277
|
+
A domain’s root TXT records (SPF) are never taken over this way, only through the root TXT opt-in on the domain. Records of
|
|
278
|
+
another type in the way are never taken over: they are listed for you to delete at your DNS host. Without it, such records
|
|
279
|
+
show as conflicts, with a Take over action on their domain and, for the host names, on this provider’s page.
|
|
280
|
+
</span>
|
|
281
|
+
</span>
|
|
282
|
+
</label>
|
|
283
|
+
|
|
284
|
+
<div>
|
|
285
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
286
|
+
{submitLabel}
|
|
287
|
+
</Button>
|
|
288
|
+
</div>
|
|
289
|
+
</form>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** One option of the provider type, drawn by its kind. */
|
|
294
|
+
function OptionField({ option, value, onChange }: { option: DnsProviderOptionSpec; value: string | boolean; onChange: (value: string | boolean) => void }) {
|
|
295
|
+
const hint = <span className="text-xs text-text-muted">{option.description}</span>;
|
|
296
|
+
if (option.kind === "boolean") {
|
|
297
|
+
return (
|
|
298
|
+
<label className="flex items-start gap-2 text-sm">
|
|
299
|
+
<input type="checkbox" className="mt-1" aria-label={option.label} checked={value as boolean} onChange={(e) => onChange(e.target.checked)} />
|
|
300
|
+
<span>
|
|
301
|
+
<span className="font-semibold block">{option.label}</span>
|
|
302
|
+
{hint}
|
|
303
|
+
</span>
|
|
304
|
+
</label>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
let control: React.ReactNode;
|
|
308
|
+
if (option.kind === "enum") {
|
|
309
|
+
control = (
|
|
310
|
+
<select aria-label={option.label} className={INPUT_CLASS} value={value as string} onChange={(e) => onChange(e.target.value)}>
|
|
311
|
+
{option.default === undefined && <option value="">Not set</option>}
|
|
312
|
+
{option.values!.map((choice) => (
|
|
313
|
+
<option key={choice} value={choice}>
|
|
314
|
+
{choice}
|
|
315
|
+
</option>
|
|
316
|
+
))}
|
|
317
|
+
</select>
|
|
318
|
+
);
|
|
319
|
+
} else if (option.kind === "string-list") {
|
|
320
|
+
control = (
|
|
321
|
+
<textarea
|
|
322
|
+
aria-label={option.label}
|
|
323
|
+
rows={3}
|
|
324
|
+
spellCheck={false}
|
|
325
|
+
className={`${INPUT_CLASS} font-mono`}
|
|
326
|
+
value={value as string}
|
|
327
|
+
onChange={(e) => onChange(e.target.value)}
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
control = (
|
|
332
|
+
<input
|
|
333
|
+
aria-label={option.label}
|
|
334
|
+
type={option.kind === "integer" ? "number" : "text"}
|
|
335
|
+
min={option.min}
|
|
336
|
+
max={option.max}
|
|
337
|
+
className={INPUT_CLASS}
|
|
338
|
+
value={value as string}
|
|
339
|
+
onChange={(e) => onChange(e.target.value)}
|
|
340
|
+
/>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return (
|
|
344
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
345
|
+
<span className="font-semibold">
|
|
346
|
+
{option.label}
|
|
347
|
+
{!option.required && <span className="font-normal text-text-muted"> (optional)</span>}
|
|
348
|
+
</span>
|
|
349
|
+
{control}
|
|
350
|
+
{hint}
|
|
351
|
+
{option.kind === "string-list" && <span className="text-xs text-text-muted">One per line.</span>}
|
|
352
|
+
</label>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { DnsHostManualReason, DnsHostPublication, listDnsHostPublications, takeoverDnsHosts } from "../../../../../lib/admin/dnsProvidersApi.js";
|
|
7
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
8
|
+
import Modal from "../../../../../lib/components/overlays/Modal.js";
|
|
9
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
10
|
+
import Alert from "../ActionAlert.js";
|
|
11
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
12
|
+
import { TypeMismatchNotice } from "./dnsProviderParts.js";
|
|
13
|
+
|
|
14
|
+
const MANUAL_REASONS: Record<DnsHostManualReason, string> = {
|
|
15
|
+
no_provider: "No DNS provider is assigned.",
|
|
16
|
+
kind_unsupported: "The DNS provider can't hold this kind of record.",
|
|
17
|
+
outside_zones: "This name is outside the zones of the DNS provider.",
|
|
18
|
+
apex_txt_unmanaged: "Shares the domain's root TXT records, which are managed only on the domain's opt-in.",
|
|
19
|
+
no_addresses: "The server has no public address to publish: set the public addresses, then it is published.",
|
|
20
|
+
no_target: "There is no mail host to point it at.",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const STATE_LABELS: Record<DnsHostPublication["state"], string> = {
|
|
24
|
+
published: "Published",
|
|
25
|
+
pending: "Pending",
|
|
26
|
+
conflict: "Conflict",
|
|
27
|
+
manual: "Manual",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A host record's name once per name: an address host has an A and an AAAA record at one name. */
|
|
31
|
+
function uniqueNames(records: DnsHostPublication[]): string[] {
|
|
32
|
+
return [...new Set(records.map((record) => record.recordName))];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The deployment's own host names (mail, auth, autodiscover, openpgpkey) that this config publishes, each with how it is published, and "Take over host
|
|
37
|
+
* names" for the ones another system created (a record this deployment doesn't own). A host whose records of another type stand in the way is never taken
|
|
38
|
+
* over: it lists them for the administrator to delete at their DNS host. Shown only when the config publishes a host, and not at all on a server without
|
|
39
|
+
* the route.
|
|
40
|
+
*/
|
|
41
|
+
export default function DnsProviderHosts({ uid }: { uid: string }) {
|
|
42
|
+
const [hosts, setHosts] = useState<DnsHostPublication[] | null>(null);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
const [confirming, setConfirming] = useState(false);
|
|
45
|
+
const [taking, setTaking] = useState(false);
|
|
46
|
+
const [takeError, setTakeError] = useState<string | null>(null);
|
|
47
|
+
const [queued, setQueued] = useState(false);
|
|
48
|
+
|
|
49
|
+
function load(isCurrent: () => boolean = () => true) {
|
|
50
|
+
listDnsHostPublications()
|
|
51
|
+
.then((loaded) => {
|
|
52
|
+
if (!isCurrent()) return;
|
|
53
|
+
setHosts(loaded.filter((host) => host.providerUid === uid));
|
|
54
|
+
setError(null);
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => {
|
|
57
|
+
if (!isCurrent()) return;
|
|
58
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
59
|
+
setHosts([]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
setError(actionErrorMessage(err, "Could not load the host names."));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let current = true;
|
|
68
|
+
load(() => current);
|
|
69
|
+
return () => {
|
|
70
|
+
current = false;
|
|
71
|
+
};
|
|
72
|
+
}, [uid]);
|
|
73
|
+
|
|
74
|
+
async function handleTakeOver(names: string[]) {
|
|
75
|
+
setTaking(true);
|
|
76
|
+
setTakeError(null);
|
|
77
|
+
try {
|
|
78
|
+
await takeoverDnsHosts(uid, names);
|
|
79
|
+
setQueued(true);
|
|
80
|
+
setConfirming(false);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setTakeError(actionErrorMessage(err, "Could not queue the takeover."));
|
|
83
|
+
} finally {
|
|
84
|
+
setTaking(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (error) {
|
|
89
|
+
return (
|
|
90
|
+
<section className="mt-8 max-w-3xl">
|
|
91
|
+
<Alert>{error}</Alert>
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (!hosts || hosts.length === 0) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const mismatched = new Set(uniqueNames(hosts.filter((host) => host.state === "conflict" && host.conflictReason === "type_mismatch")));
|
|
99
|
+
const notOwned: string[] = uniqueNames(hosts.filter((host) => host.state === "conflict" && host.conflictReason !== "type_mismatch")).filter(
|
|
100
|
+
(name) => !mismatched.has(name),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<section className="mt-8 max-w-3xl">
|
|
105
|
+
<h3 className="text-base font-bold uppercase tracking-wide mb-1">Host names</h3>
|
|
106
|
+
<p className="text-sm text-text-muted mb-3">
|
|
107
|
+
The names of this server itself that this provider publishes, such as the mail host. Records you already have at these names are shown as
|
|
108
|
+
conflicts.
|
|
109
|
+
</p>
|
|
110
|
+
<table aria-label="Host names" className="w-full text-sm border-collapse mb-3">
|
|
111
|
+
<thead>
|
|
112
|
+
<tr>
|
|
113
|
+
{["Name", "Record", "Published"].map((h) => (
|
|
114
|
+
<th key={h} className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
|
|
115
|
+
{h}
|
|
116
|
+
</th>
|
|
117
|
+
))}
|
|
118
|
+
</tr>
|
|
119
|
+
</thead>
|
|
120
|
+
<tbody>
|
|
121
|
+
{hosts.map((host) => (
|
|
122
|
+
<tr key={`${host.recordName} ${host.recordKind}`}>
|
|
123
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
124
|
+
<code className="text-xs break-all">{host.recordName}</code>
|
|
125
|
+
</td>
|
|
126
|
+
<td className="py-2 px-2.5 border-b border-border text-text-muted">{host.recordKind}</td>
|
|
127
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
128
|
+
<span
|
|
129
|
+
className={`text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${
|
|
130
|
+
host.state === "published"
|
|
131
|
+
? "bg-success text-white"
|
|
132
|
+
: host.state === "conflict"
|
|
133
|
+
? "bg-danger-bg text-danger"
|
|
134
|
+
: "bg-surface-alt text-text-muted"
|
|
135
|
+
}`}
|
|
136
|
+
>
|
|
137
|
+
{STATE_LABELS[host.state]}
|
|
138
|
+
</span>
|
|
139
|
+
{host.state === "manual" && host.manualReason && (
|
|
140
|
+
<div className="text-xs text-text-muted mt-1 max-w-xs">{MANUAL_REASONS[host.manualReason]}</div>
|
|
141
|
+
)}
|
|
142
|
+
{host.state === "conflict" && host.conflictReason === "type_mismatch" && <TypeMismatchNotice existing={host.existing} />}
|
|
143
|
+
{host.state === "conflict" && host.conflictReason !== "type_mismatch" && (
|
|
144
|
+
<div className="text-xs mt-1 max-w-xs">A record is there that this deployment doesn’t own.</div>
|
|
145
|
+
)}
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
))}
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
{queued && <p className="text-sm text-text-muted mb-3">Takeover queued. The DNS controller overwrites the records shortly; refresh to see the result.</p>}
|
|
152
|
+
<div className="flex gap-3">
|
|
153
|
+
{notOwned.length > 0 && (
|
|
154
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={queued} onClick={() => setConfirming(true)}>
|
|
155
|
+
Take over host names
|
|
156
|
+
</Button>
|
|
157
|
+
)}
|
|
158
|
+
<Button
|
|
159
|
+
type="button"
|
|
160
|
+
variant="secondary"
|
|
161
|
+
className="!w-auto"
|
|
162
|
+
onClick={() => {
|
|
163
|
+
setQueued(false);
|
|
164
|
+
load();
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
Refresh
|
|
168
|
+
</Button>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<Modal open={confirming} onClose={() => setConfirming(false)} title="Take over host names">
|
|
172
|
+
<div className="flex flex-col gap-3 text-sm">
|
|
173
|
+
<p>
|
|
174
|
+
Taking over overwrites the records at these names with the values this server publishes. From then on RapidMX manages them, and what was
|
|
175
|
+
there is lost.
|
|
176
|
+
</p>
|
|
177
|
+
<ul className="list-none flex flex-col gap-0.5">
|
|
178
|
+
{notOwned.map((name) => (
|
|
179
|
+
<li key={name}>
|
|
180
|
+
<code className="text-xs break-all">{name}</code>
|
|
181
|
+
</li>
|
|
182
|
+
))}
|
|
183
|
+
</ul>
|
|
184
|
+
{takeError && <Alert>{takeError}</Alert>}
|
|
185
|
+
<div className="flex gap-3 justify-end">
|
|
186
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={taking} onClick={() => setConfirming(false)}>
|
|
187
|
+
Cancel
|
|
188
|
+
</Button>
|
|
189
|
+
<Button type="button" className="!w-auto" loading={taking} disabled={taking} onClick={() => handleTakeOver(notOwned)}>
|
|
190
|
+
Take over
|
|
191
|
+
</Button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</Modal>
|
|
195
|
+
</section>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { DnsOperation, DnsOperationState, DnsOperationType, listDnsProviderOperations, testDnsProvider } from "../../../../../lib/admin/dnsProvidersApi.js";
|
|
7
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
8
|
+
import Alert from "../ActionAlert.js";
|
|
9
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
10
|
+
import { formatTime } from "./dnsProviderParts.js";
|
|
11
|
+
|
|
12
|
+
const TYPE_LABELS: Record<DnsOperationType, string> = {
|
|
13
|
+
test: "Connection test",
|
|
14
|
+
takeover: "Take over",
|
|
15
|
+
restore: "Restore",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const STATE_LABELS: Record<DnsOperationState, string> = {
|
|
19
|
+
queued: "Queued",
|
|
20
|
+
running: "Running",
|
|
21
|
+
succeeded: "Succeeded",
|
|
22
|
+
failed: "Failed",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* "Test connection" and the config's latest operations (connection tests, takeovers, restores of a domain's root TXT records), which the DNS controller
|
|
27
|
+
* runs and reports on. A test is only queued here: its result arrives with the controller's next report, so the list can be refreshed.
|
|
28
|
+
*/
|
|
29
|
+
export default function DnsProviderOperations({ uid }: { uid: string }) {
|
|
30
|
+
const [operations, setOperations] = useState<DnsOperation[]>([]);
|
|
31
|
+
const [loaded, setLoaded] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [testing, setTesting] = useState(false);
|
|
34
|
+
const [testError, setTestError] = useState<string | null>(null);
|
|
35
|
+
const [queued, setQueued] = useState(false);
|
|
36
|
+
|
|
37
|
+
function load() {
|
|
38
|
+
listDnsProviderOperations(uid)
|
|
39
|
+
.then((loadedOperations) => {
|
|
40
|
+
setOperations(loadedOperations);
|
|
41
|
+
setLoaded(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
})
|
|
44
|
+
.catch((err) => setError(actionErrorMessage(err, "Could not load the operations.")));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
useEffect(load, [uid]);
|
|
48
|
+
|
|
49
|
+
async function handleTest() {
|
|
50
|
+
setTesting(true);
|
|
51
|
+
setTestError(null);
|
|
52
|
+
try {
|
|
53
|
+
const operation = await testDnsProvider(uid);
|
|
54
|
+
setOperations((current) => [operation, ...current]);
|
|
55
|
+
setQueued(true);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setTestError(actionErrorMessage(err, "Could not queue the connection test."));
|
|
58
|
+
} finally {
|
|
59
|
+
setTesting(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section className="mt-8 max-w-3xl">
|
|
65
|
+
<h3 className="text-base font-bold uppercase tracking-wide mb-1">Connection and operations</h3>
|
|
66
|
+
<p className="text-sm text-text-muted mb-3">
|
|
67
|
+
Test connection has the DNS controller run external-dns once with the saved credentials, to check that they work and reach the zones.
|
|
68
|
+
</p>
|
|
69
|
+
<div className="flex gap-3 mb-3">
|
|
70
|
+
<Button type="button" variant="secondary" className="!w-auto" loading={testing} disabled={testing} onClick={handleTest}>
|
|
71
|
+
Test connection
|
|
72
|
+
</Button>
|
|
73
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={load}>
|
|
74
|
+
Refresh
|
|
75
|
+
</Button>
|
|
76
|
+
</div>
|
|
77
|
+
{testError && <Alert>{testError}</Alert>}
|
|
78
|
+
{queued && (
|
|
79
|
+
<p className="text-sm text-text-muted mb-3">Test queued. The DNS controller runs the test and reports the result here; refresh to see it.</p>
|
|
80
|
+
)}
|
|
81
|
+
{error && <Alert>{error}</Alert>}
|
|
82
|
+
{operations.length > 0 ? (
|
|
83
|
+
<table aria-label="Recent operations" className="w-full text-sm border-collapse">
|
|
84
|
+
<thead>
|
|
85
|
+
<tr>
|
|
86
|
+
{["Operation", "State", "Result", "When"].map((h) => (
|
|
87
|
+
<th key={h} className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
|
|
88
|
+
{h}
|
|
89
|
+
</th>
|
|
90
|
+
))}
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
{operations.map((operation) => (
|
|
95
|
+
<tr key={operation.uid}>
|
|
96
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
97
|
+
{TYPE_LABELS[operation.type] + (operation.domainUid ? ` (${operation.domainUid})` : operation.endpointSetId?.startsWith("hosts-") ? " (host names)" : "")}
|
|
98
|
+
</td>
|
|
99
|
+
<td className="py-2 px-2.5 border-b border-border">{STATE_LABELS[operation.state]}</td>
|
|
100
|
+
<td className="py-2 px-2.5 border-b border-border text-text-muted">{operation.message}</td>
|
|
101
|
+
<td className="py-2 px-2.5 border-b border-border text-text-muted whitespace-nowrap">
|
|
102
|
+
{formatTime(operation.finishedAt ?? operation.startedAt ?? operation.dateCreated)}
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
))}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
) : loaded ? (
|
|
109
|
+
<p className="text-sm text-text-muted">No operations yet.</p>
|
|
110
|
+
) : (
|
|
111
|
+
!error && <p className="text-sm text-text-muted">Loading…</p>
|
|
112
|
+
)}
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
}
|