@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,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { getDnsControllerState, listDnsProviders, listDnsProviderTypes, } from "../../../../lib/admin/dnsProvidersApi.js";
|
|
8
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
9
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
10
|
+
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
11
|
+
import IntegrationsLayout from "../../../shared/components/admin/integrations/IntegrationsLayout.js";
|
|
12
|
+
import { Badge, dnsProviderStatus, formatTime, isStaleReport } from "../../../shared/components/admin/integrations/dnsProviderParts.js";
|
|
13
|
+
import Alert from "../../../shared/components/admin/ActionAlert.js";
|
|
14
|
+
import { actionErrorMessage } from "../../../shared/components/admin/elevation.js";
|
|
15
|
+
export default function DnsProvidersPage(props) {
|
|
16
|
+
return (_jsx(AdminShell, { ...props, active: "integrations", children: _jsx(IntegrationsLayout, { active: "dnsProviders", children: _jsx(DnsProvidersList, {}) }) }));
|
|
17
|
+
}
|
|
18
|
+
/** What the DNS controller last said about itself, in a sentence: whether it works with this server, and when it last reported (a warning when that
|
|
19
|
+
* was too long ago: it may be down). */
|
|
20
|
+
function ControllerNotice({ state }) {
|
|
21
|
+
const { handshake, lastReport } = state;
|
|
22
|
+
if (handshake && !handshake.compatible) {
|
|
23
|
+
return (_jsx(Alert, { children: `DNS controller ${handshake.controllerVersion} can't work with this server (${handshake.reason}). Nothing is published until matching versions are installed.` }));
|
|
24
|
+
}
|
|
25
|
+
// The last time it was heard from at all: a handshake after a report (a restart of either side) shows it alive.
|
|
26
|
+
const heard = [lastReport?.at, handshake?.at].reduce((latest, at) => (at && (!latest || Date.parse(at) > Date.parse(latest)) ? at : latest), undefined);
|
|
27
|
+
if (heard && isStaleReport(heard)) {
|
|
28
|
+
return (_jsx(Alert, { children: `The DNS controller hasn't been heard from since ${formatTime(heard)}; it may be down. Nothing is published or removed until it is back.` }));
|
|
29
|
+
}
|
|
30
|
+
let text;
|
|
31
|
+
if (lastReport) {
|
|
32
|
+
text = `DNS controller ${lastReport.controllerVersion}, last report ${formatTime(lastReport.at)}.`;
|
|
33
|
+
}
|
|
34
|
+
else if (handshake) {
|
|
35
|
+
text = `DNS controller ${handshake.controllerVersion} has connected and not reported yet.`;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
text = "The DNS controller hasn’t contacted this server yet, so nothing is published automatically.";
|
|
39
|
+
}
|
|
40
|
+
return _jsx("p", { className: "text-sm text-text-muted mb-4", children: text });
|
|
41
|
+
}
|
|
42
|
+
function DnsProvidersList() {
|
|
43
|
+
const [providers, setProviders] = useState(null);
|
|
44
|
+
const [types, setTypes] = useState([]);
|
|
45
|
+
const [controller, setController] = useState(null);
|
|
46
|
+
const [error, setError] = useState(null);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
Promise.all([listDnsProviders(), listDnsProviderTypes()])
|
|
49
|
+
.then(([loaded, loadedTypes]) => {
|
|
50
|
+
setProviders(loaded);
|
|
51
|
+
setTypes(loadedTypes);
|
|
52
|
+
})
|
|
53
|
+
.catch((err) => setError(
|
|
54
|
+
// A server from before DNS publishing has no such route.
|
|
55
|
+
err instanceof ApiRequestError && err.status === 404
|
|
56
|
+
? "This server doesn't offer DNS providers yet. Upgrade it to manage them here."
|
|
57
|
+
: actionErrorMessage(err, "Could not load the DNS providers.")));
|
|
58
|
+
// The list stands without it: a server that can't say what the controller reported shows nothing about it.
|
|
59
|
+
getDnsControllerState()
|
|
60
|
+
.then(setController)
|
|
61
|
+
.catch(() => setController(null));
|
|
62
|
+
}, []);
|
|
63
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide", children: "DNS providers" }), _jsx("a", { href: "/admin/integrations/dns-providers/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New DNS provider" }) })] }), _jsx("p", { className: "text-sm text-text-muted mb-4 max-w-2xl", children: "The DNS hosting accounts this server publishes DNS records through: each domain\u2019s, and the server\u2019s own host names. A domain is assigned to one on its own page." }), controller && _jsx(ControllerNotice, { state: controller }), error && _jsx(Alert, { children: error }), !providers ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : providers.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No DNS providers yet. Until one is added, every domain\u2019s records are published by hand, from the checklist on its page." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Type", "Zones", "Status", "Domains", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: providers.map((provider) => {
|
|
64
|
+
const spec = types.find((candidate) => candidate.type === provider.type);
|
|
65
|
+
const status = dnsProviderStatus(provider);
|
|
66
|
+
return (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [provider.name, provider.origin === "installer" && _jsx(Badge, { children: "Added by the installer" })] }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [spec?.label ?? provider.type, spec && !spec.tested && _jsx(Badge, { children: "Not yet live-tested" })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: provider.zones.join(", ") }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [status.label, status.error && _jsx("span", { className: "block text-xs text-danger", children: status.error })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: provider.domainCount }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/integrations/dns-providers/${encodeURIComponent(provider.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, provider.uid));
|
|
67
|
+
}) })] }) }))] }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { useRouter } from "@rapidrest/react/client";
|
|
8
|
+
import { createDnsProvider, listDnsProviderTypes } from "../../../../../lib/admin/dnsProvidersApi.js";
|
|
9
|
+
import AdminShell from "../../../../shared/components/admin/layout/AdminShell.js";
|
|
10
|
+
import DnsProviderForm from "../../../../shared/components/admin/integrations/DnsProviderForm.js";
|
|
11
|
+
import IntegrationsLayout from "../../../../shared/components/admin/integrations/IntegrationsLayout.js";
|
|
12
|
+
import Alert from "../../../../shared/components/admin/ActionAlert.js";
|
|
13
|
+
import { actionErrorMessage } from "../../../../shared/components/admin/elevation.js";
|
|
14
|
+
export default function NewDnsProviderPage(props) {
|
|
15
|
+
return (_jsx(AdminShell, { ...props, active: "integrations", children: _jsx(IntegrationsLayout, { active: "dnsProviders", children: _jsx(NewDnsProviderContent, {}) }) }));
|
|
16
|
+
}
|
|
17
|
+
function NewDnsProviderContent() {
|
|
18
|
+
const { navigate } = useRouter();
|
|
19
|
+
const [types, setTypes] = useState(null);
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
listDnsProviderTypes()
|
|
23
|
+
.then(setTypes)
|
|
24
|
+
.catch((err) => setError(actionErrorMessage(err, "Could not load the DNS provider types.")));
|
|
25
|
+
}, []);
|
|
26
|
+
async function handleSave(input) {
|
|
27
|
+
const created = await createDnsProvider(input);
|
|
28
|
+
void navigate(`/admin/integrations/dns-providers/${encodeURIComponent(created.uid)}`);
|
|
29
|
+
}
|
|
30
|
+
return (_jsxs(_Fragment, { children: [_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "New DNS provider" }), _jsx("p", { className: "text-sm text-text-muted mb-5 max-w-2xl", children: "A DNS hosting account this server can publish records through. Prefer credentials scoped to these zones alone." }), error ? (_jsx(Alert, { children: error })) : !types ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DnsProviderForm, { types: types, submitLabel: "Create DNS provider", onSave: handleSave }))] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import DnsResolverForm from "../../../shared/components/admin/integrations/DnsResolverForm.js";
|
|
3
|
+
import IntegrationSectionPage from "../../../shared/components/admin/integrations/IntegrationSectionPage.js";
|
|
4
|
+
export default function DnsResolverPage(props) {
|
|
5
|
+
return (_jsx(IntegrationSectionPage, { ...props, area: "dnsResolver", children: (settings, onChange) => _jsx(DnsResolverForm, { settings: settings, onChange: onChange }) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import GiphyForm from "../../../shared/components/admin/integrations/GiphyForm.js";
|
|
3
|
+
import IntegrationSectionPage from "../../../shared/components/admin/integrations/IntegrationSectionPage.js";
|
|
4
|
+
export default function GiphyPage(props) {
|
|
5
|
+
return (_jsx(IntegrationSectionPage, { ...props, area: "giphy", children: (settings, onChange) => _jsx(GiphyForm, { settings: settings, onChange: onChange }) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
3
|
+
/** The Integrations overview: each third-party service this deployment can use, with what it is for. */
|
|
4
|
+
export default function IntegrationsPage(props: Omit<AdminShellProps, "active">): React.JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
3
|
+
import IntegrationsLayout, { INTEGRATION_AREAS } from "../../shared/components/admin/integrations/IntegrationsLayout.js";
|
|
4
|
+
/** The Integrations overview: each third-party service this deployment can use, with what it is for. */
|
|
5
|
+
export default function IntegrationsPage(props) {
|
|
6
|
+
return (_jsx(AdminShell, { ...props, active: "integrations", children: _jsxs(IntegrationsLayout, { children: [_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-2xl", children: "The services this server works with. A setting saved here takes effect at once and overrides the deployment\u2019s configuration until it is reset." }), _jsx("ul", { "aria-label": "Integration areas", className: "grid gap-3 sm:grid-cols-2", children: INTEGRATION_AREAS.map((area) => (_jsx("li", { children: _jsxs("a", { href: area.href, className: "block h-full border border-border rounded-sm bg-surface p-4 hover:border-primary", children: [_jsx("span", { className: "block font-semibold text-primary-dark", children: area.label }), _jsx("span", { className: "block text-sm text-text-muted mt-1", children: area.description })] }) }, area.id))) })] }) }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import PluginRegistryForm from "../../../shared/components/admin/integrations/PluginRegistryForm.js";
|
|
3
|
+
import IntegrationSectionPage from "../../../shared/components/admin/integrations/IntegrationSectionPage.js";
|
|
4
|
+
export default function PluginRegistryPage(props) {
|
|
5
|
+
return (_jsx(IntegrationSectionPage, { ...props, area: "pluginRegistry", children: (settings, onChange) => _jsx(PluginRegistryForm, { settings: settings, onChange: onChange }) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import SigningCaForm from "../../../shared/components/admin/integrations/SigningCaForm.js";
|
|
3
|
+
import IntegrationSectionPage from "../../../shared/components/admin/integrations/IntegrationSectionPage.js";
|
|
4
|
+
export default function SigningCaPage(props) {
|
|
5
|
+
return (_jsx(IntegrationSectionPage, { ...props, area: "signingCa", children: (settings, onChange) => _jsx(SigningCaForm, { settings: settings, onChange: onChange }) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import StunForm from "../../../shared/components/admin/integrations/StunForm.js";
|
|
3
|
+
import IntegrationSectionPage from "../../../shared/components/admin/integrations/IntegrationSectionPage.js";
|
|
4
|
+
export default function StunPage(props) {
|
|
5
|
+
return (_jsx(IntegrationSectionPage, { ...props, area: "stun", children: (settings, onChange) => _jsx(StunForm, { settings: settings, onChange: onChange }) }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/** How often the progress of a rotation in progress is read again. The server's job looks at a rotation every 30 seconds, and the copies report every 30. */
|
|
3
|
+
export declare const POLL_MS = 10000;
|
|
4
|
+
/**
|
|
5
|
+
* The configuration keyring's console (spec 5.5, 10.5, 14): the keys with their age and how many saved values each encrypts, "Rotate configuration key" and
|
|
6
|
+
* "Key compromised", a rotation's progress with the server copies it waits for, the warning once the wait has gone on too long and "Activate anyway", the plugin
|
|
7
|
+
* secret settings' encryption, and the reminders to back up the keyring and, after a compromise, to replace the credentials at their providers.
|
|
8
|
+
*/
|
|
9
|
+
export default function ConfigKeysManager(): React.JSX.Element;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { activateConfigKey, configKeysOfRefusal, getConfigKeys, rotateConfigKey, } from "../../../../../lib/admin/configKeysApi.js";
|
|
8
|
+
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
9
|
+
import Modal from "../../../../../lib/components/overlays/Modal.js";
|
|
10
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
11
|
+
import Alert from "../ActionAlert.js";
|
|
12
|
+
import { actionErrorMessage } from "../elevation.js";
|
|
13
|
+
import { Badge, formatTime } from "../integrations/dnsProviderParts.js";
|
|
14
|
+
import { activeKidOf, ageText, isRunning } from "./keyringParts.js";
|
|
15
|
+
/** How often the progress of a rotation in progress is read again. The server's job looks at a rotation every 30 seconds, and the copies report every 30. */
|
|
16
|
+
export const POLL_MS = 10000;
|
|
17
|
+
function copiesText(count) {
|
|
18
|
+
return count === 1 ? "1 copy" : `${count} copies`;
|
|
19
|
+
}
|
|
20
|
+
/** The server copies a rotation is waiting for, one per line. */
|
|
21
|
+
function CopyList({ label, copies }) {
|
|
22
|
+
return (_jsx("ul", { "aria-label": label, className: "list-disc pl-5 text-sm mb-3", children: copies.map((copy) => (_jsx("li", { children: _jsx("code", { className: "text-xs", children: copy }) }, copy))) }));
|
|
23
|
+
}
|
|
24
|
+
function Note({ children }) {
|
|
25
|
+
return (_jsx("div", { role: "note", className: "text-sm rounded-sm py-3 px-3.5 mb-3 bg-warning text-warning-contrast", children: children }));
|
|
26
|
+
}
|
|
27
|
+
function Card({ title, children }) {
|
|
28
|
+
return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6 mb-5", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: title }), children] }));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The configuration keyring's console (spec 5.5, 10.5, 14): the keys with their age and how many saved values each encrypts, "Rotate configuration key" and
|
|
32
|
+
* "Key compromised", a rotation's progress with the server copies it waits for, the warning once the wait has gone on too long and "Activate anyway", the plugin
|
|
33
|
+
* secret settings' encryption, and the reminders to back up the keyring and, after a compromise, to replace the credentials at their providers.
|
|
34
|
+
*/
|
|
35
|
+
export default function ConfigKeysManager() {
|
|
36
|
+
const [keys, setKeys] = useState(null);
|
|
37
|
+
// `true` where the server has no key route (an older server).
|
|
38
|
+
const [missing, setMissing] = useState(false);
|
|
39
|
+
const [loadError, setLoadError] = useState(null);
|
|
40
|
+
// The failure of a read after the first, which leaves what is shown as it was.
|
|
41
|
+
const [refreshError, setRefreshError] = useState(null);
|
|
42
|
+
// The copies that were waiting when "Activate anyway" was opened: the dialog names what the administrator was shown, not what a later read says.
|
|
43
|
+
const [staleCopies, setStaleCopies] = useState([]);
|
|
44
|
+
const [busy, setBusy] = useState(false);
|
|
45
|
+
const [error, setError] = useState(null);
|
|
46
|
+
// What the server's refusal of a stale request taught: the progress shown is the rotation's, not the one the administrator expected.
|
|
47
|
+
const [notice, setNotice] = useState(null);
|
|
48
|
+
const [confirming, setConfirming] = useState(null);
|
|
49
|
+
// Counts every finished read, so a failed poll is tried again too.
|
|
50
|
+
const [reads, setReads] = useState(0);
|
|
51
|
+
const latest = useRef(0);
|
|
52
|
+
// Whether a read has been answered: a missing route then means an older pod answered, not that the server has none.
|
|
53
|
+
const loaded = useRef(false);
|
|
54
|
+
// Reads the keyring's state; an answer to an older request is dropped. The failure of the first read replaces the page; a later one is noted above it.
|
|
55
|
+
function load() {
|
|
56
|
+
const id = ++latest.current;
|
|
57
|
+
return getConfigKeys()
|
|
58
|
+
.then((answer) => {
|
|
59
|
+
if (id === latest.current) {
|
|
60
|
+
loaded.current = true;
|
|
61
|
+
setKeys(answer);
|
|
62
|
+
setLoadError(null);
|
|
63
|
+
setRefreshError(null);
|
|
64
|
+
}
|
|
65
|
+
}, (err) => {
|
|
66
|
+
if (id !== latest.current) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (loaded.current) {
|
|
70
|
+
setRefreshError(actionErrorMessage(err, "The server did not answer."));
|
|
71
|
+
}
|
|
72
|
+
else if (err instanceof ApiRequestError && err.status === 404) {
|
|
73
|
+
setMissing(true);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
setLoadError(actionErrorMessage(err, "Could not load the configuration keys."));
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.then(() => setReads((count) => count + 1));
|
|
80
|
+
}
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
void load();
|
|
83
|
+
// A read still on its way when the page is left must not set state.
|
|
84
|
+
return () => void latest.current++;
|
|
85
|
+
}, []);
|
|
86
|
+
const rotation = keys?.rotation;
|
|
87
|
+
const running = isRunning(rotation);
|
|
88
|
+
// While a rotation runs the copies and the server's job move it along, so the page follows.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!running) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const timer = setTimeout(() => void load(), POLL_MS);
|
|
94
|
+
return () => clearTimeout(timer);
|
|
95
|
+
}, [running, reads]);
|
|
96
|
+
if (missing) {
|
|
97
|
+
return (_jsx("p", { className: "text-sm text-text-muted", children: "This server doesn\u2019t offer configuration encryption yet. Upgrade it to manage its keys here." }));
|
|
98
|
+
}
|
|
99
|
+
if (!keys) {
|
|
100
|
+
return loadError ? (_jsxs(_Fragment, { children: [_jsx(Alert, { children: loadError }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => void load(), children: "Try again" })] })) : (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }));
|
|
101
|
+
}
|
|
102
|
+
async function run(action, fallback, refused) {
|
|
103
|
+
setBusy(true);
|
|
104
|
+
setError(null);
|
|
105
|
+
setNotice(null);
|
|
106
|
+
try {
|
|
107
|
+
const answer = await action();
|
|
108
|
+
// Drops a read that was on its way before this change.
|
|
109
|
+
latest.current++;
|
|
110
|
+
loaded.current = true;
|
|
111
|
+
setKeys(answer);
|
|
112
|
+
setRefreshError(null);
|
|
113
|
+
setConfirming(null);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// A 409 carries the rotation as it is: the request was for a state it is no longer in.
|
|
117
|
+
const current = configKeysOfRefusal(err);
|
|
118
|
+
if (current) {
|
|
119
|
+
latest.current++;
|
|
120
|
+
loaded.current = true;
|
|
121
|
+
setKeys(current);
|
|
122
|
+
setRefreshError(null);
|
|
123
|
+
setConfirming(null);
|
|
124
|
+
setNotice(refused);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
setError(actionErrorMessage(err, fallback));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
setBusy(false);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// The refusal a dialog shows belongs to it, so it goes with it rather than moving to the card.
|
|
135
|
+
function closeDialog() {
|
|
136
|
+
if (!busy) {
|
|
137
|
+
setConfirming(null);
|
|
138
|
+
setError(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function openDialog(which) {
|
|
142
|
+
setError(null);
|
|
143
|
+
setStaleCopies(waiting);
|
|
144
|
+
setConfirming(which);
|
|
145
|
+
}
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const newKid = rotation?.kid;
|
|
148
|
+
const waiting = rotation?.waitingFor ?? [];
|
|
149
|
+
const counted = keys.keys.some((key) => key.envelopes !== undefined);
|
|
150
|
+
const earlier = keys.keys.filter((key) => key.kid !== newKid).reduce((sum, key) => sum + (key.envelopes ?? 0), 0);
|
|
151
|
+
const onNew = keys.keys.filter((key) => key.kid === newKid).reduce((sum, key) => sum + (key.envelopes ?? 0), 0);
|
|
152
|
+
const unreadable = keys.reencryption?.unreadable ?? [];
|
|
153
|
+
const activeKid = activeKidOf(keys);
|
|
154
|
+
// A compromised rotation that failed: starting again from the plain button would drop the compromise.
|
|
155
|
+
const failedCompromised = rotation?.phase === "failed" && rotation.compromised;
|
|
156
|
+
let step;
|
|
157
|
+
switch (rotation?.phase) {
|
|
158
|
+
case "adding":
|
|
159
|
+
step = _jsx("p", { className: "text-sm mb-3", children: "Adding the new key to the keyring." });
|
|
160
|
+
break;
|
|
161
|
+
case "activating":
|
|
162
|
+
step =
|
|
163
|
+
waiting.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm mb-2", children: `Waiting for ${copiesText(waiting.length)} to load the new key` }), _jsx(CopyList, { label: "Copies that haven't loaded the new key", copies: waiting }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Nothing breaks while it waits: the current key keeps encrypting, and the new one is used as soon as every copy has it." })] })) : (_jsx("p", { className: "text-sm mb-3", children: "Activating the new key." }));
|
|
164
|
+
break;
|
|
165
|
+
case "reencrypting":
|
|
166
|
+
step = (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm mb-2", children: "Re-encrypting saved values with the new key." }), counted && (_jsx("p", { className: "text-sm text-text-muted mb-3", children: `${earlier === 1 ? "1 value is" : `${earlier} values are`} still on an earlier key; ${onNew} ${onNew === 1 ? "is" : "are"} on the new one.` })), waiting.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm mb-2", children: `Waiting for ${copiesText(waiting.length)} to start using the new key` }), _jsx(CopyList, { label: "Copies that don't yet use the new key", copies: waiting }), rotation?.activationForced && (_jsx("p", { className: "text-sm text-text-muted mb-3", children: "The key was activated before every copy had loaded it, so values are re-encrypted only once these copies have loaded it or are replaced." }))] }))] }));
|
|
167
|
+
break;
|
|
168
|
+
case "retiring":
|
|
169
|
+
step = _jsx("p", { className: "text-sm mb-3", children: "Removing the old key from the keyring." });
|
|
170
|
+
break;
|
|
171
|
+
case "failed":
|
|
172
|
+
step = _jsx("p", { className: "text-sm mb-3", children: "The last rotation failed." });
|
|
173
|
+
break;
|
|
174
|
+
case "done":
|
|
175
|
+
step = (_jsx("p", { className: "text-sm mb-3", children: rotation.finishedAt
|
|
176
|
+
? `The last rotation finished ${formatTime(rotation.finishedAt)}.`
|
|
177
|
+
: "The last rotation has finished." }));
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
step = _jsx("p", { className: "text-sm mb-3", children: "No rotation is in progress." });
|
|
181
|
+
}
|
|
182
|
+
return (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-2xl", children: "The credentials, API keys and tokens saved in this console, and the secret settings of plugins, are encrypted with these keys, so a copy of the database alone shows none of them. A key is only ever replaced by an administrator, here." }), _jsxs(Card, { title: "Keys", children: [refreshError && (_jsxs(Alert, { children: ["Couldn\u2019t refresh the progress; what is shown may be out of date, and it is tried again every few seconds. ", refreshError] })), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse mb-3", children: [_jsx("thead", { children: _jsx("tr", { children: ["Key", "Created", "Age", "Values"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: keys.keys.map((key) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [_jsx("code", { className: "text-xs", children: key.kid }), key.kid === activeKid && _jsx(Badge, { children: "Active" })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: new Date(key.createdAt).toLocaleDateString() }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: ageText(key.createdAt, now) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: key.envelopes ?? "Not counted yet" })] }, key.kid))) })] }) }), keys.countedAt && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Values were last counted ", formatTime(keys.countedAt), "."] }))] }), _jsxs(Card, { title: "Rotation", children: [!keys.available && (_jsx(Alert, { children: "Keys can\u2019t be rotated on this server: its keyring isn\u2019t kept where the server can change it, or its copies can\u2019t be coordinated. See the deployment\u2019s configuration encryption settings." })), step, running && (_jsx("p", { className: "text-sm text-text-muted mb-3", children: `Started ${formatTime(rotation.requestedAt)}${rotation.compromised ? ", reported as a key compromise" : ""}.` })), rotation?.warning && _jsx(Note, { children: rotation.warning }), rotation?.lastError && _jsx(Alert, { children: rotation.lastError }), rotation?.compromised && (_jsx(Note, { children: "Because the keyring may have been exposed, replace the credentials saved here at their providers (DNS provider tokens, the Giphy key, registry tokens, plugin secrets), then enter the new ones. A keyring exposed together with a copy of the database has exposed the saved values themselves; rotating the key doesn\u2019t undo that." })), failedCompromised && (_jsx(Note, { children: "The failed rotation was reported as a key compromise. Starting again with \u201CRotate configuration key\u201D doesn\u2019t carry that over: use \u201CKey compromised: rotate now\u201D to keep it." })), rotation?.phase === "done" && (_jsx(Note, { children: "Back up the keyring again now. A copy taken before this rotation doesn\u2019t have the new key, so it can\u2019t read the values saved after it." })), notice && (_jsx("p", { role: "status", className: "text-sm text-text-muted mb-3", children: notice })), error && confirming === null && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!running && (_jsx(Button, { type: "button", className: "!w-auto", disabled: busy || !keys.available, onClick: () => void run(() => rotateConfigKey(), "Could not start the rotation.", "A rotation was already in progress; its progress is shown."), children: "Rotate configuration key" })), (!running || !rotation.compromised) && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", disabled: busy || !keys.available, onClick: () => openDialog("compromised"), children: "Key compromised: rotate now" })), rotation?.phase === "activating" && newKid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => openDialog("activate"), children: "Activate anyway" }))] }), _jsxs("p", { className: "text-xs text-text-muted mt-4", children: ["A database backup can\u2019t be read without the keyring: back up the keyring with it (the Secret ending in ", _jsx("code", { children: "-config-keyring" }), ", or its OpenBao entry), and keep each keyring backup as long as the database backups it serves."] })] }), unreadable.length > 0 && (_jsxs(Card, { title: "Values that can't be read", children: [_jsx("p", { className: "text-sm mb-2", children: "These saved values couldn\u2019t be decrypted with any key in the keyring (a database restored with an older keyring, for example). Enter each again where it is set." }), _jsx("p", { className: "text-sm text-text-muted mb-2", children: keys.reencryption?.finishedAt
|
|
183
|
+
? `This list was made by the re-encryption pass that finished ${formatTime(keys.reencryption.finishedAt)}; only a later pass updates it, so a value entered again stays listed until then.`
|
|
184
|
+
: "This list was made by the last re-encryption pass; only a later pass updates it, so a value entered again stays listed until then." }), _jsx("ul", { className: "list-disc pl-5 text-sm", children: unreadable.map((item) => (_jsx("li", { children: `${item.record} ${item.uid}, ${item.field} (key ${item.kid})` }, `${item.record}/${item.uid}/${item.field}`))) })] })), _jsx(Card, { title: "Plugin secret settings", children: keys.pluginSecrets?.encryptedAt ? (_jsx("p", { className: "text-sm", children: `Encrypted since ${formatTime(keys.pluginSecrets.encryptedAt)}.` })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm mb-2", children: "Not encrypted yet. Secret settings of plugins are saved as plain text until every server pod runs this release and the first encryption run has finished." }), keys.pluginSecrets?.reason && (_jsx("p", { className: "text-sm text-text-muted", children: keys.pluginSecrets.reason }))] })) }), _jsxs(Modal, { open: confirming === "compromised", onClose: closeDialog, title: "Key compromised", children: [_jsx("p", { className: "text-sm mb-3", children: running
|
|
185
|
+
? "The rotation in progress is marked as a key compromise: the old key is removed as soon as every saved value has moved to the new one, with no waiting."
|
|
186
|
+
: "A new key is created and every saved value is re-encrypted with it, and the old key is removed as soon as nothing uses it." }), _jsx("p", { className: "text-sm mb-3", children: "If the keyring was exposed together with a copy of the database, the saved values were exposed too: afterwards, replace the credentials at their providers and enter the new ones here." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: closeDialog, children: "Back" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy, onClick: () => void run(() => rotateConfigKey({ compromised: true }), "Could not start the rotation.", "A rotation was already in progress and is already marked as a key compromise; its progress is shown."), children: "Rotate now" })] })] }), _jsxs(Modal, { open: confirming === "activate", onClose: closeDialog, title: "Activate the new key anyway", children: [staleCopies.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm mb-2", children: "The new key encrypts from now on, before every server copy has loaded it. These copies still encrypt with the old key, which every copy can read, but they can\u2019t read values saved with the new key until they are replaced:" }), _jsx(CopyList, { label: "Copies that will not be able to read new values", copies: staleCopies }), _jsx("p", { className: "text-sm mb-3", children: "Saved values are re-encrypted only once those copies are gone or have loaded the key. Do this only if they are being replaced." })] })) : (_jsx("p", { className: "text-sm mb-3", children: "No copy is waiting for the new key now; it is activated on its own within a moment, so there is nothing to force." })), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: closeDialog, children: "Back" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || !newKid, onClick: () => void run(() => activateConfigKey(newKid), "Could not activate the new key.", "The new key is no longer waiting to be activated; the rotation's progress is shown."), children: "Activate anyway" })] })] })] }));
|
|
187
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ConfigKeyRotation, ConfigKeys } from "../../../../../lib/admin/configKeysApi.js";
|
|
2
|
+
/** Whole days from `iso` to `now`, as the age of a key. */
|
|
3
|
+
export declare function ageText(iso: string, now: number): string;
|
|
4
|
+
/**
|
|
5
|
+
* The key the deployment encrypts with. A key's own `active` is only what the copy that answered encrypts with, which differs between copies while they
|
|
6
|
+
* reload the keyring, so once the rotation has activated its new key that is the one, and before then it is the one the copy reports other than the new key.
|
|
7
|
+
*/
|
|
8
|
+
export declare function activeKidOf(keys: ConfigKeys): string | null;
|
|
9
|
+
/** Whether `rotation` is still going: a finished or failed one is only shown. */
|
|
10
|
+
export declare function isRunning(rotation: ConfigKeyRotation | null | undefined): rotation is ConfigKeyRotation;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
/** Whole days from `iso` to `now`, as the age of a key. */
|
|
3
|
+
export function ageText(iso, now) {
|
|
4
|
+
const days = Math.max(0, Math.floor((now - new Date(iso).getTime()) / DAY_MS));
|
|
5
|
+
return days < 1 ? "less than a day" : days === 1 ? "1 day" : `${days} days`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The key the deployment encrypts with. A key's own `active` is only what the copy that answered encrypts with, which differs between copies while they
|
|
9
|
+
* reload the keyring, so once the rotation has activated its new key that is the one, and before then it is the one the copy reports other than the new key.
|
|
10
|
+
*/
|
|
11
|
+
export function activeKidOf(keys) {
|
|
12
|
+
const rotation = keys.rotation;
|
|
13
|
+
if (rotation?.kid && rotation.activatedAt) {
|
|
14
|
+
return rotation.kid;
|
|
15
|
+
}
|
|
16
|
+
if (rotation?.kid && (rotation.phase === "adding" || rotation.phase === "activating")) {
|
|
17
|
+
return (keys.keys.find((key) => key.active && key.kid !== rotation.kid)?.kid ??
|
|
18
|
+
keys.keys.find((key) => key.kid !== rotation.kid)?.kid ??
|
|
19
|
+
null);
|
|
20
|
+
}
|
|
21
|
+
return keys.keys.find((key) => key.active)?.kid ?? keys.activeKid;
|
|
22
|
+
}
|
|
23
|
+
/** Whether `rotation` is still going: a finished or failed one is only shown. */
|
|
24
|
+
export function isRunning(rotation) {
|
|
25
|
+
return !!rotation && rotation.phase !== "done" && rotation.phase !== "failed";
|
|
26
|
+
}
|
|
@@ -8,9 +8,11 @@ import { HiOutlineArrowDownTray, HiOutlineArrowPath } from "react-icons/hi2";
|
|
|
8
8
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
9
9
|
import { getDiagnosticsInformation, getDiagnosticsMetrics, getDiagnosticsRuntime, getDiagnosticsVersions, } from "./diagnosticsApi.js";
|
|
10
10
|
import { saveTextFile, timestampedFilename } from "./download.js";
|
|
11
|
+
import { activeKidOf } from "../configEncryption/keyringParts.js";
|
|
11
12
|
import { describeError } from "./format.js";
|
|
12
13
|
import InformationPanel from "./InformationPanel.js";
|
|
13
14
|
import { loadInstalledPlugins } from "./InstalledPlugins.js";
|
|
15
|
+
import { loadKeyring } from "./KeyringCard.js";
|
|
14
16
|
import LogsPanel from "./LogsPanel.js";
|
|
15
17
|
import RuntimePanel from "./RuntimePanel.js";
|
|
16
18
|
import SystemPanel from "./SystemPanel.js";
|
|
@@ -39,6 +41,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
|
|
|
39
41
|
const information = useDiagnosticsResource(getDiagnosticsInformation, reloadKey, "Could not read the server's environment and configuration.");
|
|
40
42
|
const runtime = useDiagnosticsResource(getDiagnosticsRuntime, reloadKey, "Could not read the runtime.");
|
|
41
43
|
const plugins = useDiagnosticsResource(loadInstalledPlugins, reloadKey, "Could not read the installed plugins.");
|
|
44
|
+
const keyring = useDiagnosticsResource(loadKeyring, reloadKey, "Could not read the configuration keyring.");
|
|
42
45
|
const polling = useMetricsPolling(tab === "system");
|
|
43
46
|
const logStream = useLogStream({ createSocket: createLogSocket });
|
|
44
47
|
const logsOpened = useRef(false);
|
|
@@ -49,7 +52,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
|
|
|
49
52
|
startLogs();
|
|
50
53
|
}
|
|
51
54
|
}, [tab]);
|
|
52
|
-
const refreshing = versions.loading || information.loading || runtime.loading || plugins.loading;
|
|
55
|
+
const refreshing = versions.loading || information.loading || runtime.loading || plugins.loading || keyring.loading;
|
|
53
56
|
function handleTabKeyDown(event) {
|
|
54
57
|
const index = TABS.findIndex((item) => item.id === tab);
|
|
55
58
|
let next;
|
|
@@ -73,8 +76,8 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
|
|
|
73
76
|
document.getElementById(tabId(TABS[next].id))?.focus();
|
|
74
77
|
}
|
|
75
78
|
/**
|
|
76
|
-
* Saves the versions, the environment and configuration (the server has already withheld every secret), the runtime
|
|
77
|
-
* latest metrics sample (asking for one if the System tab has not sampled): no logs.
|
|
79
|
+
* Saves the versions, the environment and configuration (the server has already withheld every secret), the runtime, the
|
|
80
|
+
* keyring's key ids, ages and counts and the latest metrics sample (asking for one if the System tab has not sampled): no logs.
|
|
78
81
|
*/
|
|
79
82
|
async function downloadReport() {
|
|
80
83
|
setPreparingReport(true);
|
|
@@ -101,17 +104,30 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
|
|
|
101
104
|
status: plugins.data.status ?? null,
|
|
102
105
|
}
|
|
103
106
|
: null,
|
|
107
|
+
// The key ids, when each was made and how many values it encrypts: never anything of a key itself, which the server doesn't send.
|
|
108
|
+
keyring: keyring.data
|
|
109
|
+
? {
|
|
110
|
+
activeKid: activeKidOf({ ...keyring.data, keys: keyring.data.keys ?? [] }),
|
|
111
|
+
countedAt: keyring.data.countedAt ?? null,
|
|
112
|
+
keys: (keyring.data.keys ?? []).map(({ kid, createdAt, envelopes }) => ({ kid, createdAt, envelopes })),
|
|
113
|
+
rotationPhase: keyring.data.rotation?.phase ?? null,
|
|
114
|
+
// As of the re-encryption pass that made the list: a value entered again stays counted until a later pass.
|
|
115
|
+
unreadableValues: keyring.data.reencryption?.unreadable?.length ?? 0,
|
|
116
|
+
unreadableAsOf: keyring.data.reencryption?.finishedAt ?? null,
|
|
117
|
+
}
|
|
118
|
+
: null,
|
|
104
119
|
metrics: metrics ?? null,
|
|
105
120
|
errors: {
|
|
106
121
|
versions: versions.error ?? null,
|
|
107
122
|
information: information.error ?? null,
|
|
108
123
|
runtime: runtime.error ?? null,
|
|
109
124
|
plugins: plugins.error ?? null,
|
|
125
|
+
keyring: keyring.error ?? null,
|
|
110
126
|
metrics: metricsError ?? null,
|
|
111
127
|
},
|
|
112
128
|
};
|
|
113
129
|
saveFile(timestampedFilename("rapidmx-diagnostics", "json", generatedAt), `${JSON.stringify(report, null, 2)}\n`, "application/json");
|
|
114
130
|
setPreparingReport(false);
|
|
115
131
|
}
|
|
116
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-5 flex flex-wrap items-center justify-between gap-3", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Diagnostics" }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: refreshing, onClick: () => setReloadKey(reloadKey + 1), children: [_jsx(HiOutlineArrowPath, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Refresh"] }), _jsxs(Button, { type: "button", className: "!w-auto", loading: preparingReport, onClick: () => void downloadReport(), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Download diagnostics report"] })] })] }), _jsx("div", { role: "tablist", "aria-label": "Diagnostics sections", className: "mb-5 flex gap-1 overflow-x-auto rounded-lg bg-surface-alt p-1", children: TABS.map((item) => (_jsx("button", { type: "button", role: "tab", id: tabId(item.id), "aria-selected": item.id === tab, "aria-controls": panelId(item.id), tabIndex: item.id === tab ? 0 : -1, onClick: () => setTab(item.id), onKeyDown: handleTabKeyDown, className: `flex-1 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ${item.id === tab ? "bg-surface text-text shadow-card" : "text-text-muted hover:text-text"}`, children: item.label }, item.id))) }), _jsxs("div", { role: "tabpanel", id: panelId(tab), "aria-labelledby": tabId(tab), children: [tab === "information" && _jsx(InformationPanel, { versions: versions, plugins: plugins, information: information }), tab === "runtime" && _jsx(RuntimePanel, { runtime: runtime }), tab === "system" && _jsx(SystemPanel, { polling: polling }), tab === "logs" && _jsx(LogsPanel, { stream: logStream, saveFile: saveFile })] })] }));
|
|
132
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-5 flex flex-wrap items-center justify-between gap-3", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Diagnostics" }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: refreshing, onClick: () => setReloadKey(reloadKey + 1), children: [_jsx(HiOutlineArrowPath, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Refresh"] }), _jsxs(Button, { type: "button", className: "!w-auto", loading: preparingReport, onClick: () => void downloadReport(), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Download diagnostics report"] })] })] }), _jsx("div", { role: "tablist", "aria-label": "Diagnostics sections", className: "mb-5 flex gap-1 overflow-x-auto rounded-lg bg-surface-alt p-1", children: TABS.map((item) => (_jsx("button", { type: "button", role: "tab", id: tabId(item.id), "aria-selected": item.id === tab, "aria-controls": panelId(item.id), tabIndex: item.id === tab ? 0 : -1, onClick: () => setTab(item.id), onKeyDown: handleTabKeyDown, className: `flex-1 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ${item.id === tab ? "bg-surface text-text shadow-card" : "text-text-muted hover:text-text"}`, children: item.label }, item.id))) }), _jsxs("div", { role: "tabpanel", id: panelId(tab), "aria-labelledby": tabId(tab), children: [tab === "information" && _jsx(InformationPanel, { versions: versions, plugins: plugins, information: information, keyring: keyring }), tab === "runtime" && _jsx(RuntimePanel, { runtime: runtime }), tab === "system" && _jsx(SystemPanel, { polling: polling }), tab === "logs" && _jsx(LogsPanel, { stream: logStream, saveFile: saveFile })] })] }));
|
|
117
133
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import type { ConfigKeys } from "../../../../../lib/admin/configKeysApi.js";
|
|
2
3
|
import type { DiagnosticsInformation, DiagnosticsVersions } from "./diagnosticsApi.js";
|
|
3
4
|
import type { DiagnosticsResource } from "./useDiagnosticsResource.js";
|
|
4
5
|
import { InstalledPluginsData } from "./InstalledPlugins.js";
|
|
@@ -6,10 +7,13 @@ export interface InformationPanelProps {
|
|
|
6
7
|
versions: DiagnosticsResource<DiagnosticsVersions>;
|
|
7
8
|
plugins: DiagnosticsResource<InstalledPluginsData>;
|
|
8
9
|
information: DiagnosticsResource<DiagnosticsInformation>;
|
|
10
|
+
/** `null` when the server has no keyring route. */
|
|
11
|
+
keyring: DiagnosticsResource<ConfigKeys | null>;
|
|
9
12
|
}
|
|
10
13
|
/**
|
|
11
|
-
* The Information tab: the server, the install's other containers, the installed plugins, the server's environment
|
|
12
|
-
* configuration (secrets withheld by the server),
|
|
13
|
-
*
|
|
14
|
+
* The Information tab: the server, the install's other containers (DNS publishing's among them), the installed plugins, the server's environment
|
|
15
|
+
* variables and configuration (secrets withheld by the server), what the Integrations pages saved as a source of its own, the configuration keyring and
|
|
16
|
+
* every installed package. The environment and configuration come from their own request, and so does the keyring, so a server that predates them
|
|
17
|
+
* leaves the rest of the tab working and says so in their place.
|
|
14
18
|
*/
|
|
15
|
-
export default function InformationPanel({ versions, plugins, information }: InformationPanelProps): React.JSX.Element;
|
|
19
|
+
export default function InformationPanel({ versions, plugins, information, keyring }: InformationPanelProps): React.JSX.Element;
|
|
@@ -2,15 +2,17 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import Alert from "../ActionAlert.js";
|
|
3
3
|
import ComponentsTable from "./ComponentsTable.js";
|
|
4
4
|
import InstalledPlugins from "./InstalledPlugins.js";
|
|
5
|
+
import KeyringCard from "./KeyringCard.js";
|
|
5
6
|
import PackagesTable from "./PackagesTable.js";
|
|
6
7
|
import ServerVersionCard from "./ServerVersionCard.js";
|
|
7
8
|
import SettingsTable from "./SettingsTable.js";
|
|
8
9
|
/**
|
|
9
|
-
* The Information tab: the server, the install's other containers, the installed plugins, the server's environment
|
|
10
|
-
* configuration (secrets withheld by the server),
|
|
11
|
-
*
|
|
10
|
+
* The Information tab: the server, the install's other containers (DNS publishing's among them), the installed plugins, the server's environment
|
|
11
|
+
* variables and configuration (secrets withheld by the server), what the Integrations pages saved as a source of its own, the configuration keyring and
|
|
12
|
+
* every installed package. The environment and configuration come from their own request, and so does the keyring, so a server that predates them
|
|
13
|
+
* leaves the rest of the tab working and says so in their place.
|
|
12
14
|
*/
|
|
13
|
-
export default function InformationPanel({ versions, plugins, information }) {
|
|
15
|
+
export default function InformationPanel({ versions, plugins, information, keyring }) {
|
|
14
16
|
const { data, error, loading } = versions;
|
|
15
|
-
return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent.", settings: information.data.environment ?? [] }), _jsx(SettingsTable, { id: "diagnostics-configuration", title: "Configuration", description: "The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs.", settings: information.data.configuration ?? [] })] })), _jsx(PackagesTable, { packages: data.packages ?? [] })] }))
|
|
17
|
+
return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent.", settings: information.data.environment ?? [] }), _jsx(SettingsTable, { id: "diagnostics-configuration", title: "Configuration", description: "The effective settings the server runs with: the environment, saved plugin settings, what the Integrations pages saved and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs.", settings: information.data.configuration ?? [] }), information.data.integrations && (_jsx(SettingsTable, { id: "diagnostics-integrations", title: "Integrations", description: "Saved in the Integrations pages of the admin console and applied without a restart. Each of these overrides the deployment's own value of the same name above. A secret shows only that it is set.", settings: information.data.integrations, secrets: "set", emptyText: "Nothing is saved in the Integrations pages." }))] }))] })), _jsx(KeyringCard, { keyring: keyring }), data && _jsx(PackagesTable, { packages: data.packages ?? [] })] }));
|
|
16
18
|
}
|