@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,61 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { DnsResolverMode, IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
|
|
7
|
+
import IntegrationSectionForm, { SectionField } from "./IntegrationSectionForm.js";
|
|
8
|
+
import { INPUT_CLASS } from "./SecretField.js";
|
|
9
|
+
|
|
10
|
+
const MODES: Record<DnsResolverMode, string> = {
|
|
11
|
+
node: "The system resolver",
|
|
12
|
+
"doh-dnssec": "DNS over HTTPS, with DNSSEC validation",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const FIELDS: Record<string, SectionField> = {
|
|
16
|
+
mode: { name: "resolver", describe: (mode: DnsResolverMode) => MODES[mode] },
|
|
17
|
+
dohEndpoint: { name: "DoH endpoint", describe: (url: string) => url },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The DNS resolver section: how this server looks up DNS records. */
|
|
21
|
+
export default function DnsResolverForm({ settings, onChange }: { settings: IntegrationSettings; onChange: (settings: IntegrationSettings) => void }) {
|
|
22
|
+
return (
|
|
23
|
+
<IntegrationSectionForm
|
|
24
|
+
section="dnsResolver"
|
|
25
|
+
title="DNS resolver"
|
|
26
|
+
description="How this server looks up DNS records: for domain checks and verification, key discovery and mail delivery decisions."
|
|
27
|
+
consequence="Every DNS check, domain verification and key discovery uses the new resolver as soon as it is saved, and answers already looked up are dropped."
|
|
28
|
+
fields={FIELDS}
|
|
29
|
+
settings={settings}
|
|
30
|
+
onChange={onChange}
|
|
31
|
+
>
|
|
32
|
+
{(form) => (
|
|
33
|
+
<>
|
|
34
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
35
|
+
<span className="font-semibold">Resolver</span>
|
|
36
|
+
<select aria-label="Resolver" className={INPUT_CLASS} value={form.value("mode")} onChange={(e) => form.set("mode", e.target.value)}>
|
|
37
|
+
{Object.entries(MODES).map(([mode, label]) => (
|
|
38
|
+
<option key={mode} value={mode}>
|
|
39
|
+
{label}
|
|
40
|
+
</option>
|
|
41
|
+
))}
|
|
42
|
+
</select>
|
|
43
|
+
</label>
|
|
44
|
+
{form.footer("mode")}
|
|
45
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
46
|
+
<span className="font-semibold">DoH endpoint</span>
|
|
47
|
+
<input
|
|
48
|
+
aria-label="DoH endpoint"
|
|
49
|
+
type="url"
|
|
50
|
+
className={INPUT_CLASS}
|
|
51
|
+
value={form.value("dohEndpoint")}
|
|
52
|
+
onChange={(e) => form.set("dohEndpoint", e.target.value)}
|
|
53
|
+
/>
|
|
54
|
+
<span className="text-xs text-text-muted">The DNS over HTTPS service queried, https:// only. Used with DNS over HTTPS.</span>
|
|
55
|
+
</label>
|
|
56
|
+
{form.footer("dohEndpoint")}
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</IntegrationSectionForm>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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, { secretSectionField, SectionField } from "./IntegrationSectionForm.js";
|
|
8
|
+
import SecretField from "./SecretField.js";
|
|
9
|
+
|
|
10
|
+
const FIELDS: Record<string, SectionField> = {
|
|
11
|
+
enabled: { name: "GIF search", describe: (value) => (value ? "on" : "off") },
|
|
12
|
+
apiKey: secretSectionField("API key"),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** The Giphy section: whether GIF search is offered, and the API key it uses. */
|
|
16
|
+
export default function GiphyForm({ settings, onChange }: { settings: IntegrationSettings; onChange: (settings: IntegrationSettings) => void }) {
|
|
17
|
+
return (
|
|
18
|
+
<IntegrationSectionForm
|
|
19
|
+
section="giphy"
|
|
20
|
+
title="Giphy"
|
|
21
|
+
description="GIF search in the message editor, through Giphy's API."
|
|
22
|
+
fields={FIELDS}
|
|
23
|
+
settings={settings}
|
|
24
|
+
onChange={onChange}
|
|
25
|
+
>
|
|
26
|
+
{(form) => (
|
|
27
|
+
<>
|
|
28
|
+
<label className="flex items-start gap-2 text-sm">
|
|
29
|
+
<input
|
|
30
|
+
type="checkbox"
|
|
31
|
+
className="mt-1"
|
|
32
|
+
aria-label="Use Giphy for GIF search"
|
|
33
|
+
checked={form.value("enabled")}
|
|
34
|
+
onChange={(e) => form.set("enabled", e.target.checked)}
|
|
35
|
+
/>
|
|
36
|
+
<span>
|
|
37
|
+
<span className="font-semibold block">Use Giphy for GIF search</span>
|
|
38
|
+
<span className="text-xs text-text-muted">Off, the editor offers no GIF search and the key is not used.</span>
|
|
39
|
+
</span>
|
|
40
|
+
</label>
|
|
41
|
+
{form.footer("enabled")}
|
|
42
|
+
<div className="flex flex-col gap-1.5 text-sm">
|
|
43
|
+
<span className="font-semibold">API key</span>
|
|
44
|
+
<SecretField
|
|
45
|
+
id="giphy-api-key"
|
|
46
|
+
label="API key"
|
|
47
|
+
name="API key"
|
|
48
|
+
saved={form.secret("apiKey")}
|
|
49
|
+
unreadable={form.unreadable("apiKey")}
|
|
50
|
+
value={form.value("apiKey")}
|
|
51
|
+
onChange={(value) => form.set("apiKey", value)}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
{form.footer("apiKey")}
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
</IntegrationSectionForm>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, ReactNode, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
IntegrationSectionName,
|
|
8
|
+
IntegrationSectionView,
|
|
9
|
+
IntegrationSettings,
|
|
10
|
+
IntegrationSource,
|
|
11
|
+
getIntegrations,
|
|
12
|
+
isSecretMask,
|
|
13
|
+
isUnreadableSecret,
|
|
14
|
+
updateIntegrationSection,
|
|
15
|
+
} from "../../../../../lib/admin/integrationsApi.js";
|
|
16
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
17
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
18
|
+
import Alert from "../ActionAlert.js";
|
|
19
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
20
|
+
|
|
21
|
+
/** A field put back to the deployment's value, pending the form's Save. */
|
|
22
|
+
const RESET: unique symbol = Symbol("reset");
|
|
23
|
+
|
|
24
|
+
/** How a section's form treats one of its fields. */
|
|
25
|
+
export interface SectionField {
|
|
26
|
+
/** How the field's reset button names it ("Reset API key to the deployment default"). */
|
|
27
|
+
name: string;
|
|
28
|
+
/** The deployment's value, in words, for the line beneath the field. */
|
|
29
|
+
describe: (value: any) => string;
|
|
30
|
+
/** The form's value for a value of the view, saved here (`admin`) or the deployment's (the value itself when left out). */
|
|
31
|
+
toForm?: (value: any, source: IntegrationSource) => unknown;
|
|
32
|
+
/** The form's value as the server shows it, to compare with the saved and the deployment's values (the value itself when left out). */
|
|
33
|
+
toWire?: (formValue: any) => unknown;
|
|
34
|
+
/** What saving sends for the form's value, when it differs from `toWire()`'s. */
|
|
35
|
+
toSend?: (formValue: any) => unknown;
|
|
36
|
+
/** False when the form's value isn't the deployment's though `toWire()` shows it the same: a secret saved here masked like the deployment's.
|
|
37
|
+
* Such a value is never taken as edited back to the deployment's (reset), which would drop the secret saved here. */
|
|
38
|
+
canBeDeployment?: (formValue: any) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A secret field: no value in the form (it is write-only, `SecretField`), "set" or "none" for the deployment's. */
|
|
42
|
+
export function secretSectionField(name: string): SectionField {
|
|
43
|
+
return { name, describe: (value) => (isSecretMask(value) ? "set" : "none"), toForm: () => undefined };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a section form's fields are drawn with. */
|
|
47
|
+
export interface SectionForm {
|
|
48
|
+
/** The field's value in the form. */
|
|
49
|
+
value(field: string): any;
|
|
50
|
+
/** Changes the field; `undefined` takes back any change. */
|
|
51
|
+
set(field: string, value: unknown): void;
|
|
52
|
+
/** What is saved for a secret field now: nothing, a value saved here, or the deployment's. A pending reset shows the deployment's. */
|
|
53
|
+
secret(field: string): "none" | "here" | "deployment";
|
|
54
|
+
/** Whether the secret saved here for the field is one the server can't decrypt, so that it must be entered again. */
|
|
55
|
+
unreadable(field: string): boolean;
|
|
56
|
+
/** Whether the form holds a value of the administrator's for the field, rather than the deployment's. */
|
|
57
|
+
setHere(field: string): boolean;
|
|
58
|
+
/** The line beneath a field: the deployment's value and, for a field set here, the button that puts it back. */
|
|
59
|
+
footer(field: string): ReactNode;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether two values are the same once sent (`undefined` counting as `null`, and a secret's `unreadable` flag, which is only the server's remark, not
|
|
63
|
+
* counting at all). */
|
|
64
|
+
function same(a: unknown, b: unknown): boolean {
|
|
65
|
+
const plain = (value: unknown): string => JSON.stringify(value ?? null, (key, inner) => (key === "unreadable" ? undefined : inner));
|
|
66
|
+
return plain(a) === plain(b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IntegrationSectionFormProps {
|
|
70
|
+
section: IntegrationSectionName;
|
|
71
|
+
title: string;
|
|
72
|
+
description: ReactNode;
|
|
73
|
+
/** What a change affects, stated above the Save button. */
|
|
74
|
+
consequence?: ReactNode;
|
|
75
|
+
fields: Record<string, SectionField>;
|
|
76
|
+
settings: IntegrationSettings;
|
|
77
|
+
onChange: (settings: IntegrationSettings) => void;
|
|
78
|
+
children: (form: SectionForm) => ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One Integrations section's form, following the policy pages: each field shows the deployment's value beneath it and, while the form holds a value
|
|
83
|
+
* saved here, a "Reset to deployment default" that puts the field back in the form only - Save sends it, with the rest, as `null`. A field is never
|
|
84
|
+
* saved as a copy of the deployment's value (it would stop following the deployment's configuration): edited back to it, it is reset too. Only what
|
|
85
|
+
* changes is sent, with the version the settings were read at, so a change made over someone else's is refused rather than overwriting it. A save
|
|
86
|
+
* the server refuses for want of a recently confirmed identity offers to confirm it again (`ActionAlert`).
|
|
87
|
+
*/
|
|
88
|
+
export default function IntegrationSectionForm({
|
|
89
|
+
section,
|
|
90
|
+
title,
|
|
91
|
+
description,
|
|
92
|
+
consequence,
|
|
93
|
+
fields,
|
|
94
|
+
settings,
|
|
95
|
+
onChange,
|
|
96
|
+
children,
|
|
97
|
+
}: IntegrationSectionFormProps) {
|
|
98
|
+
const view = settings[section] as IntegrationSectionView<any>;
|
|
99
|
+
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
|
100
|
+
// Bumped by a save, so the fields start over (a credential being replaced is shown as saved again).
|
|
101
|
+
const [formKey, setFormKey] = useState(0);
|
|
102
|
+
const [error, setError] = useState<string | null>(null);
|
|
103
|
+
const [saving, setSaving] = useState(false);
|
|
104
|
+
const [saved, setSaved] = useState(false);
|
|
105
|
+
|
|
106
|
+
const toForm = (field: string, value: unknown, source: IntegrationSource): unknown => (fields[field].toForm ? fields[field].toForm(value, source) : value);
|
|
107
|
+
/** The field's change as the server would show it, the deployment's value for a reset. */
|
|
108
|
+
const wire = (field: string, edit: unknown): unknown => (edit === RESET ? view.defaults[field] : fields[field].toWire ? fields[field].toWire(edit) : edit);
|
|
109
|
+
/** Whether the field's change puts it back to the deployment's value: a reset, a value left empty (`null`, which the server takes as unset), or the
|
|
110
|
+
* deployment's own. */
|
|
111
|
+
const isDeployment = (field: string, edit: unknown): boolean =>
|
|
112
|
+
edit === RESET ||
|
|
113
|
+
wire(field, edit) === null ||
|
|
114
|
+
(same(wire(field, edit), view.defaults[field]) && (fields[field].canBeDeployment?.(edit) ?? true));
|
|
115
|
+
|
|
116
|
+
const form: SectionForm = {
|
|
117
|
+
value: (field) =>
|
|
118
|
+
field in edits
|
|
119
|
+
? edits[field] === RESET
|
|
120
|
+
? toForm(field, view.defaults[field], "deployment")
|
|
121
|
+
: edits[field]
|
|
122
|
+
: toForm(field, view.values[field], view.source[field]),
|
|
123
|
+
set: (field, value) =>
|
|
124
|
+
setEdits((current) => {
|
|
125
|
+
const next = { ...current };
|
|
126
|
+
if (value === undefined) {
|
|
127
|
+
delete next[field];
|
|
128
|
+
} else {
|
|
129
|
+
next[field] = value;
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
}),
|
|
133
|
+
secret: (field) => {
|
|
134
|
+
const reset = edits[field] === RESET;
|
|
135
|
+
const shown = reset ? view.defaults[field] : view.values[field];
|
|
136
|
+
const source: IntegrationSource = reset ? "deployment" : view.source[field];
|
|
137
|
+
return !isSecretMask(shown) ? "none" : source === "admin" ? "here" : "deployment";
|
|
138
|
+
},
|
|
139
|
+
unreadable: (field) => edits[field] !== RESET && view.source[field] === "admin" && isUnreadableSecret(view.values[field]),
|
|
140
|
+
setHere: (field) =>
|
|
141
|
+
field in edits
|
|
142
|
+
? !isDeployment(field, edits[field])
|
|
143
|
+
: // A registry elsewhere than the deployment's shows its token as none set here (restapi's view): nothing to reset.
|
|
144
|
+
view.source[field] === "admin" && view.values[field] !== null,
|
|
145
|
+
footer: (field) => (
|
|
146
|
+
<div className="flex flex-wrap items-center gap-x-3 text-xs text-text-muted -mt-1">
|
|
147
|
+
<span>Deployment default: {fields[field].describe(view.defaults[field])}</span>
|
|
148
|
+
{form.setHere(field) && (
|
|
149
|
+
<Button
|
|
150
|
+
type="button"
|
|
151
|
+
variant="text"
|
|
152
|
+
className="text-xs"
|
|
153
|
+
aria-label={`Reset ${fields[field].name} to the deployment default`}
|
|
154
|
+
onClick={() => form.set(field, RESET)}
|
|
155
|
+
>
|
|
156
|
+
Reset to deployment default
|
|
157
|
+
</Button>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
async function handleSubmit(e: FormEvent) {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
setError(null);
|
|
166
|
+
setSaved(false);
|
|
167
|
+
const patch: Record<string, unknown> = {};
|
|
168
|
+
for (const [field, edit] of Object.entries(edits)) {
|
|
169
|
+
const value: unknown = wire(field, edit);
|
|
170
|
+
const admin: boolean = view.source[field] === "admin";
|
|
171
|
+
if (isDeployment(field, edit)) {
|
|
172
|
+
// Back to the deployment's value: nothing to send unless one is saved here.
|
|
173
|
+
if (admin) {
|
|
174
|
+
patch[field] = null;
|
|
175
|
+
}
|
|
176
|
+
} else if (!admin || !same(value, view.values[field])) {
|
|
177
|
+
patch[field] = fields[field].toSend ? fields[field].toSend(edit) : value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (Object.keys(patch).length === 0) {
|
|
181
|
+
setEdits({});
|
|
182
|
+
setSaved(true);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
setSaving(true);
|
|
186
|
+
try {
|
|
187
|
+
onChange(await updateIntegrationSection(section, { ...patch, version: settings.version }));
|
|
188
|
+
setEdits({});
|
|
189
|
+
setFormKey((key) => key + 1);
|
|
190
|
+
setSaved(true);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
setError(await refusalMessage(err));
|
|
193
|
+
} finally {
|
|
194
|
+
setSaving(false);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What a failed save says. A change refused for a newer version loads the settings again (keeping the entries, which are compared with them when
|
|
199
|
+
* saving), so that the next Save is made against the version now saved rather than failing the same way until the page is reloaded. */
|
|
200
|
+
async function refusalMessage(err: unknown): Promise<string> {
|
|
201
|
+
if (err instanceof ApiRequestError && err.status === 409) {
|
|
202
|
+
try {
|
|
203
|
+
const latest: IntegrationSettings = await getIntegrations();
|
|
204
|
+
if (latest.version !== settings.version) {
|
|
205
|
+
onChange(latest);
|
|
206
|
+
return "The integration settings were changed by someone else. They are shown as saved now, with your entries kept: check them and save again.";
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Not loaded: the server's own message stands.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return actionErrorMessage(err, `Could not save the ${title} settings.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className="max-w-xl">
|
|
217
|
+
<h2 className="text-lg font-bold uppercase tracking-wide mb-1">{title}</h2>
|
|
218
|
+
<div className="text-sm text-text-muted mb-5">{description}</div>
|
|
219
|
+
|
|
220
|
+
{error && <Alert>{error}</Alert>}
|
|
221
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
222
|
+
|
|
223
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
224
|
+
<React.Fragment key={formKey}>{children(form)}</React.Fragment>
|
|
225
|
+
{consequence && <p className="text-xs text-text-muted">{consequence}</p>}
|
|
226
|
+
<div>
|
|
227
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
228
|
+
Save
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</form>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode } from "react";
|
|
6
|
+
import { getIntegrations, IntegrationSettings } from "../../../../../lib/admin/integrationsApi.js";
|
|
7
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
8
|
+
import AdminShell, { AdminShellProps } from "../layout/AdminShell.js";
|
|
9
|
+
import LoadedSettingsForm from "../settings/LoadedSettingsForm.js";
|
|
10
|
+
import IntegrationsLayout, { IntegrationArea } from "./IntegrationsLayout.js";
|
|
11
|
+
|
|
12
|
+
/** What a server from before Integrations (no `system/integrations` route) is shown instead of a bare "Not Found". */
|
|
13
|
+
export const NO_INTEGRATIONS_MESSAGE = "This server doesn't offer Integrations yet. Upgrade it to manage them here.";
|
|
14
|
+
|
|
15
|
+
/** The integration settings, with a server that has none answering `NO_INTEGRATIONS_MESSAGE`. */
|
|
16
|
+
async function loadIntegrations(): Promise<IntegrationSettings> {
|
|
17
|
+
try {
|
|
18
|
+
return await getIntegrations();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw err instanceof ApiRequestError && err.status === 404 ? new ApiRequestError(NO_INTEGRATIONS_MESSAGE, 404) : err;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IntegrationSectionPageProps extends Omit<AdminShellProps, "active"> {
|
|
25
|
+
area: Exclude<IntegrationArea, "dnsProviders">;
|
|
26
|
+
children: (settings: IntegrationSettings, onChange: (settings: IntegrationSettings) => void) => ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One Integrations section's page: the console's frame, the Integrations navigation, and the section's form once the settings have loaded. */
|
|
30
|
+
export default function IntegrationSectionPage({ area, children, ...props }: IntegrationSectionPageProps) {
|
|
31
|
+
return (
|
|
32
|
+
<AdminShell {...props} active="integrations">
|
|
33
|
+
<IntegrationsLayout active={area}>
|
|
34
|
+
<LoadedSettingsForm load={loadIntegrations} loadErrorMessage="Could not load the integrations.">
|
|
35
|
+
{children}
|
|
36
|
+
</LoadedSettingsForm>
|
|
37
|
+
</IntegrationsLayout>
|
|
38
|
+
</AdminShell>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -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
|
+
|
|
7
|
+
/** The Integrations section's areas, in the order the console shows them. */
|
|
8
|
+
export type IntegrationArea = "dnsProviders" | "giphy" | "stun" | "signingCa" | "dnsResolver" | "pluginRegistry";
|
|
9
|
+
|
|
10
|
+
export const INTEGRATION_AREAS: readonly { id: IntegrationArea; href: string; label: string; description: string }[] = [
|
|
11
|
+
{
|
|
12
|
+
id: "dnsProviders",
|
|
13
|
+
href: "/admin/integrations/dns-providers",
|
|
14
|
+
label: "DNS providers",
|
|
15
|
+
description: "The DNS hosting accounts that publish the DNS records of this server and its domains, so they don't have to be added by hand.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "giphy",
|
|
19
|
+
href: "/admin/integrations/giphy",
|
|
20
|
+
label: "Giphy",
|
|
21
|
+
description: "GIF search in the message editor.",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "stun",
|
|
25
|
+
href: "/admin/integrations/stun",
|
|
26
|
+
label: "STUN servers",
|
|
27
|
+
description: "How video meetings find a way between participants' networks.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "signingCa",
|
|
31
|
+
href: "/admin/integrations/signing-ca",
|
|
32
|
+
label: "S/MIME signing CA",
|
|
33
|
+
description: "How people get the certificates that sign their mail.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "dnsResolver",
|
|
37
|
+
href: "/admin/integrations/dns-resolver",
|
|
38
|
+
label: "DNS resolver",
|
|
39
|
+
description: "How this server looks up DNS records.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "pluginRegistry",
|
|
43
|
+
href: "/admin/integrations/plugin-registry",
|
|
44
|
+
label: "Plugin registry",
|
|
45
|
+
description: "Where plugins are found and installed from.",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** The Integrations heading and the navigation between its areas (`active` is the current one; none on the overview), above the area's page. */
|
|
50
|
+
export default function IntegrationsLayout({ active, children }: { active?: IntegrationArea; children: ReactNode }) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<h1 className="text-xl font-bold uppercase tracking-wide mb-3">Integrations</h1>
|
|
54
|
+
<nav aria-label="Integrations" className="flex flex-wrap gap-x-4 gap-y-1 border-b border-border mb-6">
|
|
55
|
+
{INTEGRATION_AREAS.map((area) => (
|
|
56
|
+
<a
|
|
57
|
+
key={area.id}
|
|
58
|
+
href={area.href}
|
|
59
|
+
aria-current={area.id === active ? "page" : undefined}
|
|
60
|
+
className={[
|
|
61
|
+
"py-2 text-sm font-medium border-b-2 -mb-px",
|
|
62
|
+
area.id === active ? "border-primary text-primary-dark" : "border-transparent text-text-muted hover:text-text",
|
|
63
|
+
].join(" ")}
|
|
64
|
+
>
|
|
65
|
+
{area.label}
|
|
66
|
+
</a>
|
|
67
|
+
))}
|
|
68
|
+
</nav>
|
|
69
|
+
{children}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|